enhance Guard and permission handling with new test cases

- Updated the `Guard` class to improve context validation and permission checks, ensuring clearer error messages for unmet conditions.
- Refactored the `Policy` and `RolePermission` classes to support default effects and better handle conditions and filters.
- Enhanced tests in `authorize.spec.ts` and `permissions.spec.ts` to cover new permission scenarios, including guest and member role behaviors.
- Added new tests for context validation in permission middleware, ensuring robust error handling for invalid contexts.
- Improved utility functions for better integration with the updated permission structure.
This commit is contained in:
dswbx
2025-10-13 21:03:49 +02:00
parent 2f88c2216c
commit 7e5c28d621
9 changed files with 317 additions and 52 deletions

View File

@@ -160,7 +160,13 @@ export class Guard {
if (!this.isEnabled()) {
return;
}
const { ctx, user, exists, role, rolePermission } = this.collect(permission, c, context);
const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context);
// validate context
let ctx = Object.assign({}, _ctx);
if (permission.context) {
ctx = permission.parseContext(ctx);
}
$console.debug("guard: checking permission", {
name: permission.name,
@@ -187,32 +193,37 @@ export class Guard {
throw new GuardPermissionsException(
permission,
undefined,
"Role does not have required permission",
`Role "${role.name}" 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");
// set the default effect of the role permission
let allowed = rolePermission.effect === "allow";
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",
);
// if condition is met, check the effect
const meets = policy.meetsCondition(ctx);
if (meets) {
// if deny, then break early
if (policy.content.effect === "deny") {
allowed = false;
break;
// if allow, set allow but continue checking
} else if (policy.content.effect === "allow") {
allowed = true;
}
}
}
if (!allowed) {
throw new GuardPermissionsException(permission, undefined, "Policy condition unmet");
}
}
$console.debug("guard allowing", {
@@ -235,20 +246,24 @@ export class Guard {
c: GuardContext,
context?: PermissionContext<P>,
): PolicySchema["filter"] | undefined {
if (!permission.isFilterable()) return;
if (!permission.isFilterable()) {
$console.debug("getPolicyFilter: permission is not filterable, returning undefined");
return;
}
const { ctx, exists, role, rolePermission } = this.collect(permission, c, context);
const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context);
// validate context
let ctx2 = Object.assign({}, ctx);
let ctx = Object.assign({}, _ctx);
if (permission.context) {
ctx2 = permission.parseContext(ctx2);
ctx = permission.parseContext(ctx);
}
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;
const meets = policy.meetsCondition(ctx);
return meets ? policy.content.filter : undefined;
}
}
}

View File

@@ -5,6 +5,7 @@ export const policySchema = s
.strictObject({
description: s.string(),
condition: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
// @todo: potentially remove this, and invert from rolePermission.effect
effect: s.string({ enum: ["allow", "deny", "filter"], default: "allow" }),
filter: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
})
@@ -25,10 +26,12 @@ export class Policy<Schema extends PolicySchema = PolicySchema> {
}
meetsCondition(context: object, vars?: Record<string, any>) {
if (!this.content.condition) return true;
return query.validate(this.replace(this.content.condition!, vars), context);
}
meetsFilter(subject: object, vars?: Record<string, any>) {
if (!this.content.filter) return true;
return query.validate(this.replace(this.content.filter!, vars), subject);
}

View File

@@ -1,9 +1,13 @@
import { parse, s } from "bknd/utils";
import { s } from "bknd/utils";
import { Permission } from "./Permission";
import { Policy, policySchema } from "./Policy";
// default effect is allow for backward compatibility
const defaultEffect = "allow";
export const rolePermissionSchema = s.strictObject({
permission: s.string(),
effect: s.string({ enum: ["allow", "deny"], default: defaultEffect }).optional(),
policies: s.array(policySchema).optional(),
});
export type RolePermissionSchema = s.Static<typeof rolePermissionSchema>;
@@ -20,12 +24,14 @@ export class RolePermission {
constructor(
public permission: Permission<any, any, any, any>,
public policies: Policy[] = [],
public effect: "allow" | "deny" = defaultEffect,
) {}
toJSON() {
return {
permission: this.permission.name,
policies: this.policies.map((p) => p.toJSON()),
effect: this.effect,
};
}
}
@@ -45,7 +51,7 @@ export class Role {
return new RolePermission(new Permission(p), []);
}
const policies = p.policies?.map((policy) => new Policy(policy));
return new RolePermission(new Permission(p.permission), policies);
return new RolePermission(new Permission(p.permission), policies, p.effect);
}) ?? [];
return new Role(config.name, permissions, config.is_default, config.implicit_allow);
}

View File

@@ -5,6 +5,7 @@ import type { RouterRoute } from "hono/types";
import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Controller";
import type { MaybePromise } from "core/types";
import { GuardPermissionsException } from "auth/authorize/Guard";
function getPath(reqOrCtx: Request | Context) {
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
@@ -54,7 +55,7 @@ export function permission<P extends Permission<any, any, any, any>>(
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (threw(() => guard.granted(permission, c, context))) {
if (threw(() => guard.granted(permission, c, context), GuardPermissionsException)) {
returned = await options?.onDenied?.(c);
} else {
returned = await options?.onGranted?.(c);