mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PrimaryFieldType } from "core/config";
|
||||
import { getPath, invariant } from "bknd/utils";
|
||||
|
||||
export type Primitive = PrimaryFieldType | string | number | boolean;
|
||||
export function isPrimitive(value: any): value is Primitive {
|
||||
@@ -67,8 +68,9 @@ function _convert<Exps extends Expressions>(
|
||||
expressions: Exps,
|
||||
path: string[] = [],
|
||||
): FilterQuery<Exps> {
|
||||
invariant(typeof $query === "object", "$query must be an object");
|
||||
const ExpressionConditionKeys = expressions.map((e) => e.key);
|
||||
const keys = Object.keys($query);
|
||||
const keys = Object.keys($query ?? {});
|
||||
const operands = [OperandOr] as const;
|
||||
const newQuery: FilterQuery<Exps> = {};
|
||||
|
||||
@@ -157,7 +159,7 @@ function _build<Exps extends Expressions>(
|
||||
// check $and
|
||||
for (const [key, value] of Object.entries($and)) {
|
||||
for (const [$op, $v] of Object.entries(value)) {
|
||||
const objValue = options.value_is_kv ? key : options.object[key];
|
||||
const objValue = options.value_is_kv ? key : getPath(options.object, key);
|
||||
result.$and.push(__validate($op, $v, objValue, [key]));
|
||||
result.keys.add(key);
|
||||
}
|
||||
@@ -165,7 +167,7 @@ function _build<Exps extends Expressions>(
|
||||
|
||||
// check $or
|
||||
for (const [key, value] of Object.entries($or ?? {})) {
|
||||
const objValue = options.value_is_kv ? key : options.object[key];
|
||||
const objValue = options.value_is_kv ? key : getPath(options.object, key);
|
||||
|
||||
for (const [$op, $v] of Object.entries(value)) {
|
||||
result.$or.push(__validate($op, $v, objValue, [key]));
|
||||
|
||||
@@ -62,11 +62,18 @@ export function invariant(condition: boolean | any, message: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function threw(fn: () => any) {
|
||||
export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
|
||||
try {
|
||||
fn();
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (instance) {
|
||||
if (e instanceof instance) {
|
||||
return true;
|
||||
}
|
||||
// if instance given but not what expected, throw
|
||||
throw e;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user