refactor: restructure permission handling and enhance Guard functionality

- Introduced a new `createGuard` function to streamline the creation of Guard instances with permissions and roles.
- Updated tests in `authorize.spec.ts` to reflect changes in permission checks, ensuring they now return undefined for denied permissions.
- Added new `Permission` and `Policy` classes to improve type safety and flexibility in permission management.
- Refactored middleware and controller files to utilize the updated permission structure, including context handling for permissions.
- Created a new `SystemController.spec.ts` file to test the integration of the new permission system within the SystemController.
- Removed legacy permission handling from core security files, consolidating permission logic within the new structure.
This commit is contained in:
dswbx
2025-10-13 18:20:46 +02:00
parent b784e1c1c4
commit 2f88c2216c
26 changed files with 954 additions and 367 deletions

View File

@@ -1,95 +0,0 @@
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

@@ -61,3 +61,12 @@ export function invariant(condition: boolean | any, message: string) {
throw new Error(message);
}
}
export function threw(fn: () => any) {
try {
fn();
return false;
} catch (e) {
return true;
}
}

View File

@@ -1,3 +1,5 @@
import { Exception } from "core/errors";
import { HttpStatus } from "bknd/utils";
import * as s from "jsonv-ts";
export { validator as jsc, type Options } from "jsonv-ts/hono";
@@ -58,8 +60,9 @@ export const stringIdentifier = s.string({
maxLength: 150,
});
export class InvalidSchemaError extends Error {
export class InvalidSchemaError extends Exception {
override name = "InvalidSchemaError";
override code = HttpStatus.UNPROCESSABLE_ENTITY;
constructor(
public schema: s.Schema,