diff --git a/app/__test__/auth/authorize/SystemController.spec.ts b/app/__test__/auth/authorize/SystemController.spec.ts new file mode 100644 index 0000000..8400f46 --- /dev/null +++ b/app/__test__/auth/authorize/SystemController.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "bun:test"; +import { SystemController } from "modules/server/SystemController"; +import { createApp } from "core/test/utils"; +import type { CreateAppConfig } from "App"; +import { getPermissionRoutes } from "auth/middlewares/permission.middleware"; + +async function makeApp(config: Partial = {}) { + const app = createApp(config); + await app.build(); + return app; +} + +describe("SystemController", () => { + it("...", async () => { + const app = await makeApp(); + const controller = new SystemController(app); + const hono = controller.getController(); + console.log(getPermissionRoutes(hono)); + }); +}); diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index 5510e73..fbf787f 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -1,13 +1,36 @@ import { describe, expect, test } from "bun:test"; -import { Guard } from "auth/authorize/Guard"; -import { Permission } from "core/security/Permission"; +import { Guard, type GuardConfig } from "auth/authorize/Guard"; +import { Permission } from "auth/authorize/Permission"; +import { Role } from "auth/authorize/Role"; +import { objectTransform } from "bknd/utils"; + +function createGuard( + permissionNames: string[], + roles?: Record< + string, + { + permissions?: string[]; + is_default?: boolean; + implicit_allow?: boolean; + } + >, + config?: GuardConfig, +) { + const _roles = roles + ? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => { + return Role.create({ name, permissions, is_default, implicit_allow }); + }) + : {}; + const _permissions = permissionNames.map((name) => new Permission(name)); + return new Guard(_permissions, Object.values(_roles), config); +} describe("authorize", () => { const read = new Permission("read"); const write = new Permission("write"); test("basic", async () => { - const guard = Guard.create( + const guard = createGuard( ["read", "write"], { admin: { @@ -20,14 +43,14 @@ describe("authorize", () => { role: "admin", }; - expect(guard.granted(read, user)).toBe(true); - expect(guard.granted(write, user)).toBe(true); + expect(guard.granted(read, user)).toBeUndefined(); + expect(guard.granted(write, user)).toBeUndefined(); - expect(() => guard.granted(new Permission("something"))).toThrow(); + expect(() => guard.granted(new Permission("something"), {})).toThrow(); }); test("with default", async () => { - const guard = Guard.create( + const guard = createGuard( ["read", "write"], { admin: { @@ -41,26 +64,26 @@ describe("authorize", () => { { enabled: true }, ); - expect(guard.granted(read)).toBe(true); - expect(guard.granted(write)).toBe(false); + expect(guard.granted(read, {})).toBeUndefined(); + expect(() => guard.granted(write, {})).toThrow(); const user = { role: "admin", }; - expect(guard.granted(read, user)).toBe(true); - expect(guard.granted(write, user)).toBe(true); + expect(guard.granted(read, user)).toBeUndefined(); + expect(guard.granted(write, user)).toBeUndefined(); }); test("guard implicit allow", async () => { - const guard = Guard.create([], {}, { enabled: false }); + const guard = createGuard([], {}, { enabled: false }); - expect(guard.granted(read)).toBe(true); - expect(guard.granted(write)).toBe(true); + expect(guard.granted(read, {})).toBeUndefined(); + expect(guard.granted(write, {})).toBeUndefined(); }); test("role implicit allow", async () => { - const guard = Guard.create(["read", "write"], { + const guard = createGuard(["read", "write"], { admin: { implicit_allow: true, }, @@ -70,12 +93,12 @@ describe("authorize", () => { role: "admin", }; - expect(guard.granted(read, user)).toBe(true); - expect(guard.granted(write, user)).toBe(true); + expect(guard.granted(read, user)).toBeUndefined(); + expect(guard.granted(write, user)).toBeUndefined(); }); test("guard with guest role implicit allow", async () => { - const guard = Guard.create(["read", "write"], { + const guard = createGuard(["read", "write"], { guest: { implicit_allow: true, is_default: true, @@ -83,7 +106,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, {})).toBeUndefined(); + expect(guard.granted(write, {})).toBeUndefined(); }); }); diff --git a/app/__test__/auth/authorize/permissions.spec.ts b/app/__test__/auth/authorize/permissions.spec.ts index c6e58fb..2b3a5ce 100644 --- a/app/__test__/auth/authorize/permissions.spec.ts +++ b/app/__test__/auth/authorize/permissions.spec.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from "bun:test"; import { s } from "bknd/utils"; -import { Permission, Policy } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; +import { Policy } from "auth/authorize/Policy"; +import { Hono } from "hono"; +import { permission } from "auth/middlewares/permission.middleware"; +import { auth } from "auth/middlewares/auth.middleware"; +import { Guard, type GuardConfig } from "auth/authorize/Guard"; +import { Role, RolePermission } from "auth/authorize/Role"; +import { Exception } from "bknd"; describe("Permission", () => { it("works with minimal schema", () => { @@ -91,3 +98,331 @@ describe("Policy", () => { expect(p.meetsFilter({ a: "test2" })).toBe(false); }); }); + +describe("Guard", () => { + it("collects filters", () => { + const p = new Permission( + "test", + { + filterable: true, + }, + s.object({ + a: s.number(), + }), + ); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + filter: { a: { $eq: 1 } }, + effect: "filter", + }), + ]), + ]); + const guard = new Guard([p], [r], { + enabled: true, + }); + expect( + guard.getPolicyFilter( + p, + { + role: r.name, + }, + { a: 1 }, + ), + ).toEqual({ a: { $eq: 1 } }); + expect( + guard.getPolicyFilter( + p, + { + role: r.name, + }, + { a: 2 }, + ), + ).toBeUndefined(); + // if no user context given, filter cannot be applied + expect(guard.getPolicyFilter(p, {}, { a: 1 })).toBeUndefined(); + }); + + it("collects filters for default role", () => { + const p = new Permission( + "test", + { + filterable: true, + }, + s.object({ + a: s.number(), + }), + ); + const r = new Role( + "test", + [ + new RolePermission(p, [ + new Policy({ + filter: { a: { $eq: 1 } }, + effect: "filter", + }), + ]), + ], + true, + ); + const guard = new Guard([p], [r], { + enabled: true, + }); + + expect( + guard.getPolicyFilter( + p, + { + role: r.name, + }, + { a: 1 }, + ), + ).toEqual({ a: { $eq: 1 } }); + expect( + guard.getPolicyFilter( + p, + { + role: r.name, + }, + { a: 2 }, + ), + ).toBeUndefined(); + // if no user context given, the default role is applied + // hence it can be found + expect(guard.getPolicyFilter(p, {}, { a: 1 })).toEqual({ a: { $eq: 1 } }); + }); +}); + +describe("permission middleware", () => { + const makeApp = ( + permissions: Permission[], + roles: Role[] = [], + config: Partial = {}, + ) => { + const app = { + module: { + auth: { + enabled: true, + }, + }, + modules: { + ctx: () => ({ + guard: new Guard(permissions, roles, { + enabled: true, + ...config, + }), + }), + }, + }; + return new Hono() + .use(async (c, next) => { + // @ts-expect-error + c.set("app", app); + await next(); + }) + .use(auth()) + .onError((err, c) => { + if (err instanceof Exception) { + return c.json(err.toJSON(), err.code as any); + } + return c.json({ error: err.message }, "code" in err ? (err.code as any) : 500); + }); + }; + + it("allows if guard is disabled", async () => { + const p = new Permission("test"); + const hono = makeApp([p], [], { enabled: false }).get("/test", permission(p, {}), async (c) => + c.text("test"), + ); + + const res = await hono.request("/test"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("test"); + }); + + it("denies if guard is enabled", async () => { + const p = new Permission("test"); + const hono = makeApp([p]).get("/test", permission(p, {}), async (c) => c.text("test")); + + const res = await hono.request("/test"); + expect(res.status).toBe(403); + }); + + it("allows if user has (plain) role", async () => { + const p = new Permission("test"); + const r = Role.create({ name: "test", permissions: [p.name] }); + const hono = makeApp([p], [r]) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get("/test", permission(p, {}), async (c) => c.text("test")); + + const res = await hono.request("/test"); + expect(res.status).toBe(200); + }); + + it("allows if user has role with policy", async () => { + const p = new Permission("test"); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $gte: 1 }, + }, + }), + ]), + ]); + const hono = makeApp([p], [r], { + context: { + a: 1, + }, + }) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get("/test", permission(p, {}), async (c) => c.text("test")); + + const res = await hono.request("/test"); + expect(res.status).toBe(200); + }); + + it("denies if user with role doesn't meet condition", async () => { + const p = new Permission("test"); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $lt: 1 }, + }, + }), + ]), + ]); + const hono = makeApp([p], [r], { + context: { + a: 1, + }, + }) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get("/test", permission(p, {}), async (c) => c.text("test")); + + const res = await hono.request("/test"); + expect(res.status).toBe(403); + }); + + it("allows if user with role doesn't meet condition (from middleware)", async () => { + const p = new Permission( + "test", + {}, + s.object({ + a: s.number(), + }), + ); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $eq: 1 }, + }, + }), + ]), + ]); + const hono = makeApp([p], [r]) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get( + "/test", + permission(p, { + context: (c) => ({ + a: 1, + }), + }), + async (c) => c.text("test"), + ); + + const res = await hono.request("/test"); + expect(res.status).toBe(200); + }); + + it("throws if permission context is invalid", async () => { + const p = new Permission( + "test", + {}, + s.object({ + a: s.number({ minimum: 2 }), + }), + ); + const r = new Role("test", [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $eq: 1 }, + }, + }), + ]), + ]); + const hono = makeApp([p], [r]) + .use(async (c, next) => { + // @ts-expect-error + c.set("auth", { registered: true, user: { id: 0, role: r.name } }); + await next(); + }) + .get( + "/test", + permission(p, { + context: (c) => ({ + a: 1, + }), + }), + async (c) => c.text("test"), + ); + + const res = await hono.request("/test"); + // expecting 500 because bknd should have handled it correctly + expect(res.status).toBe(500); + }); +}); + +describe("Role", () => { + it("serializes and deserializes", () => { + const p = new Permission( + "test", + { + filterable: true, + }, + s.object({ + a: s.number({ minimum: 2 }), + }), + ); + const r = new Role( + "test", + [ + new RolePermission(p, [ + new Policy({ + condition: { + a: { $eq: 1 }, + }, + effect: "deny", + filter: { + b: { $lt: 1 }, + }, + }), + ]), + ], + true, + ); + const json = JSON.parse(JSON.stringify(r.toJSON())); + const r2 = Role.create(json); + expect(r2.toJSON()).toEqual(r.toJSON()); + }); +}); diff --git a/app/__test__/integration/auth.integration.test.ts b/app/__test__/integration/auth.integration.test.ts index 340ccaf..477951f 100644 --- a/app/__test__/integration/auth.integration.test.ts +++ b/app/__test__/integration/auth.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { App, createApp, type AuthResponse } from "../../src"; -import { auth } from "../../src/auth/middlewares"; +import { auth } from "../../src/modules/middlewares"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { getDummyConnection } from "../helper"; diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 15448be..b94aa4b 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -60,8 +60,8 @@ export class AuthController extends Controller { if (create) { hono.post( "/create", - permission(AuthPermissions.createUser), - permission(DataPermissions.entityCreate), + permission(AuthPermissions.createUser, {}), + permission(DataPermissions.entityCreate, {}), describeRoute({ summary: "Create a new user", tags: ["auth"], @@ -239,7 +239,7 @@ export class AuthController extends Controller { }), }, async (params, c) => { - await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c); + await c.context.ctx().helper.granted(c, AuthPermissions.createUser); return c.json(await this.auth.createUser(params)); }, @@ -256,7 +256,7 @@ export class AuthController extends Controller { }), }, async (params, c) => { - await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c); + await c.context.ctx().helper.granted(c, AuthPermissions.createToken); const user = await getUser(params); return c.json({ user, token: await this.auth.authenticator.jwt(user) }); @@ -275,7 +275,7 @@ export class AuthController extends Controller { }), }, async (params, c) => { - await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c); + await c.context.ctx().helper.granted(c, AuthPermissions.changePassword); const user = await getUser(params); if (!(await this.auth.changePassword(user.id, params.password))) { @@ -296,7 +296,7 @@ export class AuthController extends Controller { }), }, async (params, c) => { - await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c); + await c.context.ctx().helper.granted(c, AuthPermissions.testPassword); const pw = this.auth.authenticator.strategy("password") as PasswordStrategy; const controller = pw.getController(this.auth.authenticator); diff --git a/app/src/auth/auth-permissions.ts b/app/src/auth/auth-permissions.ts index 8b097e7..dce59f5 100644 --- a/app/src/auth/auth-permissions.ts +++ b/app/src/auth/auth-permissions.ts @@ -1,4 +1,4 @@ -import { Permission } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; export const createUser = new Permission("auth.user.create"); //export const updateUser = new Permission("auth.user.update"); diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 5576d4b..ac97c63 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,9 +1,11 @@ import { Exception } from "core/errors"; -import { $console, objectTransform, type s } from "bknd/utils"; -import { Permission } from "core/security/Permission"; +import { $console, type s } from "bknd/utils"; +import type { Permission, PermissionContext } from "auth/authorize/Permission"; import type { Context } from "hono"; import type { ServerEnv } from "modules/Controller"; -import { Role } from "./Role"; +import type { Role } from "./Role"; +import { HttpStatus } from "bknd/utils"; +import type { Policy, PolicySchema } from "./Policy"; export type GuardUserContext = { role?: string | null; @@ -12,45 +14,43 @@ export type GuardUserContext = { export type GuardConfig = { enabled?: boolean; - context?: string; + context?: object; }; export type GuardContext = Context | GuardUserContext; -export class Guard { - permissions: Permission[]; - roles?: Role[]; - config?: GuardConfig; +export class GuardPermissionsException extends Exception { + override name = "PermissionsException"; + override code = HttpStatus.FORBIDDEN; - constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) { + constructor( + public permission: Permission, + public policy?: Policy, + public description?: string, + ) { + super(`Permission "${permission.name}" not granted`); + } + + override toJSON(): any { + return { + ...super.toJSON(), + description: this.description, + permission: this.permission.name, + policy: this.policy?.toJSON(), + }; + } +} + +export class Guard { + constructor( + public permissions: Permission[] = [], + public roles: Role[] = [], + public config?: GuardConfig, + ) { this.permissions = permissions; this.roles = roles; this.config = config; } - /** - * @deprecated - */ - static create( - permissionNames: string[], - roles?: Record< - string, - { - permissions?: string[]; - is_default?: boolean; - implicit_allow?: boolean; - } - >, - config?: GuardConfig, - ) { - const _roles = roles - ? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => { - return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow); - }) - : {}; - const _permissions = permissionNames.map((name) => new Permission(name)); - return new Guard(_permissions, Object.values(_roles), config); - } - getPermissionNames(): string[] { return this.permissions.map((permission) => permission.name); } @@ -77,7 +77,7 @@ export class Guard { return this; } - registerPermission(permission: Permission) { + registerPermission(permission: Permission) { if (this.permissions.find((p) => p.name === permission.name)) { throw new Error(`Permission ${permission.name} already exists`); } @@ -86,9 +86,13 @@ export class Guard { return this; } - registerPermissions(permissions: Record); - registerPermissions(permissions: Permission[]); - registerPermissions(permissions: Permission[] | Record) { + registerPermissions(permissions: Record>); + registerPermissions(permissions: Permission[]); + registerPermissions( + permissions: + | Permission[] + | Record>, + ) { const p = Array.isArray(permissions) ? permissions : Object.values(permissions); for (const permission of p) { @@ -121,69 +125,133 @@ export class Guard { return this.config?.enabled === true; } - hasPermission(permission: Permission, user?: GuardUserContext): boolean; - hasPermission(name: string, user?: GuardUserContext): boolean; - hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean { - if (!this.isEnabled()) { - return true; - } - - const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name; - $console.debug("guard: checking permission", { - name, - user: { id: user?.id, role: user?.role }, - }); - const exists = this.permissionExists(name); - if (!exists) { - throw new Error(`Permission ${name} does not exist`); - } - + private collect(permission: Permission, c: GuardContext, context: any) { + const user = c && "get" in c ? c.get("auth")?.user : c; + const ctx = { + ...((context ?? {}) as any), + ...this.config?.context, + user, + }; + const exists = this.permissionExists(permission.name); const role = this.getUserRole(user); + const rolePermission = role?.permissions.find( + (rolePermission) => rolePermission.permission.name === permission.name, + ); + return { + ctx, + user, + exists, + role, + rolePermission, + }; + } + + granted

>( + permission: P, + c: GuardContext, + context: PermissionContext

, + ): void; + granted

>(permission: P, c: GuardContext): void; + granted

>( + permission: P, + c: GuardContext, + context?: PermissionContext

, + ): void { + if (!this.isEnabled()) { + return; + } + const { ctx, user, exists, role, rolePermission } = this.collect(permission, c, context); + + $console.debug("guard: checking permission", { + name: permission.name, + context: ctx, + }); + if (!exists) { + throw new GuardPermissionsException( + permission, + undefined, + `Permission ${permission.name} does not exist`, + ); + } if (!role) { $console.debug("guard: user has no role, denying"); - return false; + throw new GuardPermissionsException(permission, undefined, "User has no role"); } else if (role.implicit_allow === true) { $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); - return true; + return; } - const rolePermission = role.permissions.find( - (rolePermission) => rolePermission.permission.name === name, - ); - - $console.debug("guard: rolePermission, allowing?", { - permission: name, - role: role.name, - allowing: !!rolePermission, - }); - return !!rolePermission; - } - - granted

( - permission: P, - c?: GuardContext, - context: s.Static = {} as s.Static, - ): boolean { - const user = c && "get" in c ? c.get("auth")?.user : c; - const ctx = { - ...context, - user, - context: this.config?.context, - }; - return this.hasPermission(permission, user); - } - - 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`, - 403, + if (!rolePermission) { + $console.debug("guard: rolePermission not found, denying"); + throw new GuardPermissionsException( + permission, + undefined, + "Role 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"); + 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", + ); + } + } + } + + $console.debug("guard allowing", { + permission: permission.name, + role: role.name, + }); + } + + getPolicyFilter

>( + permission: P, + c: GuardContext, + context: PermissionContext

, + ): PolicySchema["filter"] | undefined; + getPolicyFilter

>( + permission: P, + c: GuardContext, + ): PolicySchema["filter"] | undefined; + getPolicyFilter

>( + permission: P, + c: GuardContext, + context?: PermissionContext

, + ): PolicySchema["filter"] | undefined { + if (!permission.isFilterable()) return; + + const { ctx, exists, role, rolePermission } = this.collect(permission, c, context); + + // validate context + let ctx2 = Object.assign({}, ctx); + if (permission.context) { + ctx2 = permission.parseContext(ctx2); + } + + 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; + } + } + } + return; } } diff --git a/app/src/auth/authorize/Permission.ts b/app/src/auth/authorize/Permission.ts new file mode 100644 index 0000000..cd7b51b --- /dev/null +++ b/app/src/auth/authorize/Permission.ts @@ -0,0 +1,68 @@ +import { s, type ParseOptions, parse, InvalidSchemaError, HttpStatus } from "bknd/utils"; + +export const permissionOptionsSchema = s + .strictObject({ + description: s.string(), + filterable: s.boolean(), + }) + .partial(); + +export type PermissionOptions = s.Static; +export type PermissionContext

> = P extends Permission< + any, + any, + infer Context, + any +> + ? Context extends s.ObjectSchema + ? s.Static + : never + : never; + +export class InvalidPermissionContextError extends InvalidSchemaError { + override name = "InvalidPermissionContextError"; + + // changing to internal server error because it's an unexpected behavior + override code = HttpStatus.INTERNAL_SERVER_ERROR; + + 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 | undefined = undefined, + ContextValue = Context extends s.ObjectSchema ? s.Static : undefined, +> { + constructor( + public name: Name, + public options: Options = {} as Options, + public context: Context = undefined as Context, + ) {} + + isFilterable() { + return this.options.filterable === true; + } + + parseContext(ctx: ContextValue, opts?: ParseOptions) { + try { + return this.context ? parse(this.context!, ctx, opts) : undefined; + } catch (e) { + if (e instanceof InvalidSchemaError) { + throw InvalidPermissionContextError.from(e); + } + + throw e; + } + } + + toJSON() { + return { + name: this.name, + ...this.options, + context: this.context, + }; + } +} diff --git a/app/src/auth/authorize/Policy.ts b/app/src/auth/authorize/Policy.ts new file mode 100644 index 0000000..fd873af --- /dev/null +++ b/app/src/auth/authorize/Policy.ts @@ -0,0 +1,42 @@ +import { s, parse, recursivelyReplacePlaceholders } from "bknd/utils"; +import * as query from "core/object/query/object-query"; + +export const policySchema = s + .strictObject({ + description: s.string(), + condition: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>, + effect: s.string({ enum: ["allow", "deny", "filter"], default: "allow" }), + filter: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>, + }) + .partial(); +export type PolicySchema = s.Static; + +export class Policy { + public content: Schema; + + constructor(content?: Schema) { + this.content = parse(policySchema, content ?? {}, { + withDefaults: true, + }) 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/auth/authorize/Role.ts b/app/src/auth/authorize/Role.ts index 54efaf1..0cf038b 100644 --- a/app/src/auth/authorize/Role.ts +++ b/app/src/auth/authorize/Role.ts @@ -1,10 +1,33 @@ -import { Permission } from "core/security/Permission"; +import { parse, s } from "bknd/utils"; +import { Permission } from "./Permission"; +import { Policy, policySchema } from "./Policy"; + +export const rolePermissionSchema = s.strictObject({ + permission: s.string(), + policies: s.array(policySchema).optional(), +}); +export type RolePermissionSchema = s.Static; + +export const roleSchema = s.strictObject({ + name: s.string(), + permissions: s.anyOf([s.array(s.string()), s.array(rolePermissionSchema)]).optional(), + is_default: s.boolean().optional(), + implicit_allow: s.boolean().optional(), +}); +export type RoleSchema = s.Static; export class RolePermission { constructor( - public permission: Permission, - public config?: any, + public permission: Permission, + public policies: Policy[] = [], ) {} + + toJSON() { + return { + permission: this.permission.name, + policies: this.policies.map((p) => p.toJSON()), + }; + } } export class Role { @@ -15,31 +38,24 @@ export class Role { public implicit_allow: boolean = false, ) {} - static createWithPermissionNames( - name: string, - permissionNames: string[], - is_default: boolean = false, - implicit_allow: boolean = false, - ) { - return new Role( - name, - permissionNames.map((name) => new RolePermission(new Permission(name))), - is_default, - implicit_allow, - ); + static create(config: RoleSchema) { + const permissions = + config.permissions?.map((p: string | RolePermissionSchema) => { + if (typeof p === "string") { + 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 Role(config.name, permissions, config.is_default, config.implicit_allow); } - static create(config: { - name: string; - permissions?: string[]; - is_default?: boolean; - implicit_allow?: boolean; - }) { - return new Role( - config.name, - config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [], - config.is_default, - config.implicit_allow, - ); + toJSON() { + return { + name: this.name, + permissions: this.permissions.map((p) => p.toJSON()), + is_default: this.is_default, + implicit_allow: this.implicit_allow, + }; } } diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares/auth.middleware.ts similarity index 52% rename from app/src/auth/middlewares.ts rename to app/src/auth/middlewares/auth.middleware.ts index 685a9bd..eeebe45 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares/auth.middleware.ts @@ -1,9 +1,7 @@ -import type { Permission } from "core/security/Permission"; -import { $console, patternMatch, type s } from "bknd/utils"; +import { $console, patternMatch } 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; @@ -68,49 +66,3 @@ export const auth = (options?: { authCtx.resolved = false; authCtx.user = undefined; }); - -export const permission =

( - permission: P, - options?: { - onGranted?: (c: Context) => MaybePromise; - onDenied?: (c: Context) => MaybePromise; - context?: (c: Context) => MaybePromise>; - }, -) => - // @ts-ignore - createMiddleware(async (c, next) => { - const app = c.get("app"); - const authCtx = c.get("auth"); - if (!authCtx) { - throw new Error("auth ctx not found"); - } - - // in tests, app is not defined - if (!authCtx.registered || !app) { - const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; - if (app?.module.auth.enabled) { - throw new Error(msg); - } else { - $console.warn(msg); - } - } else if (!authCtx.skip) { - const guard = app.modules.ctx().guard; - const context = (await options?.context?.(c)) ?? ({} as any); - - if (options?.onGranted || options?.onDenied) { - let returned: undefined | void | Response; - if (guard.granted(permission, c, context)) { - returned = await options?.onGranted?.(c); - } else { - returned = await options?.onDenied?.(c); - } - if (returned instanceof Response) { - return returned; - } - } else { - guard.throwUnlessGranted(permission, c, context); - } - } - - await next(); - }); diff --git a/app/src/auth/middlewares/permission.middleware.ts b/app/src/auth/middlewares/permission.middleware.ts new file mode 100644 index 0000000..ac38a08 --- /dev/null +++ b/app/src/auth/middlewares/permission.middleware.ts @@ -0,0 +1,93 @@ +import type { Permission, PermissionContext } from "auth/authorize/Permission"; +import { $console, threw } from "bknd/utils"; +import type { Context, Hono } from "hono"; +import type { RouterRoute } from "hono/types"; +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; + return new URL(req.url).pathname; +} + +const permissionSymbol = Symbol.for("permission"); + +type PermissionMiddlewareOptions

> = { + onGranted?: (c: Context) => MaybePromise; + onDenied?: (c: Context) => MaybePromise; +} & (P extends Permission + ? PC extends undefined + ? { + context?: never; + } + : { + context: (c: Context) => MaybePromise>; + } + : { + context?: never; + }); + +export function permission

>( + permission: P, + options: PermissionMiddlewareOptions

, +) { + // @ts-ignore (middlewares do not always return) + const handler = createMiddleware(async (c, next) => { + const app = c.get("app"); + const authCtx = c.get("auth"); + if (!authCtx) { + throw new Error("auth ctx not found"); + } + + // in tests, app is not defined + if (!authCtx.registered || !app) { + const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; + if (app?.module.auth.enabled) { + throw new Error(msg); + } else { + $console.warn(msg); + } + } else if (!authCtx.skip) { + const guard = app.modules.ctx().guard; + const context = (await options?.context?.(c)) ?? ({} as any); + + if (options?.onGranted || options?.onDenied) { + let returned: undefined | void | Response; + if (threw(() => guard.granted(permission, c, context))) { + returned = await options?.onDenied?.(c); + } else { + returned = await options?.onGranted?.(c); + } + if (returned instanceof Response) { + return returned; + } + } else { + guard.granted(permission, c, context); + } + } + + await next(); + }); + + return Object.assign(handler, { + [permissionSymbol]: { permission, options }, + }); +} + +export function getPermissionRoutes(hono: Hono) { + const routes: { + route: RouterRoute; + permission: Permission; + options: PermissionMiddlewareOptions; + }[] = []; + for (const route of hono.routes) { + if (permissionSymbol in route.handler) { + routes.push({ + route, + ...(route.handler[permissionSymbol] as any), + }); + } + } + return routes; +} diff --git a/app/src/core/security/Permission.ts b/app/src/core/security/Permission.ts deleted file mode 100644 index 5ca4d84..0000000 --- a/app/src/core/security/Permission.ts +++ /dev/null @@ -1,95 +0,0 @@ -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/runtime.ts b/app/src/core/utils/runtime.ts index 0772abd..9b8e385 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -61,3 +61,12 @@ export function invariant(condition: boolean | any, message: string) { throw new Error(message); } } + +export function threw(fn: () => any) { + try { + fn(); + return false; + } catch (e) { + return true; + } +} diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index d30aae1..ff8190c 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -1,3 +1,5 @@ +import { Exception } from "core/errors"; +import { HttpStatus } from "bknd/utils"; import * as s from "jsonv-ts"; export { validator as jsc, type Options } from "jsonv-ts/hono"; @@ -58,8 +60,9 @@ export const stringIdentifier = s.string({ maxLength: 150, }); -export class InvalidSchemaError extends Error { +export class InvalidSchemaError extends Exception { override name = "InvalidSchemaError"; + override code = HttpStatus.UNPROCESSABLE_ENTITY; constructor( public schema: s.Schema, diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 163f0af..d4f9cdf 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -42,7 +42,7 @@ export class DataController extends Controller { override getController() { const { permission, auth } = this.middlewares; - const hono = this.create().use(auth(), permission(SystemPermissions.accessApi)); + const hono = this.create().use(auth(), permission(SystemPermissions.accessApi, {})); const entitiesEnum = this.getEntitiesEnum(this.em); // info @@ -58,7 +58,7 @@ export class DataController extends Controller { // sync endpoint hono.get( "/sync", - permission(DataPermissions.databaseSync), + permission(DataPermissions.databaseSync, {}), mcpTool("data_sync", { // @todo: should be removed if readonly annotations: { @@ -95,7 +95,7 @@ export class DataController extends Controller { // read entity schema hono.get( "/schema.json", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Retrieve data schema", tags: ["data"], @@ -121,7 +121,7 @@ export class DataController extends Controller { // read schema hono.get( "/schemas/:entity/:context?", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Retrieve entity schema", tags: ["data"], @@ -161,7 +161,7 @@ export class DataController extends Controller { */ hono.get( "/info/:entity", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Retrieve entity info", tags: ["data"], @@ -213,7 +213,7 @@ export class DataController extends Controller { // fn: count hono.post( "/:entity/fn/count", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Count entities", tags: ["data"], @@ -236,7 +236,7 @@ export class DataController extends Controller { // fn: exists hono.post( "/:entity/fn/exists", - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), describeRoute({ summary: "Check if entity exists", tags: ["data"], @@ -285,7 +285,7 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]), tags: ["data"], }), - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), jsc("param", s.object({ entity: entitiesEnum })), jsc("query", repoQuery, { skipOpenAPI: true }), async (c) => { @@ -308,7 +308,7 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(["offset", "sort", "select"]), tags: ["data"], }), - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), mcpTool("data_entity_read_one", { inputSchema: { param: s.object({ entity: entitiesEnum, id: idType }), @@ -344,7 +344,7 @@ export class DataController extends Controller { parameters: saveRepoQueryParams(), tags: ["data"], }), - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), jsc( "param", s.object({ @@ -390,7 +390,7 @@ export class DataController extends Controller { }, tags: ["data"], }), - permission(DataPermissions.entityRead), + permission(DataPermissions.entityRead, {}), mcpTool("data_entity_read_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -421,7 +421,7 @@ export class DataController extends Controller { summary: "Insert one or many", tags: ["data"], }), - permission(DataPermissions.entityCreate), + permission(DataPermissions.entityCreate, {}), mcpTool("data_entity_insert"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), @@ -455,7 +455,7 @@ export class DataController extends Controller { summary: "Update many", tags: ["data"], }), - permission(DataPermissions.entityUpdate), + permission(DataPermissions.entityUpdate, {}), mcpTool("data_entity_update_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), @@ -495,7 +495,7 @@ export class DataController extends Controller { summary: "Update one", tags: ["data"], }), - permission(DataPermissions.entityUpdate), + permission(DataPermissions.entityUpdate, {}), mcpTool("data_entity_update_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("json", s.object({})), @@ -518,7 +518,7 @@ export class DataController extends Controller { summary: "Delete one", tags: ["data"], }), - permission(DataPermissions.entityDelete), + permission(DataPermissions.entityDelete, {}), mcpTool("data_entity_delete_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), async (c) => { @@ -539,7 +539,7 @@ export class DataController extends Controller { summary: "Delete many", tags: ["data"], }), - permission(DataPermissions.entityDelete), + permission(DataPermissions.entityDelete, {}), mcpTool("data_entity_delete_many", { inputSchema: { param: s.object({ entity: entitiesEnum }), diff --git a/app/src/data/permissions/index.ts b/app/src/data/permissions/index.ts index 3db75ed..124980e 100644 --- a/app/src/data/permissions/index.ts +++ b/app/src/data/permissions/index.ts @@ -1,4 +1,4 @@ -import { Permission } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; export const entityRead = new Permission("data.entity.read"); export const entityCreate = new Permission("data.entity.create"); diff --git a/app/src/index.ts b/app/src/index.ts index ae01151..0f3d980 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -45,7 +45,7 @@ export type { MaybePromise } from "core/types"; export { Exception, BkndError } from "core/errors"; export { isDebug, env } from "core/env"; export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; -export { Permission } from "core/security/Permission"; +export { Permission } from "auth/authorize/Permission"; export { getFlashMessage } from "core/server/flash"; export * from "core/drivers"; export { Event, InvalidEventReturn } from "core/events/Event"; diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 3c67bc7..e1f795b 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -36,7 +36,7 @@ export class MediaController extends Controller { summary: "Get the list of files", tags: ["media"], }), - permission(MediaPermissions.listFiles), + permission(MediaPermissions.listFiles, {}), async (c) => { const files = await this.getStorageAdapter().listObjects(); return c.json(files); @@ -51,7 +51,7 @@ export class MediaController extends Controller { summary: "Get a file by name", tags: ["media"], }), - permission(MediaPermissions.readFile), + permission(MediaPermissions.readFile, {}), async (c) => { const { filename } = c.req.param(); if (!filename) { @@ -81,7 +81,7 @@ export class MediaController extends Controller { summary: "Delete a file by name", tags: ["media"], }), - permission(MediaPermissions.deleteFile), + permission(MediaPermissions.deleteFile, {}), async (c) => { const { filename } = c.req.param(); if (!filename) { @@ -149,7 +149,7 @@ export class MediaController extends Controller { requestBody, }), jsc("param", s.object({ filename: s.string().optional() })), - permission(MediaPermissions.uploadFile), + permission(MediaPermissions.uploadFile, {}), async (c) => { const reqname = c.req.param("filename"); @@ -189,8 +189,8 @@ export class MediaController extends Controller { }), ), jsc("query", s.object({ overwrite: s.boolean().optional() })), - permission(DataPermissions.entityCreate), - permission(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/media/media-permissions.ts b/app/src/media/media-permissions.ts index 527ce28..0ae0017 100644 --- a/app/src/media/media-permissions.ts +++ b/app/src/media/media-permissions.ts @@ -1,4 +1,4 @@ -import { Permission } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; export const readFile = new Permission("media.file.read"); export const listFiles = new Permission("media.file.list"); diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index d671065..29c4172 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -5,7 +5,7 @@ import { entityTypes } from "data/entities/Entity"; import { isEqual } from "lodash-es"; import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module"; import type { EntityRelation } from "data/relations"; -import type { Permission } from "core/security/Permission"; +import type { Permission, PermissionContext } from "auth/authorize/Permission"; import { Exception } from "core/errors"; import { invariant, isPlainObject } from "bknd/utils"; @@ -114,10 +114,20 @@ export class ModuleHelper { entity.__replaceField(name, newField); } - async throwUnlessGranted( - permission: Permission, + async granted

>( c: { context: ModuleBuildContextMcpContext; raw?: unknown }, - ) { + permission: P, + context: PermissionContext

, + ): Promise; + async granted

>( + c: { context: ModuleBuildContextMcpContext; raw?: unknown }, + permission: P, + ): Promise; + async granted

>( + c: { context: ModuleBuildContextMcpContext; raw?: unknown }, + permission: P, + context?: PermissionContext

, + ): Promise { invariant(c.context.app, "app is not available in mcp context"); const auth = c.context.app.module.auth; if (!auth.enabled) return; @@ -127,12 +137,6 @@ export class ModuleHelper { } const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any); - - if (!this.ctx.guard.granted(permission, user)) { - throw new Exception( - `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, - 403, - ); - } + this.ctx.guard.granted(permission, { user }, context as any); } } diff --git a/app/src/modules/middlewares/index.ts b/app/src/modules/middlewares/index.ts index be1ad59..213eb7e 100644 --- a/app/src/modules/middlewares/index.ts +++ b/app/src/modules/middlewares/index.ts @@ -1 +1,2 @@ -export { auth, permission } from "auth/middlewares"; +export { auth } from "auth/middlewares/auth.middleware"; +export { permission } from "auth/middlewares/permission.middleware"; diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index 5f5ccd1..152072d 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -1,4 +1,4 @@ -import { Permission } from "core/security/Permission"; +import { Permission } from "auth/authorize/Permission"; import { s } from "bknd/utils"; export const accessAdmin = new Permission("system.access.admin"); @@ -24,6 +24,12 @@ export const configWrite = new Permission( module: s.string().optional(), }), ); -export const schemaRead = new Permission("system.schema.read"); +export const schemaRead = new Permission( + "system.schema.read", + {}, + s.object({ + module: s.string().optional(), + }), +); 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 2cdb2a7..454ad40 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -116,6 +116,7 @@ export class AdminController extends Controller { onDenied: async (c) => { addFlashMessage(c, "You not allowed to read the schema", "warning"); }, + context: (c) => ({}), }), async (c) => { const obj: AdminBkndWindowContext = { @@ -147,9 +148,10 @@ export class AdminController extends Controller { return c.redirect(authRoutes.success); } }, + context: (c) => ({}), }; const redirectRouteParams = [ - permission(SystemPermissions.accessAdmin, options), + permission(SystemPermissions.accessAdmin, options as any), permission(SystemPermissions.schemaRead, options), async (c) => { return c.html(c.get("html")!); diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index b9fb531..e774d1e 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -87,6 +87,10 @@ export class AppServer extends Module { } if (err instanceof AuthException) { + if (isDebug()) { + return c.json(err.toJSON(), err.code); + } + return c.json(err.toJSON(), err.getSafeErrorAndCode().code); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 43d688e..4469c7e 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -119,7 +119,7 @@ export class SystemController extends Controller { private registerConfigController(client: Hono): void { const { permission } = this.middlewares; // don't add auth again, it's already added in getController - const hono = this.create().use(permission(SystemPermissions.configRead)); + const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */ if (!this.app.isReadOnly()) { const manager = this.app.modules as DbModuleManager; @@ -130,7 +130,11 @@ export class SystemController extends Controller { summary: "Get the raw config", tags: ["system"], }), - permission(SystemPermissions.configReadSecrets), + permission(SystemPermissions.configReadSecrets, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), async (c) => { // @ts-expect-error "fetch" is private return c.json(await this.app.modules.fetch().then((r) => r?.configs)); @@ -165,7 +169,11 @@ export class SystemController extends Controller { hono.post( "/set/:module", - permission(SystemPermissions.configWrite), + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }), async (c) => { const module = c.req.param("module") as any; @@ -194,32 +202,44 @@ export class SystemController extends Controller { }, ); - hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => { - // @todo: require auth (admin) - const module = c.req.param("module") as any; - const value = await c.req.json(); - const path = c.req.param("path") as string; + hono.post( + "/add/:module/:path", + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), + async (c) => { + // @todo: require auth (admin) + const module = c.req.param("module") as any; + const value = await c.req.json(); + const path = c.req.param("path") as string; - if (this.app.modules.get(module).schema().has(path)) { - return c.json( - { success: false, path, error: "Path already exists" }, - { status: 400 }, - ); - } + if (this.app.modules.get(module).schema().has(path)) { + return c.json( + { success: false, path, error: "Path already exists" }, + { status: 400 }, + ); + } - return await handleConfigUpdateResponse(c, async () => { - await manager.mutateConfigSafe(module).patch(path, value); - return { - success: true, - module, - config: this.app.module[module].config, - }; - }); - }); + return await handleConfigUpdateResponse(c, async () => { + await manager.mutateConfigSafe(module).patch(path, value); + return { + success: true, + module, + config: this.app.module[module].config, + }; + }); + }, + ); hono.patch( "/patch/:module/:path", - permission(SystemPermissions.configWrite), + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), async (c) => { // @todo: require auth (admin) const module = c.req.param("module") as any; @@ -239,7 +259,11 @@ export class SystemController extends Controller { hono.put( "/overwrite/:module/:path", - permission(SystemPermissions.configWrite), + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), async (c) => { // @todo: require auth (admin) const module = c.req.param("module") as any; @@ -259,7 +283,11 @@ export class SystemController extends Controller { hono.delete( "/remove/:module/:path", - permission(SystemPermissions.configWrite), + permission(SystemPermissions.configWrite, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), async (c) => { // @todo: require auth (admin) const module = c.req.param("module") as any; @@ -296,7 +324,7 @@ export class SystemController extends Controller { const { module } = c.req.valid("param"); if (secrets) { - this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, { + this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, { module, }); } @@ -330,7 +358,11 @@ export class SystemController extends Controller { summary: "Get the schema for a module", tags: ["system"], }), - permission(SystemPermissions.schemaRead), + permission(SystemPermissions.schemaRead, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), jsc( "query", s @@ -347,12 +379,12 @@ export class SystemController extends Controller { const readonly = this.app.isReadOnly(); if (config) { - this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c, { + this.ctx.guard.granted(SystemPermissions.configRead, c, { module, }); } if (secrets) { - this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, { + this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, { module, }); } @@ -395,7 +427,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.granted(SystemPermissions.build, c); await this.app.build(options); return c.json({ @@ -467,7 +499,7 @@ export class SystemController extends Controller { const { version, ...appConfig } = this.app.toJSON(); mcp.resource("system_config", "bknd://system/config", async (c) => { - await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c); + await c.context.ctx().helper.granted(c, SystemPermissions.configRead, {}); return c.json(this.app.toJSON(), { title: "System Config", @@ -477,7 +509,9 @@ export class SystemController extends Controller { "system_config_module", "bknd://system/config/{module}", async (c, { module }) => { - await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c); + await this.ctx.helper.granted(c, SystemPermissions.configRead, { + module, + }); const m = this.app.modules.get(module as any) as Module; return c.json(m.toJSON(), { @@ -489,7 +523,7 @@ export class SystemController extends Controller { }, ) .resource("system_schema", "bknd://system/schema", async (c) => { - await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); + await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {}); return c.json(this.app.getSchema(), { title: "System Schema", @@ -499,7 +533,9 @@ export class SystemController extends Controller { "system_schema_module", "bknd://system/schema/{module}", async (c, { module }) => { - await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); + await this.ctx.helper.granted(c, SystemPermissions.schemaRead, { + module, + }); const m = this.app.modules.get(module as any); return c.json(m.getSchema().toJSON(), {