mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
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:
@@ -1,7 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Guard } from "../../../src/auth/authorize/Guard";
|
import { Guard } from "auth/authorize/Guard";
|
||||||
|
import { Permission } from "core/security/Permission";
|
||||||
|
|
||||||
describe("authorize", () => {
|
describe("authorize", () => {
|
||||||
|
const read = new Permission("read");
|
||||||
|
const write = new Permission("write");
|
||||||
|
|
||||||
test("basic", async () => {
|
test("basic", async () => {
|
||||||
const guard = Guard.create(
|
const guard = Guard.create(
|
||||||
["read", "write"],
|
["read", "write"],
|
||||||
@@ -16,10 +20,10 @@ describe("authorize", () => {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(guard.granted("read", user)).toBe(true);
|
expect(guard.granted(read, user)).toBe(true);
|
||||||
expect(guard.granted("write", user)).toBe(true);
|
expect(guard.granted(write, user)).toBe(true);
|
||||||
|
|
||||||
expect(() => guard.granted("something")).toThrow();
|
expect(() => guard.granted(new Permission("something"))).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with default", async () => {
|
test("with default", async () => {
|
||||||
@@ -37,22 +41,22 @@ describe("authorize", () => {
|
|||||||
{ enabled: true },
|
{ enabled: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted(read)).toBe(true);
|
||||||
expect(guard.granted("write")).toBe(false);
|
expect(guard.granted(write)).toBe(false);
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
role: "admin",
|
role: "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(guard.granted("read", user)).toBe(true);
|
expect(guard.granted(read, user)).toBe(true);
|
||||||
expect(guard.granted("write", user)).toBe(true);
|
expect(guard.granted(write, user)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard implicit allow", async () => {
|
test("guard implicit allow", async () => {
|
||||||
const guard = Guard.create([], {}, { enabled: false });
|
const guard = Guard.create([], {}, { enabled: false });
|
||||||
|
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted(read)).toBe(true);
|
||||||
expect(guard.granted("write")).toBe(true);
|
expect(guard.granted(write)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("role implicit allow", async () => {
|
test("role implicit allow", async () => {
|
||||||
@@ -66,8 +70,8 @@ describe("authorize", () => {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(guard.granted("read", user)).toBe(true);
|
expect(guard.granted(read, user)).toBe(true);
|
||||||
expect(guard.granted("write", user)).toBe(true);
|
expect(guard.granted(write, user)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard with guest role implicit allow", async () => {
|
test("guard with guest role implicit allow", async () => {
|
||||||
@@ -79,7 +83,7 @@ describe("authorize", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(guard.getUserRole()?.name).toBe("guest");
|
expect(guard.getUserRole()?.name).toBe("guest");
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted(read)).toBe(true);
|
||||||
expect(guard.granted("write")).toBe(true);
|
expect(guard.granted(write)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
93
app/__test__/auth/authorize/permissions.spec.ts
Normal file
93
app/__test__/auth/authorize/permissions.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { s } from "bknd/utils";
|
||||||
|
import { Permission, Policy } from "core/security/Permission";
|
||||||
|
|
||||||
|
describe("Permission", () => {
|
||||||
|
it("works with minimal schema", () => {
|
||||||
|
expect(() => new Permission("test")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses context", () => {
|
||||||
|
const p = new Permission(
|
||||||
|
"test3",
|
||||||
|
{
|
||||||
|
filterable: true,
|
||||||
|
},
|
||||||
|
s.object({
|
||||||
|
a: s.string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => p.parseContext({ a: [] })).toThrow();
|
||||||
|
expect(p.parseContext({ a: "test" })).toEqual({ a: "test" });
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(p.parseContext({ a: 1 })).toEqual({ a: "1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Policy", () => {
|
||||||
|
it("works with minimal schema", () => {
|
||||||
|
expect(() => new Policy().toJSON()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks condition", () => {
|
||||||
|
const p = new Policy({
|
||||||
|
condition: {
|
||||||
|
a: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p.meetsCondition({ a: 1 })).toBe(true);
|
||||||
|
expect(p.meetsCondition({ a: 2 })).toBe(false);
|
||||||
|
expect(p.meetsCondition({ a: 1, b: 1 })).toBe(true);
|
||||||
|
expect(p.meetsCondition({})).toBe(false);
|
||||||
|
|
||||||
|
const p2 = new Policy({
|
||||||
|
condition: {
|
||||||
|
a: { $gt: 1 },
|
||||||
|
$or: {
|
||||||
|
b: { $lt: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p2.meetsCondition({ a: 2 })).toBe(true);
|
||||||
|
expect(p2.meetsCondition({ a: 1 })).toBe(false);
|
||||||
|
expect(p2.meetsCondition({ a: 1, b: 1 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters", () => {
|
||||||
|
const p = new Policy({
|
||||||
|
filter: {
|
||||||
|
age: { $gt: 18 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const subjects = [{ age: 19 }, { age: 17 }, { age: 12 }];
|
||||||
|
|
||||||
|
expect(p.getFiltered(subjects)).toEqual([{ age: 19 }]);
|
||||||
|
|
||||||
|
expect(p.meetsFilter({ age: 19 })).toBe(true);
|
||||||
|
expect(p.meetsFilter({ age: 17 })).toBe(false);
|
||||||
|
expect(p.meetsFilter({ age: 12 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces placeholders", () => {
|
||||||
|
const p = new Policy({
|
||||||
|
condition: {
|
||||||
|
a: "@auth.username",
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
a: "@auth.username",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const vars = { auth: { username: "test" } };
|
||||||
|
|
||||||
|
expect(p.meetsCondition({ a: "test" }, vars)).toBe(true);
|
||||||
|
expect(p.meetsCondition({ a: "test2" }, vars)).toBe(false);
|
||||||
|
expect(p.meetsCondition({ a: "test2" })).toBe(false);
|
||||||
|
expect(p.meetsFilter({ a: "test" }, vars)).toBe(true);
|
||||||
|
expect(p.meetsFilter({ a: "test2" }, vars)).toBe(false);
|
||||||
|
expect(p.meetsFilter({ a: "test2" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,6 +194,116 @@ describe("Core Utils", async () => {
|
|||||||
expect(result).toEqual(expected);
|
expect(result).toEqual(expected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recursivelyReplacePlaceholders", () => {
|
||||||
|
// test basic replacement with simple pattern
|
||||||
|
const obj1 = { a: "Hello, {$name}!", b: { c: "Hello, {$name}!" } };
|
||||||
|
const variables1 = { name: "John" };
|
||||||
|
const result1 = utils.recursivelyReplacePlaceholders(obj1, /\{\$(\w+)\}/g, variables1);
|
||||||
|
expect(result1).toEqual({ a: "Hello, John!", b: { c: "Hello, John!" } });
|
||||||
|
|
||||||
|
// test the specific example from the user request
|
||||||
|
const obj2 = { some: "value", here: "@auth.user" };
|
||||||
|
const variables2 = { auth: { user: "what" } };
|
||||||
|
const result2 = utils.recursivelyReplacePlaceholders(obj2, /^@([a-z\.]+)$/, variables2);
|
||||||
|
expect(result2).toEqual({ some: "value", here: "what" });
|
||||||
|
|
||||||
|
// test with arrays
|
||||||
|
const obj3 = { items: ["@config.name", "static", "@config.version"] };
|
||||||
|
const variables3 = { config: { name: "MyApp", version: "1.0.0" } };
|
||||||
|
const result3 = utils.recursivelyReplacePlaceholders(obj3, /^@([a-z\.]+)$/, variables3);
|
||||||
|
expect(result3).toEqual({ items: ["MyApp", "static", "1.0.0"] });
|
||||||
|
|
||||||
|
// test with nested objects and deep paths
|
||||||
|
const obj4 = {
|
||||||
|
user: "@auth.user.name",
|
||||||
|
settings: {
|
||||||
|
theme: "@ui.theme",
|
||||||
|
nested: {
|
||||||
|
value: "@deep.nested.value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const variables4 = {
|
||||||
|
auth: { user: { name: "Alice" } },
|
||||||
|
ui: { theme: "dark" },
|
||||||
|
deep: { nested: { value: "found" } },
|
||||||
|
};
|
||||||
|
const result4 = utils.recursivelyReplacePlaceholders(obj4, /^@([a-z\.]+)$/, variables4);
|
||||||
|
expect(result4).toEqual({
|
||||||
|
user: "Alice",
|
||||||
|
settings: {
|
||||||
|
theme: "dark",
|
||||||
|
nested: {
|
||||||
|
value: "found",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// test with missing paths (should return original match)
|
||||||
|
const obj5 = { value: "@missing.path" };
|
||||||
|
const variables5 = { existing: "value" };
|
||||||
|
const result5 = utils.recursivelyReplacePlaceholders(obj5, /^@([a-z\.]+)$/, variables5);
|
||||||
|
expect(result5).toEqual({ value: "@missing.path" });
|
||||||
|
|
||||||
|
// test with non-matching strings (should remain unchanged)
|
||||||
|
const obj6 = { value: "normal string", other: "not@matching" };
|
||||||
|
const variables6 = { some: "value" };
|
||||||
|
const result6 = utils.recursivelyReplacePlaceholders(obj6, /^@([a-z\.]+)$/, variables6);
|
||||||
|
expect(result6).toEqual({ value: "normal string", other: "not@matching" });
|
||||||
|
|
||||||
|
// test with primitive values (should handle gracefully)
|
||||||
|
expect(
|
||||||
|
utils.recursivelyReplacePlaceholders("@test.value", /^@([a-z\.]+)$/, {
|
||||||
|
test: { value: "replaced" },
|
||||||
|
}),
|
||||||
|
).toBe("replaced");
|
||||||
|
expect(utils.recursivelyReplacePlaceholders(123, /^@([a-z\.]+)$/, {})).toBe(123);
|
||||||
|
expect(utils.recursivelyReplacePlaceholders(null, /^@([a-z\.]+)$/, {})).toBe(null);
|
||||||
|
|
||||||
|
// test type preservation for full string matches
|
||||||
|
const variables7 = { test: { value: 123, flag: true, data: null, arr: [1, 2, 3] } };
|
||||||
|
const result7 = utils.recursivelyReplacePlaceholders(
|
||||||
|
{
|
||||||
|
number: "@test.value",
|
||||||
|
boolean: "@test.flag",
|
||||||
|
nullValue: "@test.data",
|
||||||
|
array: "@test.arr",
|
||||||
|
},
|
||||||
|
/^@([a-z\.]+)$/,
|
||||||
|
variables7,
|
||||||
|
);
|
||||||
|
expect(result7).toEqual({
|
||||||
|
number: 123,
|
||||||
|
boolean: true,
|
||||||
|
nullValue: null,
|
||||||
|
array: [1, 2, 3],
|
||||||
|
});
|
||||||
|
|
||||||
|
// test partial string replacement (should convert to string)
|
||||||
|
const result8 = utils.recursivelyReplacePlaceholders(
|
||||||
|
{ message: "The value is @test.value!" },
|
||||||
|
/@([a-z\.]+)/g,
|
||||||
|
variables7,
|
||||||
|
);
|
||||||
|
expect(result8).toEqual({ message: "The value is 123!" });
|
||||||
|
|
||||||
|
// test mixed scenarios
|
||||||
|
const result9 = utils.recursivelyReplacePlaceholders(
|
||||||
|
{
|
||||||
|
fullMatch: "@test.value", // should preserve number type
|
||||||
|
partialMatch: "Value: @test.value", // should convert to string
|
||||||
|
noMatch: "static text",
|
||||||
|
},
|
||||||
|
/^@([a-z\.]+)$/,
|
||||||
|
variables7,
|
||||||
|
);
|
||||||
|
expect(result9).toEqual({
|
||||||
|
fullMatch: 123, // number preserved
|
||||||
|
partialMatch: "Value: @test.value", // no replacement (pattern requires full match)
|
||||||
|
noMatch: "static text",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("file", async () => {
|
describe("file", async () => {
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export class AuthController extends Controller {
|
|||||||
if (create) {
|
if (create) {
|
||||||
hono.post(
|
hono.post(
|
||||||
"/create",
|
"/create",
|
||||||
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
permission(AuthPermissions.createUser),
|
||||||
|
permission(DataPermissions.entityCreate),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Create a new user",
|
summary: "Create a new user",
|
||||||
tags: ["auth"],
|
tags: ["auth"],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Exception } from "core/errors";
|
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 { Permission } from "core/security/Permission";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import type { ServerEnv } from "modules/Controller";
|
import type { ServerEnv } from "modules/Controller";
|
||||||
@@ -12,6 +12,7 @@ export type GuardUserContext = {
|
|||||||
|
|
||||||
export type GuardConfig = {
|
export type GuardConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
context?: string;
|
||||||
};
|
};
|
||||||
export type GuardContext = Context<ServerEnv> | GuardUserContext;
|
export type GuardContext = Context<ServerEnv> | GuardUserContext;
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ export class Guard {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
static create(
|
static create(
|
||||||
permissionNames: string[],
|
permissionNames: string[],
|
||||||
roles?: Record<
|
roles?: Record<
|
||||||
@@ -156,12 +160,25 @@ export class Guard {
|
|||||||
return !!rolePermission;
|
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;
|
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)) {
|
if (!this.granted(permission, c)) {
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Permission } from "core/security/Permission";
|
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 type { Context } from "hono";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import type { ServerEnv } from "modules/Controller";
|
import type { ServerEnv } from "modules/Controller";
|
||||||
|
import type { MaybePromise } from "core/types";
|
||||||
|
|
||||||
function getPath(reqOrCtx: Request | Context) {
|
function getPath(reqOrCtx: Request | Context) {
|
||||||
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
|
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
|
||||||
@@ -49,7 +50,7 @@ export const auth = (options?: {
|
|||||||
// make sure to only register once
|
// make sure to only register once
|
||||||
if (authCtx.registered) {
|
if (authCtx.registered) {
|
||||||
skipped = true;
|
skipped = true;
|
||||||
$console.warn(`auth middleware already registered for ${getPath(c)}`);
|
$console.debug(`auth middleware already registered for ${getPath(c)}`);
|
||||||
} else {
|
} else {
|
||||||
authCtx.registered = true;
|
authCtx.registered = true;
|
||||||
|
|
||||||
@@ -68,11 +69,12 @@ export const auth = (options?: {
|
|||||||
authCtx.user = undefined;
|
authCtx.user = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const permission = (
|
export const permission = <P extends Permission>(
|
||||||
permission: Permission | Permission[],
|
permission: P,
|
||||||
options?: {
|
options?: {
|
||||||
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
onGranted?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
|
||||||
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
onDenied?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
|
||||||
|
context?: (c: Context<ServerEnv>) => MaybePromise<s.Static<P["context"]>>;
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -93,11 +95,11 @@ export const permission = (
|
|||||||
}
|
}
|
||||||
} else if (!authCtx.skip) {
|
} else if (!authCtx.skip) {
|
||||||
const guard = app.modules.ctx().guard;
|
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) {
|
if (options?.onGranted || options?.onDenied) {
|
||||||
let returned: undefined | void | Response;
|
let returned: undefined | void | Response;
|
||||||
if (permissions.every((p) => guard.granted(p, c))) {
|
if (guard.granted(permission, c, context)) {
|
||||||
returned = await options?.onGranted?.(c);
|
returned = await options?.onGranted?.(c);
|
||||||
} else {
|
} else {
|
||||||
returned = await options?.onDenied?.(c);
|
returned = await options?.onDenied?.(c);
|
||||||
@@ -106,7 +108,7 @@ export const permission = (
|
|||||||
return returned;
|
return returned;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
permissions.some((p) => guard.throwUnlessGranted(p, c));
|
guard.throwUnlessGranted(permission, c, context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,95 @@
|
|||||||
export class Permission<Name extends string = string> {
|
import {
|
||||||
constructor(public name: Name) {
|
s,
|
||||||
this.name = name;
|
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() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
name: this.name,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -512,3 +512,38 @@ export function convertNumberedObjectToArray(obj: object): any[] | object {
|
|||||||
}
|
}
|
||||||
return obj;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export const stringIdentifier = s.string({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export class InvalidSchemaError extends Error {
|
export class InvalidSchemaError extends Error {
|
||||||
|
override name = "InvalidSchemaError";
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public schema: s.Schema,
|
public schema: s.Schema,
|
||||||
public value: unknown,
|
public value: unknown,
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ export class MediaController extends Controller {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||||
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
permission(DataPermissions.entityCreate),
|
||||||
|
permission(MediaPermissions.uploadFile),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");
|
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export class ModuleHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async throwUnlessGranted(
|
async throwUnlessGranted(
|
||||||
permission: Permission | string,
|
permission: Permission,
|
||||||
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
|
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
|
||||||
) {
|
) {
|
||||||
invariant(c.context.app, "app is not available in mcp context");
|
invariant(c.context.app, "app is not available in mcp context");
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import { Permission } from "core/security/Permission";
|
import { Permission } from "core/security/Permission";
|
||||||
|
import { s } from "bknd/utils";
|
||||||
|
|
||||||
export const accessAdmin = new Permission("system.access.admin");
|
export const accessAdmin = new Permission("system.access.admin");
|
||||||
export const accessApi = new Permission("system.access.api");
|
export const accessApi = new Permission("system.access.api");
|
||||||
export const configRead = new Permission("system.config.read");
|
export const configRead = new Permission(
|
||||||
export const configReadSecrets = new Permission("system.config.read.secrets");
|
"system.config.read",
|
||||||
export const configWrite = new Permission("system.config.write");
|
{},
|
||||||
|
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 schemaRead = new Permission("system.schema.read");
|
||||||
export const build = new Permission("system.build");
|
export const build = new Permission("system.build");
|
||||||
export const mcp = new Permission("system.mcp");
|
export const mcp = new Permission("system.mcp");
|
||||||
|
|||||||
@@ -139,9 +139,7 @@ export class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (auth_enabled) {
|
if (auth_enabled) {
|
||||||
const redirectRouteParams = [
|
const options = {
|
||||||
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
|
|
||||||
// @ts-ignore
|
|
||||||
onGranted: async (c) => {
|
onGranted: async (c) => {
|
||||||
// @todo: add strict test to permissions middleware?
|
// @todo: add strict test to permissions middleware?
|
||||||
if (c.get("auth")?.user) {
|
if (c.get("auth")?.user) {
|
||||||
@@ -149,7 +147,10 @@ export class AdminController extends Controller {
|
|||||||
return c.redirect(authRoutes.success);
|
return c.redirect(authRoutes.success);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
};
|
||||||
|
const redirectRouteParams = [
|
||||||
|
permission(SystemPermissions.accessAdmin, options),
|
||||||
|
permission(SystemPermissions.schemaRead, options),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.html(c.get("html")!);
|
return c.html(c.get("html")!);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export class SystemController extends Controller {
|
|||||||
summary: "Get the raw config",
|
summary: "Get the raw config",
|
||||||
tags: ["system"],
|
tags: ["system"],
|
||||||
}),
|
}),
|
||||||
permission([SystemPermissions.configReadSecrets]),
|
permission(SystemPermissions.configReadSecrets),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
// @ts-expect-error "fetch" is private
|
// @ts-expect-error "fetch" is private
|
||||||
return c.json(await this.app.modules.fetch().then((r) => r?.configs));
|
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 { secrets } = c.req.valid("query");
|
||||||
const { module } = c.req.valid("param");
|
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);
|
const config = this.app.toJSON(secrets);
|
||||||
|
|
||||||
@@ -342,8 +346,16 @@ export class SystemController extends Controller {
|
|||||||
const { config, secrets, fresh } = c.req.valid("query");
|
const { config, secrets, fresh } = c.req.valid("query");
|
||||||
const readonly = this.app.isReadOnly();
|
const readonly = this.app.isReadOnly();
|
||||||
|
|
||||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
|
if (config) {
|
||||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c, {
|
||||||
|
module,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (secrets) {
|
||||||
|
this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, {
|
||||||
|
module,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { version, ...schema } = this.app.getSchema();
|
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() })),
|
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const options = c.req.valid("query") as Record<string, boolean>;
|
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);
|
await this.app.build(options);
|
||||||
return c.json({
|
return c.json({
|
||||||
|
|||||||
Reference in New Issue
Block a user