refactor: enhance permission handling and introduce new Permission and Policy classes

- Updated the `Guard` class to improve permission checking by utilizing the new `Permission` class.
- Refactored tests in `authorize.spec.ts` to use `Permission` instances instead of strings for better type safety.
- Introduced a new `permissions.spec.ts` file to test the functionality of the `Permission` and `Policy` classes.
- Enhanced the `recursivelyReplacePlaceholders` utility function to support various object structures and types.
- Updated middleware and controller files to align with the new permission handling structure.
This commit is contained in:
dswbx
2025-10-03 20:22:42 +02:00
parent db58911df3
commit 90f93caff4
14 changed files with 432 additions and 51 deletions

View File

@@ -60,7 +60,8 @@ export class AuthController extends Controller {
if (create) {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
permission(AuthPermissions.createUser),
permission(DataPermissions.entityCreate),
describeRoute({
summary: "Create a new user",
tags: ["auth"],

View File

@@ -1,5 +1,5 @@
import { Exception } from "core/errors";
import { $console, objectTransform } from "bknd/utils";
import { $console, objectTransform, type s } from "bknd/utils";
import { Permission } from "core/security/Permission";
import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller";
@@ -12,6 +12,7 @@ export type GuardUserContext = {
export type GuardConfig = {
enabled?: boolean;
context?: string;
};
export type GuardContext = Context<ServerEnv> | GuardUserContext;
@@ -26,6 +27,9 @@ export class Guard {
this.config = config;
}
/**
* @deprecated
*/
static create(
permissionNames: string[],
roles?: Record<
@@ -156,12 +160,25 @@ export class Guard {
return !!rolePermission;
}
granted(permission: Permission | string, c?: GuardContext): boolean {
granted<P extends Permission>(
permission: P,
c?: GuardContext,
context: s.Static<P["context"]> = {} as s.Static<P["context"]>,
): boolean {
const user = c && "get" in c ? c.get("auth")?.user : c;
return this.hasPermission(permission as any, user);
const ctx = {
...context,
user,
context: this.config?.context,
};
return this.hasPermission(permission, user);
}
throwUnlessGranted(permission: Permission | string, c: GuardContext) {
throwUnlessGranted<P extends Permission>(
permission: P,
c: GuardContext,
context: s.Static<P["context"]>,
) {
if (!this.granted(permission, c)) {
throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,

View File

@@ -1,8 +1,9 @@
import type { Permission } from "core/security/Permission";
import { $console, patternMatch } from "bknd/utils";
import { $console, patternMatch, type s } from "bknd/utils";
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Controller";
import type { MaybePromise } from "core/types";
function getPath(reqOrCtx: Request | Context) {
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
@@ -49,7 +50,7 @@ export const auth = (options?: {
// make sure to only register once
if (authCtx.registered) {
skipped = true;
$console.warn(`auth middleware already registered for ${getPath(c)}`);
$console.debug(`auth middleware already registered for ${getPath(c)}`);
} else {
authCtx.registered = true;
@@ -68,11 +69,12 @@ export const auth = (options?: {
authCtx.user = undefined;
});
export const permission = (
permission: Permission | Permission[],
export const permission = <P extends Permission>(
permission: P,
options?: {
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
onGranted?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
context?: (c: Context<ServerEnv>) => MaybePromise<s.Static<P["context"]>>;
},
) =>
// @ts-ignore
@@ -93,11 +95,11 @@ export const permission = (
}
} else if (!authCtx.skip) {
const guard = app.modules.ctx().guard;
const permissions = Array.isArray(permission) ? permission : [permission];
const context = (await options?.context?.(c)) ?? ({} as any);
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (permissions.every((p) => guard.granted(p, c))) {
if (guard.granted(permission, c, context)) {
returned = await options?.onGranted?.(c);
} else {
returned = await options?.onDenied?.(c);
@@ -106,7 +108,7 @@ export const permission = (
return returned;
}
} else {
permissions.some((p) => guard.throwUnlessGranted(p, c));
guard.throwUnlessGranted(permission, c, context);
}
}

View File

@@ -1,11 +1,95 @@
export class Permission<Name extends string = string> {
constructor(public name: Name) {
this.name = name;
import {
s,
type ParseOptions,
parse,
InvalidSchemaError,
recursivelyReplacePlaceholders,
} from "bknd/utils";
import * as query from "core/object/query/object-query";
export const permissionOptionsSchema = s
.strictObject({
description: s.string(),
filterable: s.boolean(),
})
.partial();
export type PermissionOptions = s.Static<typeof permissionOptionsSchema>;
export class InvalidPermissionContextError extends InvalidSchemaError {
override name = "InvalidPermissionContextError";
static from(e: InvalidSchemaError) {
return new InvalidPermissionContextError(e.schema, e.value, e.errors);
}
}
export class Permission<
Name extends string = string,
Options extends PermissionOptions = {},
Context extends s.ObjectSchema = s.ObjectSchema,
> {
constructor(
public name: Name,
public options: Options = {} as Options,
public context: Context = s.object({}) as Context,
) {}
parseContext(ctx: s.Static<Context>, opts?: ParseOptions) {
try {
return parse(this.context, ctx, opts);
} catch (e) {
if (e instanceof InvalidSchemaError) {
throw InvalidPermissionContextError.from(e);
}
throw e;
}
}
toJSON() {
return {
name: this.name,
...this.options,
context: this.context,
};
}
}
export const policySchema = s
.strictObject({
description: s.string(),
condition: s.object({}, { default: {} }) as s.Schema<{}, query.ObjectQuery>,
effect: s.string({ enum: ["allow", "deny", "filter"], default: "deny" }),
filter: s.object({}, { default: {} }) as s.Schema<{}, query.ObjectQuery>,
})
.partial();
export type PolicySchema = s.Static<typeof policySchema>;
export class Policy<Schema extends PolicySchema> {
public content: Schema;
constructor(content?: Schema) {
this.content = parse(policySchema, content ?? {}) as Schema;
}
replace(context: object, vars?: Record<string, any>) {
return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context;
}
meetsCondition(context: object, vars?: Record<string, any>) {
return query.validate(this.replace(this.content.condition!, vars), context);
}
meetsFilter(subject: object, vars?: Record<string, any>) {
return query.validate(this.replace(this.content.filter!, vars), subject);
}
getFiltered<Given extends any[]>(given: Given): Given {
return given.filter((item) => this.meetsFilter(item)) as Given;
}
toJSON() {
return this.content;
}
}

View File

@@ -512,3 +512,38 @@ export function convertNumberedObjectToArray(obj: object): any[] | object {
}
return obj;
}
export function recursivelyReplacePlaceholders(
obj: any,
pattern: RegExp,
variables: Record<string, any>,
) {
if (typeof obj === "string") {
// check if the entire string matches the pattern
const match = obj.match(pattern);
if (match && match[0] === obj && match[1]) {
// full string match - replace with the actual value (preserving type)
const key = match[1];
const value = getPath(variables, key);
return value !== undefined ? value : obj;
}
// partial match - use string replacement
if (pattern.test(obj)) {
return obj.replace(pattern, (match, key) => {
const value = getPath(variables, key);
// convert to string for partial replacements
return value !== undefined ? String(value) : match;
});
}
}
if (Array.isArray(obj)) {
return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables));
}
if (obj && typeof obj === "object") {
return Object.entries(obj).reduce((acc, [key, value]) => {
acc[key] = recursivelyReplacePlaceholders(value, pattern, variables);
return acc;
}, {} as object);
}
return obj;
}

View File

@@ -59,6 +59,8 @@ export const stringIdentifier = s.string({
});
export class InvalidSchemaError extends Error {
override name = "InvalidSchemaError";
constructor(
public schema: s.Schema,
public value: unknown,

View File

@@ -186,7 +186,8 @@ export class MediaController extends Controller {
}),
),
jsc("query", s.object({ overwrite: s.boolean().optional() })),
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
permission(DataPermissions.entityCreate),
permission(MediaPermissions.uploadFile),
async (c) => {
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");

View File

@@ -115,7 +115,7 @@ export class ModuleHelper {
}
async throwUnlessGranted(
permission: Permission | string,
permission: Permission,
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
) {
invariant(c.context.app, "app is not available in mcp context");

View File

@@ -1,10 +1,29 @@
import { Permission } from "core/security/Permission";
import { s } from "bknd/utils";
export const accessAdmin = new Permission("system.access.admin");
export const accessApi = new Permission("system.access.api");
export const configRead = new Permission("system.config.read");
export const configReadSecrets = new Permission("system.config.read.secrets");
export const configWrite = new Permission("system.config.write");
export const configRead = new Permission(
"system.config.read",
{},
s.object({
module: s.string().optional(),
}),
);
export const configReadSecrets = new Permission(
"system.config.read.secrets",
{},
s.object({
module: s.string().optional(),
}),
);
export const configWrite = new Permission(
"system.config.write",
{},
s.object({
module: s.string().optional(),
}),
);
export const schemaRead = new Permission("system.schema.read");
export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp");

View File

@@ -139,17 +139,18 @@ export class AdminController extends Controller {
}
if (auth_enabled) {
const options = {
onGranted: async (c) => {
// @todo: add strict test to permissions middleware?
if (c.get("auth")?.user) {
$console.log("redirecting to success");
return c.redirect(authRoutes.success);
}
},
};
const redirectRouteParams = [
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
// @ts-ignore
onGranted: async (c) => {
// @todo: add strict test to permissions middleware?
if (c.get("auth")?.user) {
$console.log("redirecting to success");
return c.redirect(authRoutes.success);
}
},
}),
permission(SystemPermissions.accessAdmin, options),
permission(SystemPermissions.schemaRead, options),
async (c) => {
return c.html(c.get("html")!);
},

View File

@@ -130,7 +130,7 @@ export class SystemController extends Controller {
summary: "Get the raw config",
tags: ["system"],
}),
permission([SystemPermissions.configReadSecrets]),
permission(SystemPermissions.configReadSecrets),
async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch().then((r) => r?.configs));
@@ -295,7 +295,11 @@ export class SystemController extends Controller {
const { secrets } = c.req.valid("query");
const { module } = c.req.valid("param");
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
if (secrets) {
this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, {
module,
});
}
const config = this.app.toJSON(secrets);
@@ -342,8 +346,16 @@ export class SystemController extends Controller {
const { config, secrets, fresh } = c.req.valid("query");
const readonly = this.app.isReadOnly();
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
if (config) {
this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c, {
module,
});
}
if (secrets) {
this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, {
module,
});
}
const { version, ...schema } = this.app.getSchema();
@@ -383,7 +395,7 @@ export class SystemController extends Controller {
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
async (c) => {
const options = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c, {});
await this.app.build(options);
return c.json({