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 { Guard } from "../../../src/auth/authorize/Guard";
|
||||
import { Guard } from "auth/authorize/Guard";
|
||||
import { Permission } from "core/security/Permission";
|
||||
|
||||
describe("authorize", () => {
|
||||
const read = new Permission("read");
|
||||
const write = new Permission("write");
|
||||
|
||||
test("basic", async () => {
|
||||
const guard = Guard.create(
|
||||
["read", "write"],
|
||||
@@ -16,10 +20,10 @@ describe("authorize", () => {
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
expect(guard.granted(read, 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 () => {
|
||||
@@ -37,22 +41,22 @@ describe("authorize", () => {
|
||||
{ enabled: true },
|
||||
);
|
||||
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(false);
|
||||
expect(guard.granted(read)).toBe(true);
|
||||
expect(guard.granted(write)).toBe(false);
|
||||
|
||||
const user = {
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
expect(guard.granted(read, user)).toBe(true);
|
||||
expect(guard.granted(write, user)).toBe(true);
|
||||
});
|
||||
|
||||
test("guard implicit allow", async () => {
|
||||
const guard = Guard.create([], {}, { enabled: false });
|
||||
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(true);
|
||||
expect(guard.granted(read)).toBe(true);
|
||||
expect(guard.granted(write)).toBe(true);
|
||||
});
|
||||
|
||||
test("role implicit allow", async () => {
|
||||
@@ -66,8 +70,8 @@ describe("authorize", () => {
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
expect(guard.granted(read, user)).toBe(true);
|
||||
expect(guard.granted(write, user)).toBe(true);
|
||||
});
|
||||
|
||||
test("guard with guest role implicit allow", async () => {
|
||||
@@ -79,7 +83,7 @@ describe("authorize", () => {
|
||||
});
|
||||
|
||||
expect(guard.getUserRole()?.name).toBe("guest");
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(true);
|
||||
expect(guard.granted(read)).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);
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -60,7 +60,8 @@ export class AuthController extends Controller {
|
||||
if (create) {
|
||||
hono.post(
|
||||
"/create",
|
||||
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
||||
permission(AuthPermissions.createUser),
|
||||
permission(DataPermissions.entityCreate),
|
||||
describeRoute({
|
||||
summary: "Create a new user",
|
||||
tags: ["auth"],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { Context } from "hono";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
@@ -12,6 +12,7 @@ export type GuardUserContext = {
|
||||
|
||||
export type GuardConfig = {
|
||||
enabled?: boolean;
|
||||
context?: string;
|
||||
};
|
||||
export type GuardContext = Context<ServerEnv> | GuardUserContext;
|
||||
|
||||
@@ -26,6 +27,9 @@ export class Guard {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
static create(
|
||||
permissionNames: string[],
|
||||
roles?: Record<
|
||||
@@ -156,12 +160,25 @@ export class Guard {
|
||||
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;
|
||||
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)) {
|
||||
throw new Exception(
|
||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { createMiddleware } from "hono/factory";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import type { MaybePromise } from "core/types";
|
||||
|
||||
function getPath(reqOrCtx: Request | Context) {
|
||||
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
|
||||
@@ -49,7 +50,7 @@ export const auth = (options?: {
|
||||
// make sure to only register once
|
||||
if (authCtx.registered) {
|
||||
skipped = true;
|
||||
$console.warn(`auth middleware already registered for ${getPath(c)}`);
|
||||
$console.debug(`auth middleware already registered for ${getPath(c)}`);
|
||||
} else {
|
||||
authCtx.registered = true;
|
||||
|
||||
@@ -68,11 +69,12 @@ export const auth = (options?: {
|
||||
authCtx.user = undefined;
|
||||
});
|
||||
|
||||
export const permission = (
|
||||
permission: Permission | Permission[],
|
||||
export const permission = <P extends Permission>(
|
||||
permission: P,
|
||||
options?: {
|
||||
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||
onGranted?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
|
||||
onDenied?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
|
||||
context?: (c: Context<ServerEnv>) => MaybePromise<s.Static<P["context"]>>;
|
||||
},
|
||||
) =>
|
||||
// @ts-ignore
|
||||
@@ -93,11 +95,11 @@ export const permission = (
|
||||
}
|
||||
} else if (!authCtx.skip) {
|
||||
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) {
|
||||
let returned: undefined | void | Response;
|
||||
if (permissions.every((p) => guard.granted(p, c))) {
|
||||
if (guard.granted(permission, c, context)) {
|
||||
returned = await options?.onGranted?.(c);
|
||||
} else {
|
||||
returned = await options?.onDenied?.(c);
|
||||
@@ -106,7 +108,7 @@ export const permission = (
|
||||
return returned;
|
||||
}
|
||||
} else {
|
||||
permissions.some((p) => guard.throwUnlessGranted(p, c));
|
||||
guard.throwUnlessGranted(permission, c, context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,95 @@
|
||||
export class Permission<Name extends string = string> {
|
||||
constructor(public name: Name) {
|
||||
this.name = name;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,3 +512,38 @@ export function convertNumberedObjectToArray(obj: object): any[] | object {
|
||||
}
|
||||
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 {
|
||||
override name = "InvalidSchemaError";
|
||||
|
||||
constructor(
|
||||
public schema: s.Schema,
|
||||
public value: unknown,
|
||||
|
||||
@@ -186,7 +186,8 @@ export class MediaController extends Controller {
|
||||
}),
|
||||
),
|
||||
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
||||
permission(DataPermissions.entityCreate),
|
||||
permission(MediaPermissions.uploadFile),
|
||||
async (c) => {
|
||||
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ export class ModuleHelper {
|
||||
}
|
||||
|
||||
async throwUnlessGranted(
|
||||
permission: Permission | string,
|
||||
permission: Permission,
|
||||
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
|
||||
) {
|
||||
invariant(c.context.app, "app is not available in mcp context");
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { Permission } from "core/security/Permission";
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const accessAdmin = new Permission("system.access.admin");
|
||||
export const accessApi = new Permission("system.access.api");
|
||||
export const configRead = new Permission("system.config.read");
|
||||
export const configReadSecrets = new Permission("system.config.read.secrets");
|
||||
export const configWrite = new Permission("system.config.write");
|
||||
export const configRead = new Permission(
|
||||
"system.config.read",
|
||||
{},
|
||||
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 build = new Permission("system.build");
|
||||
export const mcp = new Permission("system.mcp");
|
||||
|
||||
@@ -139,17 +139,18 @@ export class AdminController extends Controller {
|
||||
}
|
||||
|
||||
if (auth_enabled) {
|
||||
const options = {
|
||||
onGranted: async (c) => {
|
||||
// @todo: add strict test to permissions middleware?
|
||||
if (c.get("auth")?.user) {
|
||||
$console.log("redirecting to success");
|
||||
return c.redirect(authRoutes.success);
|
||||
}
|
||||
},
|
||||
};
|
||||
const redirectRouteParams = [
|
||||
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
|
||||
// @ts-ignore
|
||||
onGranted: async (c) => {
|
||||
// @todo: add strict test to permissions middleware?
|
||||
if (c.get("auth")?.user) {
|
||||
$console.log("redirecting to success");
|
||||
return c.redirect(authRoutes.success);
|
||||
}
|
||||
},
|
||||
}),
|
||||
permission(SystemPermissions.accessAdmin, options),
|
||||
permission(SystemPermissions.schemaRead, options),
|
||||
async (c) => {
|
||||
return c.html(c.get("html")!);
|
||||
},
|
||||
|
||||
@@ -130,7 +130,7 @@ export class SystemController extends Controller {
|
||||
summary: "Get the raw config",
|
||||
tags: ["system"],
|
||||
}),
|
||||
permission([SystemPermissions.configReadSecrets]),
|
||||
permission(SystemPermissions.configReadSecrets),
|
||||
async (c) => {
|
||||
// @ts-expect-error "fetch" is private
|
||||
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 { 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);
|
||||
|
||||
@@ -342,8 +346,16 @@ export class SystemController extends Controller {
|
||||
const { config, secrets, fresh } = c.req.valid("query");
|
||||
const readonly = this.app.isReadOnly();
|
||||
|
||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
||||
if (config) {
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c, {
|
||||
module,
|
||||
});
|
||||
}
|
||||
if (secrets) {
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, {
|
||||
module,
|
||||
});
|
||||
}
|
||||
|
||||
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() })),
|
||||
async (c) => {
|
||||
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);
|
||||
return c.json({
|
||||
|
||||
Reference in New Issue
Block a user