refactor: restructure permission handling and enhance Guard functionality

- Introduced a new `createGuard` function to streamline the creation of Guard instances with permissions and roles.
- Updated tests in `authorize.spec.ts` to reflect changes in permission checks, ensuring they now return undefined for denied permissions.
- Added new `Permission` and `Policy` classes to improve type safety and flexibility in permission management.
- Refactored middleware and controller files to utilize the updated permission structure, including context handling for permissions.
- Created a new `SystemController.spec.ts` file to test the integration of the new permission system within the SystemController.
- Removed legacy permission handling from core security files, consolidating permission logic within the new structure.
This commit is contained in:
dswbx
2025-10-13 18:20:46 +02:00
parent b784e1c1c4
commit 2f88c2216c
26 changed files with 954 additions and 367 deletions

View File

@@ -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<P extends Permission<any, any, any, any>>(
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
) {
permission: P,
context: PermissionContext<P>,
): Promise<void>;
async granted<P extends Permission<any, any, undefined, any>>(
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
permission: P,
): Promise<void>;
async granted<P extends Permission<any, any, any, any>>(
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
permission: P,
context?: PermissionContext<P>,
): Promise<void> {
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);
}
}

View File

@@ -1 +1,2 @@
export { auth, permission } from "auth/middlewares";
export { auth } from "auth/middlewares/auth.middleware";
export { permission } from "auth/middlewares/permission.middleware";

View File

@@ -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");

View File

@@ -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")!);

View File

@@ -87,6 +87,10 @@ export class AppServer extends Module<AppServerConfig> {
}
if (err instanceof AuthException) {
if (isDebug()) {
return c.json(err.toJSON(), err.code);
}
return c.json(err.toJSON(), err.getSafeErrorAndCode().code);
}

View File

@@ -119,7 +119,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono<any>): 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<string, boolean>;
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(), {