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,9 +1,11 @@
import { Exception } from "core/errors";
import { $console, objectTransform, type s } from "bknd/utils";
import { Permission } from "core/security/Permission";
import { $console, type s } from "bknd/utils";
import type { Permission, PermissionContext } from "auth/authorize/Permission";
import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller";
import { Role } from "./Role";
import type { Role } from "./Role";
import { HttpStatus } from "bknd/utils";
import type { Policy, PolicySchema } from "./Policy";
export type GuardUserContext = {
role?: string | null;
@@ -12,45 +14,43 @@ export type GuardUserContext = {
export type GuardConfig = {
enabled?: boolean;
context?: string;
context?: object;
};
export type GuardContext = Context<ServerEnv> | GuardUserContext;
export class Guard {
permissions: Permission[];
roles?: Role[];
config?: GuardConfig;
export class GuardPermissionsException extends Exception {
override name = "PermissionsException";
override code = HttpStatus.FORBIDDEN;
constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) {
constructor(
public permission: Permission,
public policy?: Policy,
public description?: string,
) {
super(`Permission "${permission.name}" not granted`);
}
override toJSON(): any {
return {
...super.toJSON(),
description: this.description,
permission: this.permission.name,
policy: this.policy?.toJSON(),
};
}
}
export class Guard {
constructor(
public permissions: Permission<any, any, any, any>[] = [],
public roles: Role[] = [],
public config?: GuardConfig,
) {
this.permissions = permissions;
this.roles = roles;
this.config = config;
}
/**
* @deprecated
*/
static create(
permissionNames: string[],
roles?: Record<
string,
{
permissions?: string[];
is_default?: boolean;
implicit_allow?: boolean;
}
>,
config?: GuardConfig,
) {
const _roles = roles
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow);
})
: {};
const _permissions = permissionNames.map((name) => new Permission(name));
return new Guard(_permissions, Object.values(_roles), config);
}
getPermissionNames(): string[] {
return this.permissions.map((permission) => permission.name);
}
@@ -77,7 +77,7 @@ export class Guard {
return this;
}
registerPermission(permission: Permission) {
registerPermission(permission: Permission<any, any, any, any>) {
if (this.permissions.find((p) => p.name === permission.name)) {
throw new Error(`Permission ${permission.name} already exists`);
}
@@ -86,9 +86,13 @@ export class Guard {
return this;
}
registerPermissions(permissions: Record<string, Permission>);
registerPermissions(permissions: Permission[]);
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
registerPermissions(permissions: Record<string, Permission<any, any, any, any>>);
registerPermissions(permissions: Permission<any, any, any, any>[]);
registerPermissions(
permissions:
| Permission<any, any, any, any>[]
| Record<string, Permission<any, any, any, any>>,
) {
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
for (const permission of p) {
@@ -121,69 +125,133 @@ export class Guard {
return this.config?.enabled === true;
}
hasPermission(permission: Permission, user?: GuardUserContext): boolean;
hasPermission(name: string, user?: GuardUserContext): boolean;
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
if (!this.isEnabled()) {
return true;
}
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
$console.debug("guard: checking permission", {
name,
user: { id: user?.id, role: user?.role },
});
const exists = this.permissionExists(name);
if (!exists) {
throw new Error(`Permission ${name} does not exist`);
}
private collect(permission: Permission, c: GuardContext, context: any) {
const user = c && "get" in c ? c.get("auth")?.user : c;
const ctx = {
...((context ?? {}) as any),
...this.config?.context,
user,
};
const exists = this.permissionExists(permission.name);
const role = this.getUserRole(user);
const rolePermission = role?.permissions.find(
(rolePermission) => rolePermission.permission.name === permission.name,
);
return {
ctx,
user,
exists,
role,
rolePermission,
};
}
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
): void;
granted<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext): void;
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
): void {
if (!this.isEnabled()) {
return;
}
const { ctx, user, exists, role, rolePermission } = this.collect(permission, c, context);
$console.debug("guard: checking permission", {
name: permission.name,
context: ctx,
});
if (!exists) {
throw new GuardPermissionsException(
permission,
undefined,
`Permission ${permission.name} does not exist`,
);
}
if (!role) {
$console.debug("guard: user has no role, denying");
return false;
throw new GuardPermissionsException(permission, undefined, "User has no role");
} else if (role.implicit_allow === true) {
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return true;
return;
}
const rolePermission = role.permissions.find(
(rolePermission) => rolePermission.permission.name === name,
);
$console.debug("guard: rolePermission, allowing?", {
permission: name,
role: role.name,
allowing: !!rolePermission,
});
return !!rolePermission;
}
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;
const ctx = {
...context,
user,
context: this.config?.context,
};
return this.hasPermission(permission, user);
}
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`,
403,
if (!rolePermission) {
$console.debug("guard: rolePermission not found, denying");
throw new GuardPermissionsException(
permission,
undefined,
"Role does not have required permission",
);
}
// validate context
let ctx2 = Object.assign({}, ctx);
if (permission.context) {
ctx2 = permission.parseContext(ctx2);
}
if (rolePermission?.policies.length > 0) {
$console.debug("guard: rolePermission has policies, checking");
for (const policy of rolePermission.policies) {
// skip filter policies
if (policy.content.effect === "filter") continue;
// if condition unmet or effect is deny, throw
const meets = policy.meetsCondition(ctx2);
if (!meets || (meets && policy.content.effect === "deny")) {
throw new GuardPermissionsException(
permission,
policy,
"Policy does not meet condition",
);
}
}
}
$console.debug("guard allowing", {
permission: permission.name,
role: role.name,
});
}
getPolicyFilter<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
): PolicySchema["filter"] | undefined;
getPolicyFilter<P extends Permission<any, any, undefined, any>>(
permission: P,
c: GuardContext,
): PolicySchema["filter"] | undefined;
getPolicyFilter<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
): PolicySchema["filter"] | undefined {
if (!permission.isFilterable()) return;
const { ctx, exists, role, rolePermission } = this.collect(permission, c, context);
// validate context
let ctx2 = Object.assign({}, ctx);
if (permission.context) {
ctx2 = permission.parseContext(ctx2);
}
if (exists && role && rolePermission && rolePermission.policies.length > 0) {
for (const policy of rolePermission.policies) {
if (policy.content.effect === "filter") {
return policy.meetsFilter(ctx2) ? policy.content.filter : undefined;
}
}
}
return;
}
}