From 90f93caff4ca7919e11f1bf07c20fda1ad06cd17 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 3 Oct 2025 20:22:42 +0200 Subject: [PATCH] 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. --- app/__test__/auth/authorize/authorize.spec.ts | 32 ++--- .../auth/authorize/permissions.spec.ts | 93 +++++++++++++++ app/__test__/core/utils.spec.ts | 110 ++++++++++++++++++ app/src/auth/api/AuthController.ts | 3 +- app/src/auth/authorize/Guard.ts | 25 +++- app/src/auth/middlewares.ts | 20 ++-- app/src/core/security/Permission.ts | 90 +++++++++++++- app/src/core/utils/objects.ts | 35 ++++++ app/src/core/utils/schema/index.ts | 2 + app/src/media/api/MediaController.ts | 3 +- app/src/modules/ModuleHelper.ts | 2 +- app/src/modules/permissions/index.ts | 25 +++- app/src/modules/server/AdminController.tsx | 21 ++-- app/src/modules/server/SystemController.ts | 22 +++- 14 files changed, 432 insertions(+), 51 deletions(-) create mode 100644 app/__test__/auth/authorize/permissions.spec.ts diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index c0e04ff..5510e73 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -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); }); }); diff --git a/app/__test__/auth/authorize/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts new file mode 100644 index 0000000..c6e58fb --- /dev/null +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -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); + }); +}); diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index 36b4969..b7d4c96 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -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 () => { diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index ba12d4a..15448be 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -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"], diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index a89b98d..5576d4b 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -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 | 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

( + permission: P, + c?: GuardContext, + context: s.Static = {} as s.Static, + ): 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

( + permission: P, + c: GuardContext, + context: s.Static, + ) { if (!this.granted(permission, c)) { throw new Exception( `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts index 702023b..685a9bd 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares.ts @@ -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 =

( + permission: P, options?: { - onGranted?: (c: Context) => Promise; - onDenied?: (c: Context) => Promise; + onGranted?: (c: Context) => MaybePromise; + onDenied?: (c: Context) => MaybePromise; + context?: (c: Context) => MaybePromise>; }, ) => // @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); } } diff --git a/app/src/core/security/Permission.ts b/app/src/core/security/Permission.ts index 86cf46b..5ca4d84 100644 --- a/app/src/core/security/Permission.ts +++ b/app/src/core/security/Permission.ts @@ -1,11 +1,95 @@ -export class Permission { - 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; + +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, 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; + +export class Policy { + public content: Schema; + + constructor(content?: Schema) { + this.content = parse(policySchema, content ?? {}) as Schema; + } + + replace(context: object, vars?: Record) { + return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context; + } + + meetsCondition(context: object, vars?: Record) { + return query.validate(this.replace(this.content.condition!, vars), context); + } + + meetsFilter(subject: object, vars?: Record) { + return query.validate(this.replace(this.content.filter!, vars), subject); + } + + getFiltered(given: Given): Given { + return given.filter((item) => this.meetsFilter(item)) as Given; + } + + toJSON() { + return this.content; + } +} diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 41902a9..65f18f9 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -512,3 +512,38 @@ export function convertNumberedObjectToArray(obj: object): any[] | object { } return obj; } + +export function recursivelyReplacePlaceholders( + obj: any, + pattern: RegExp, + variables: Record, +) { + 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; +} diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index 3d3692c..d30aae1 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -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, diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 6a72048..e20fa2e 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -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"); diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index 60a6dfc..d671065 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -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"); diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index b6fbead..5f5ccd1 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -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"); diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 2800781..2cdb2a7 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -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")!); }, diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 93533a2..43d688e 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -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; - this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c); + this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c, {}); await this.app.build(options); return c.json({