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

@@ -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<CreateAppConfig> = {}) {
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));
});
});

View File

@@ -1,13 +1,36 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Guard } from "auth/authorize/Guard"; import { Guard, type GuardConfig } from "auth/authorize/Guard";
import { Permission } from "core/security/Permission"; 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", () => { describe("authorize", () => {
const read = new Permission("read"); const read = new Permission("read");
const write = new Permission("write"); const write = new Permission("write");
test("basic", async () => { test("basic", async () => {
const guard = Guard.create( const guard = createGuard(
["read", "write"], ["read", "write"],
{ {
admin: { admin: {
@@ -20,14 +43,14 @@ describe("authorize", () => {
role: "admin", role: "admin",
}; };
expect(guard.granted(read, user)).toBe(true); expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBe(true); expect(guard.granted(write, user)).toBeUndefined();
expect(() => guard.granted(new Permission("something"))).toThrow(); expect(() => guard.granted(new Permission("something"), {})).toThrow();
}); });
test("with default", async () => { test("with default", async () => {
const guard = Guard.create( const guard = createGuard(
["read", "write"], ["read", "write"],
{ {
admin: { admin: {
@@ -41,26 +64,26 @@ describe("authorize", () => {
{ enabled: true }, { enabled: true },
); );
expect(guard.granted(read)).toBe(true); expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted(write)).toBe(false); expect(() => guard.granted(write, {})).toThrow();
const user = { const user = {
role: "admin", role: "admin",
}; };
expect(guard.granted(read, user)).toBe(true); expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBe(true); expect(guard.granted(write, user)).toBeUndefined();
}); });
test("guard implicit allow", async () => { 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(read, {})).toBeUndefined();
expect(guard.granted(write)).toBe(true); expect(guard.granted(write, {})).toBeUndefined();
}); });
test("role implicit allow", async () => { test("role implicit allow", async () => {
const guard = Guard.create(["read", "write"], { const guard = createGuard(["read", "write"], {
admin: { admin: {
implicit_allow: true, implicit_allow: true,
}, },
@@ -70,12 +93,12 @@ describe("authorize", () => {
role: "admin", role: "admin",
}; };
expect(guard.granted(read, user)).toBe(true); expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBe(true); expect(guard.granted(write, user)).toBeUndefined();
}); });
test("guard with guest role implicit allow", async () => { test("guard with guest role implicit allow", async () => {
const guard = Guard.create(["read", "write"], { const guard = createGuard(["read", "write"], {
guest: { guest: {
implicit_allow: true, implicit_allow: true,
is_default: true, is_default: true,
@@ -83,7 +106,7 @@ describe("authorize", () => {
}); });
expect(guard.getUserRole()?.name).toBe("guest"); expect(guard.getUserRole()?.name).toBe("guest");
expect(guard.granted(read)).toBe(true); expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted(write)).toBe(true); expect(guard.granted(write, {})).toBeUndefined();
}); });
}); });

View File

@@ -1,6 +1,13 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { s } from "bknd/utils"; 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", () => { describe("Permission", () => {
it("works with minimal schema", () => { it("works with minimal schema", () => {
@@ -91,3 +98,331 @@ describe("Policy", () => {
expect(p.meetsFilter({ a: "test2" })).toBe(false); 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<any, any, any, any>[],
roles: Role[] = [],
config: Partial<GuardConfig> = {},
) => {
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());
});
});

View File

@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src"; 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 { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";

View File

@@ -60,8 +60,8 @@ export class AuthController extends Controller {
if (create) { if (create) {
hono.post( hono.post(
"/create", "/create",
permission(AuthPermissions.createUser), permission(AuthPermissions.createUser, {}),
permission(DataPermissions.entityCreate), permission(DataPermissions.entityCreate, {}),
describeRoute({ describeRoute({
summary: "Create a new user", summary: "Create a new user",
tags: ["auth"], tags: ["auth"],
@@ -239,7 +239,7 @@ export class AuthController extends Controller {
}), }),
}, },
async (params, c) => { 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)); return c.json(await this.auth.createUser(params));
}, },
@@ -256,7 +256,7 @@ export class AuthController extends Controller {
}), }),
}, },
async (params, c) => { 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); const user = await getUser(params);
return c.json({ user, token: await this.auth.authenticator.jwt(user) }); return c.json({ user, token: await this.auth.authenticator.jwt(user) });
@@ -275,7 +275,7 @@ export class AuthController extends Controller {
}), }),
}, },
async (params, c) => { 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); const user = await getUser(params);
if (!(await this.auth.changePassword(user.id, params.password))) { if (!(await this.auth.changePassword(user.id, params.password))) {
@@ -296,7 +296,7 @@ export class AuthController extends Controller {
}), }),
}, },
async (params, c) => { 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 pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
const controller = pw.getController(this.auth.authenticator); const controller = pw.getController(this.auth.authenticator);

View File

@@ -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 createUser = new Permission("auth.user.create");
//export const updateUser = new Permission("auth.user.update"); //export const updateUser = new Permission("auth.user.update");

View File

@@ -1,9 +1,11 @@
import { Exception } from "core/errors"; import { Exception } from "core/errors";
import { $console, objectTransform, type s } from "bknd/utils"; import { $console, type s } from "bknd/utils";
import { Permission } from "core/security/Permission"; import type { Permission, PermissionContext } from "auth/authorize/Permission";
import type { Context } from "hono"; import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller"; 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 = { export type GuardUserContext = {
role?: string | null; role?: string | null;
@@ -12,45 +14,43 @@ export type GuardUserContext = {
export type GuardConfig = { export type GuardConfig = {
enabled?: boolean; enabled?: boolean;
context?: string; context?: object;
}; };
export type GuardContext = Context<ServerEnv> | GuardUserContext; export type GuardContext = Context<ServerEnv> | GuardUserContext;
export class Guard { export class GuardPermissionsException extends Exception {
permissions: Permission[]; override name = "PermissionsException";
roles?: Role[]; override code = HttpStatus.FORBIDDEN;
config?: GuardConfig;
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<any, any, any, any>[] = [],
public roles: Role[] = [],
public config?: GuardConfig,
) {
this.permissions = permissions; this.permissions = permissions;
this.roles = roles; this.roles = roles;
this.config = config; 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[] { getPermissionNames(): string[] {
return this.permissions.map((permission) => permission.name); return this.permissions.map((permission) => permission.name);
} }
@@ -77,7 +77,7 @@ export class Guard {
return this; return this;
} }
registerPermission(permission: Permission) { registerPermission(permission: Permission<any, any, any, any>) {
if (this.permissions.find((p) => p.name === permission.name)) { if (this.permissions.find((p) => p.name === permission.name)) {
throw new Error(`Permission ${permission.name} already exists`); throw new Error(`Permission ${permission.name} already exists`);
} }
@@ -86,9 +86,13 @@ export class Guard {
return this; return this;
} }
registerPermissions(permissions: Record<string, Permission>); registerPermissions(permissions: Record<string, Permission<any, any, any, any>>);
registerPermissions(permissions: Permission[]); registerPermissions(permissions: Permission<any, any, any, any>[]);
registerPermissions(permissions: Permission[] | Record<string, Permission>) { registerPermissions(
permissions:
| Permission<any, any, any, any>[]
| Record<string, Permission<any, any, any, any>>,
) {
const p = Array.isArray(permissions) ? permissions : Object.values(permissions); const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
for (const permission of p) { for (const permission of p) {
@@ -121,69 +125,133 @@ export class Guard {
return this.config?.enabled === true; return this.config?.enabled === true;
} }
hasPermission(permission: Permission, user?: GuardUserContext): boolean; private collect(permission: Permission, c: GuardContext, context: any) {
hasPermission(name: string, user?: GuardUserContext): boolean; const user = c && "get" in c ? c.get("auth")?.user : c;
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean { const ctx = {
if (!this.isEnabled()) { ...((context ?? {}) as any),
return true; ...this.config?.context,
} user,
};
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name; const exists = this.permissionExists(permission.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`);
}
const role = this.getUserRole(user); const role = this.getUserRole(user);
const rolePermission = role?.permissions.find(
(rolePermission) => rolePermission.permission.name === permission.name,
);
return {
ctx,
user,
exists,
role,
rolePermission,
};
}
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
): void;
granted<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext): void;
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
): 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) { if (!role) {
$console.debug("guard: user has no role, denying"); $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) { } else if (role.implicit_allow === true) {
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`); $console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return true; return;
} }
const rolePermission = role.permissions.find( if (!rolePermission) {
(rolePermission) => rolePermission.permission.name === name, $console.debug("guard: rolePermission not found, denying");
); throw new GuardPermissionsException(
permission,
$console.debug("guard: rolePermission, allowing?", { undefined,
permission: name, "Role does not have required permission",
role: role.name,
allowing: !!rolePermission,
});
return !!rolePermission;
}
granted<P extends Permission>(
permission: P,
c?: GuardContext,
context: s.Static<P["context"]> = {} as s.Static<P["context"]>,
): boolean {
const user = c && "get" in c ? c.get("auth")?.user : c;
const ctx = {
...context,
user,
context: this.config?.context,
};
return this.hasPermission(permission, user);
}
throwUnlessGranted<P extends Permission>(
permission: P,
c: GuardContext,
context: s.Static<P["context"]>,
) {
if (!this.granted(permission, c)) {
throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403,
); );
} }
// 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<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
): PolicySchema["filter"] | undefined;
getPolicyFilter<P extends Permission<any, any, undefined, any>>(
permission: P,
c: GuardContext,
): PolicySchema["filter"] | undefined;
getPolicyFilter<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
): 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;
} }
} }

View File

@@ -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<typeof permissionOptionsSchema>;
export type PermissionContext<P extends Permission<any, any, any, any>> = P extends Permission<
any,
any,
infer Context,
any
>
? Context extends s.ObjectSchema
? s.Static<Context>
: 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<Context> : 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,
};
}
}

View File

@@ -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<typeof policySchema>;
export class Policy<Schema extends PolicySchema = PolicySchema> {
public content: Schema;
constructor(content?: Schema) {
this.content = parse(policySchema, content ?? {}, {
withDefaults: true,
}) as Schema;
}
replace(context: object, vars?: Record<string, any>) {
return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context;
}
meetsCondition(context: object, vars?: Record<string, any>) {
return query.validate(this.replace(this.content.condition!, vars), context);
}
meetsFilter(subject: object, vars?: Record<string, any>) {
return query.validate(this.replace(this.content.filter!, vars), subject);
}
getFiltered<Given extends any[]>(given: Given): Given {
return given.filter((item) => this.meetsFilter(item)) as Given;
}
toJSON() {
return this.content;
}
}

View File

@@ -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<typeof rolePermissionSchema>;
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<typeof roleSchema>;
export class RolePermission { export class RolePermission {
constructor( constructor(
public permission: Permission, public permission: Permission<any, any, any, any>,
public config?: any, public policies: Policy[] = [],
) {} ) {}
toJSON() {
return {
permission: this.permission.name,
policies: this.policies.map((p) => p.toJSON()),
};
}
} }
export class Role { export class Role {
@@ -15,31 +38,24 @@ export class Role {
public implicit_allow: boolean = false, public implicit_allow: boolean = false,
) {} ) {}
static createWithPermissionNames( static create(config: RoleSchema) {
name: string, const permissions =
permissionNames: string[], config.permissions?.map((p: string | RolePermissionSchema) => {
is_default: boolean = false, if (typeof p === "string") {
implicit_allow: boolean = false, return new RolePermission(new Permission(p), []);
) { }
return new Role( const policies = p.policies?.map((policy) => new Policy(policy));
name, return new RolePermission(new Permission(p.permission), policies);
permissionNames.map((name) => new RolePermission(new Permission(name))), }) ?? [];
is_default, return new Role(config.name, permissions, config.is_default, config.implicit_allow);
implicit_allow,
);
} }
static create(config: { toJSON() {
name: string; return {
permissions?: string[]; name: this.name,
is_default?: boolean; permissions: this.permissions.map((p) => p.toJSON()),
implicit_allow?: boolean; is_default: this.is_default,
}) { implicit_allow: this.implicit_allow,
return new Role( };
config.name,
config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
config.is_default,
config.implicit_allow,
);
} }
} }

View File

@@ -1,9 +1,7 @@
import type { Permission } from "core/security/Permission"; import { $console, patternMatch } from "bknd/utils";
import { $console, patternMatch, type s } from "bknd/utils";
import type { Context } from "hono"; import type { Context } from "hono";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
import type { MaybePromise } from "core/types";
function getPath(reqOrCtx: Request | Context) { function getPath(reqOrCtx: Request | Context) {
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw; const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
@@ -68,49 +66,3 @@ export const auth = (options?: {
authCtx.resolved = false; authCtx.resolved = false;
authCtx.user = undefined; authCtx.user = undefined;
}); });
export const permission = <P extends Permission>(
permission: P,
options?: {
onGranted?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
context?: (c: Context<ServerEnv>) => MaybePromise<s.Static<P["context"]>>;
},
) =>
// @ts-ignore
createMiddleware<ServerEnv>(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();
});

View File

@@ -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<P extends Permission<any, any, any, any>> = {
onGranted?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
} & (P extends Permission<any, any, infer PC, any>
? PC extends undefined
? {
context?: never;
}
: {
context: (c: Context<ServerEnv>) => MaybePromise<PermissionContext<P>>;
}
: {
context?: never;
});
export function permission<P extends Permission<any, any, any, any>>(
permission: P,
options: PermissionMiddlewareOptions<P>,
) {
// @ts-ignore (middlewares do not always return)
const handler = createMiddleware<ServerEnv>(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<any>) {
const routes: {
route: RouterRoute;
permission: Permission;
options: PermissionMiddlewareOptions<Permission>;
}[] = [];
for (const route of hono.routes) {
if (permissionSymbol in route.handler) {
routes.push({
route,
...(route.handler[permissionSymbol] as any),
});
}
}
return routes;
}

View File

@@ -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<typeof permissionOptionsSchema>;
export class InvalidPermissionContextError extends InvalidSchemaError {
override name = "InvalidPermissionContextError";
static from(e: InvalidSchemaError) {
return new InvalidPermissionContextError(e.schema, e.value, e.errors);
}
}
export class Permission<
Name extends string = string,
Options extends PermissionOptions = {},
Context extends s.ObjectSchema = s.ObjectSchema,
> {
constructor(
public name: Name,
public options: Options = {} as Options,
public context: Context = s.object({}) as Context,
) {}
parseContext(ctx: s.Static<Context>, opts?: ParseOptions) {
try {
return parse(this.context, ctx, opts);
} catch (e) {
if (e instanceof InvalidSchemaError) {
throw InvalidPermissionContextError.from(e);
}
throw e;
}
}
toJSON() {
return {
name: this.name,
...this.options,
context: this.context,
};
}
}
export const policySchema = s
.strictObject({
description: s.string(),
condition: s.object({}, { default: {} }) as s.Schema<{}, query.ObjectQuery>,
effect: s.string({ enum: ["allow", "deny", "filter"], default: "deny" }),
filter: s.object({}, { default: {} }) as s.Schema<{}, query.ObjectQuery>,
})
.partial();
export type PolicySchema = s.Static<typeof policySchema>;
export class Policy<Schema extends PolicySchema> {
public content: Schema;
constructor(content?: Schema) {
this.content = parse(policySchema, content ?? {}) as Schema;
}
replace(context: object, vars?: Record<string, any>) {
return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context;
}
meetsCondition(context: object, vars?: Record<string, any>) {
return query.validate(this.replace(this.content.condition!, vars), context);
}
meetsFilter(subject: object, vars?: Record<string, any>) {
return query.validate(this.replace(this.content.filter!, vars), subject);
}
getFiltered<Given extends any[]>(given: Given): Given {
return given.filter((item) => this.meetsFilter(item)) as Given;
}
toJSON() {
return this.content;
}
}

View File

@@ -61,3 +61,12 @@ export function invariant(condition: boolean | any, message: string) {
throw new Error(message); throw new Error(message);
} }
} }
export function threw(fn: () => any) {
try {
fn();
return false;
} catch (e) {
return true;
}
}

View File

@@ -1,3 +1,5 @@
import { Exception } from "core/errors";
import { HttpStatus } from "bknd/utils";
import * as s from "jsonv-ts"; import * as s from "jsonv-ts";
export { validator as jsc, type Options } from "jsonv-ts/hono"; export { validator as jsc, type Options } from "jsonv-ts/hono";
@@ -58,8 +60,9 @@ export const stringIdentifier = s.string({
maxLength: 150, maxLength: 150,
}); });
export class InvalidSchemaError extends Error { export class InvalidSchemaError extends Exception {
override name = "InvalidSchemaError"; override name = "InvalidSchemaError";
override code = HttpStatus.UNPROCESSABLE_ENTITY;
constructor( constructor(
public schema: s.Schema, public schema: s.Schema,

View File

@@ -42,7 +42,7 @@ export class DataController extends Controller {
override getController() { override getController() {
const { permission, auth } = this.middlewares; 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); const entitiesEnum = this.getEntitiesEnum(this.em);
// info // info
@@ -58,7 +58,7 @@ export class DataController extends Controller {
// sync endpoint // sync endpoint
hono.get( hono.get(
"/sync", "/sync",
permission(DataPermissions.databaseSync), permission(DataPermissions.databaseSync, {}),
mcpTool("data_sync", { mcpTool("data_sync", {
// @todo: should be removed if readonly // @todo: should be removed if readonly
annotations: { annotations: {
@@ -95,7 +95,7 @@ export class DataController extends Controller {
// read entity schema // read entity schema
hono.get( hono.get(
"/schema.json", "/schema.json",
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead, {}),
describeRoute({ describeRoute({
summary: "Retrieve data schema", summary: "Retrieve data schema",
tags: ["data"], tags: ["data"],
@@ -121,7 +121,7 @@ export class DataController extends Controller {
// read schema // read schema
hono.get( hono.get(
"/schemas/:entity/:context?", "/schemas/:entity/:context?",
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead, {}),
describeRoute({ describeRoute({
summary: "Retrieve entity schema", summary: "Retrieve entity schema",
tags: ["data"], tags: ["data"],
@@ -161,7 +161,7 @@ export class DataController extends Controller {
*/ */
hono.get( hono.get(
"/info/:entity", "/info/:entity",
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead, {}),
describeRoute({ describeRoute({
summary: "Retrieve entity info", summary: "Retrieve entity info",
tags: ["data"], tags: ["data"],
@@ -213,7 +213,7 @@ export class DataController extends Controller {
// fn: count // fn: count
hono.post( hono.post(
"/:entity/fn/count", "/:entity/fn/count",
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead, {}),
describeRoute({ describeRoute({
summary: "Count entities", summary: "Count entities",
tags: ["data"], tags: ["data"],
@@ -236,7 +236,7 @@ export class DataController extends Controller {
// fn: exists // fn: exists
hono.post( hono.post(
"/:entity/fn/exists", "/:entity/fn/exists",
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead, {}),
describeRoute({ describeRoute({
summary: "Check if entity exists", summary: "Check if entity exists",
tags: ["data"], tags: ["data"],
@@ -285,7 +285,7 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]), parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead, {}),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc("query", repoQuery, { skipOpenAPI: true }), jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => { async (c) => {
@@ -308,7 +308,7 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["offset", "sort", "select"]), parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead, {}),
mcpTool("data_entity_read_one", { mcpTool("data_entity_read_one", {
inputSchema: { inputSchema: {
param: s.object({ entity: entitiesEnum, id: idType }), param: s.object({ entity: entitiesEnum, id: idType }),
@@ -344,7 +344,7 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(), parameters: saveRepoQueryParams(),
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead, {}),
jsc( jsc(
"param", "param",
s.object({ s.object({
@@ -390,7 +390,7 @@ export class DataController extends Controller {
}, },
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead, {}),
mcpTool("data_entity_read_many", { mcpTool("data_entity_read_many", {
inputSchema: { inputSchema: {
param: s.object({ entity: entitiesEnum }), param: s.object({ entity: entitiesEnum }),
@@ -421,7 +421,7 @@ export class DataController extends Controller {
summary: "Insert one or many", summary: "Insert one or many",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityCreate), permission(DataPermissions.entityCreate, {}),
mcpTool("data_entity_insert"), mcpTool("data_entity_insert"),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
@@ -455,7 +455,7 @@ export class DataController extends Controller {
summary: "Update many", summary: "Update many",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityUpdate), permission(DataPermissions.entityUpdate, {}),
mcpTool("data_entity_update_many", { mcpTool("data_entity_update_many", {
inputSchema: { inputSchema: {
param: s.object({ entity: entitiesEnum }), param: s.object({ entity: entitiesEnum }),
@@ -495,7 +495,7 @@ export class DataController extends Controller {
summary: "Update one", summary: "Update one",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityUpdate), permission(DataPermissions.entityUpdate, {}),
mcpTool("data_entity_update_one"), mcpTool("data_entity_update_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})), jsc("json", s.object({})),
@@ -518,7 +518,7 @@ export class DataController extends Controller {
summary: "Delete one", summary: "Delete one",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete, {}),
mcpTool("data_entity_delete_one"), mcpTool("data_entity_delete_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => { async (c) => {
@@ -539,7 +539,7 @@ export class DataController extends Controller {
summary: "Delete many", summary: "Delete many",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete, {}),
mcpTool("data_entity_delete_many", { mcpTool("data_entity_delete_many", {
inputSchema: { inputSchema: {
param: s.object({ entity: entitiesEnum }), param: s.object({ entity: entitiesEnum }),

View File

@@ -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 entityRead = new Permission("data.entity.read");
export const entityCreate = new Permission("data.entity.create"); export const entityCreate = new Permission("data.entity.create");

View File

@@ -45,7 +45,7 @@ export type { MaybePromise } from "core/types";
export { Exception, BkndError } from "core/errors"; export { Exception, BkndError } from "core/errors";
export { isDebug, env } from "core/env"; export { isDebug, env } from "core/env";
export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; 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 { getFlashMessage } from "core/server/flash";
export * from "core/drivers"; export * from "core/drivers";
export { Event, InvalidEventReturn } from "core/events/Event"; export { Event, InvalidEventReturn } from "core/events/Event";

View File

@@ -36,7 +36,7 @@ export class MediaController extends Controller {
summary: "Get the list of files", summary: "Get the list of files",
tags: ["media"], tags: ["media"],
}), }),
permission(MediaPermissions.listFiles), permission(MediaPermissions.listFiles, {}),
async (c) => { async (c) => {
const files = await this.getStorageAdapter().listObjects(); const files = await this.getStorageAdapter().listObjects();
return c.json(files); return c.json(files);
@@ -51,7 +51,7 @@ export class MediaController extends Controller {
summary: "Get a file by name", summary: "Get a file by name",
tags: ["media"], tags: ["media"],
}), }),
permission(MediaPermissions.readFile), permission(MediaPermissions.readFile, {}),
async (c) => { async (c) => {
const { filename } = c.req.param(); const { filename } = c.req.param();
if (!filename) { if (!filename) {
@@ -81,7 +81,7 @@ export class MediaController extends Controller {
summary: "Delete a file by name", summary: "Delete a file by name",
tags: ["media"], tags: ["media"],
}), }),
permission(MediaPermissions.deleteFile), permission(MediaPermissions.deleteFile, {}),
async (c) => { async (c) => {
const { filename } = c.req.param(); const { filename } = c.req.param();
if (!filename) { if (!filename) {
@@ -149,7 +149,7 @@ export class MediaController extends Controller {
requestBody, requestBody,
}), }),
jsc("param", s.object({ filename: s.string().optional() })), jsc("param", s.object({ filename: s.string().optional() })),
permission(MediaPermissions.uploadFile), permission(MediaPermissions.uploadFile, {}),
async (c) => { async (c) => {
const reqname = c.req.param("filename"); const reqname = c.req.param("filename");
@@ -189,8 +189,8 @@ export class MediaController extends Controller {
}), }),
), ),
jsc("query", s.object({ overwrite: s.boolean().optional() })), jsc("query", s.object({ overwrite: s.boolean().optional() })),
permission(DataPermissions.entityCreate), permission(DataPermissions.entityCreate, {}),
permission(MediaPermissions.uploadFile), permission(MediaPermissions.uploadFile, {}),
async (c) => { async (c) => {
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param"); const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");

View File

@@ -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 readFile = new Permission("media.file.read");
export const listFiles = new Permission("media.file.list"); export const listFiles = new Permission("media.file.list");

View File

@@ -5,7 +5,7 @@ import { entityTypes } from "data/entities/Entity";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module"; import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module";
import type { EntityRelation } from "data/relations"; 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 { Exception } from "core/errors";
import { invariant, isPlainObject } from "bknd/utils"; import { invariant, isPlainObject } from "bknd/utils";
@@ -114,10 +114,20 @@ export class ModuleHelper {
entity.__replaceField(name, newField); entity.__replaceField(name, newField);
} }
async throwUnlessGranted( async granted<P extends Permission<any, any, any, any>>(
permission: Permission,
c: { context: ModuleBuildContextMcpContext; raw?: unknown }, 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"); invariant(c.context.app, "app is not available in mcp context");
const auth = c.context.app.module.auth; const auth = c.context.app.module.auth;
if (!auth.enabled) return; if (!auth.enabled) return;
@@ -127,12 +137,6 @@ export class ModuleHelper {
} }
const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any); const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any);
this.ctx.guard.granted(permission, { user }, context as any);
if (!this.ctx.guard.granted(permission, user)) {
throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403,
);
}
} }
} }

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"; import { s } from "bknd/utils";
export const accessAdmin = new Permission("system.access.admin"); export const accessAdmin = new Permission("system.access.admin");
@@ -24,6 +24,12 @@ export const configWrite = new Permission(
module: s.string().optional(), 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 build = new Permission("system.build");
export const mcp = new Permission("system.mcp"); export const mcp = new Permission("system.mcp");

View File

@@ -116,6 +116,7 @@ export class AdminController extends Controller {
onDenied: async (c) => { onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning"); addFlashMessage(c, "You not allowed to read the schema", "warning");
}, },
context: (c) => ({}),
}), }),
async (c) => { async (c) => {
const obj: AdminBkndWindowContext = { const obj: AdminBkndWindowContext = {
@@ -147,9 +148,10 @@ export class AdminController extends Controller {
return c.redirect(authRoutes.success); return c.redirect(authRoutes.success);
} }
}, },
context: (c) => ({}),
}; };
const redirectRouteParams = [ const redirectRouteParams = [
permission(SystemPermissions.accessAdmin, options), permission(SystemPermissions.accessAdmin, options as any),
permission(SystemPermissions.schemaRead, options), permission(SystemPermissions.schemaRead, options),
async (c) => { async (c) => {
return c.html(c.get("html")!); return c.html(c.get("html")!);

View File

@@ -87,6 +87,10 @@ export class AppServer extends Module<AppServerConfig> {
} }
if (err instanceof AuthException) { if (err instanceof AuthException) {
if (isDebug()) {
return c.json(err.toJSON(), err.code);
}
return c.json(err.toJSON(), err.getSafeErrorAndCode().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 { private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares; const { permission } = this.middlewares;
// don't add auth again, it's already added in getController // 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()) { if (!this.app.isReadOnly()) {
const manager = this.app.modules as DbModuleManager; const manager = this.app.modules as DbModuleManager;
@@ -130,7 +130,11 @@ export class SystemController extends Controller {
summary: "Get the raw config", summary: "Get the raw config",
tags: ["system"], tags: ["system"],
}), }),
permission(SystemPermissions.configReadSecrets), permission(SystemPermissions.configReadSecrets, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => { async (c) => {
// @ts-expect-error "fetch" is private // @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch().then((r) => r?.configs)); return c.json(await this.app.modules.fetch().then((r) => r?.configs));
@@ -165,7 +169,11 @@ export class SystemController extends Controller {
hono.post( hono.post(
"/set/:module", "/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 }), jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
async (c) => { async (c) => {
const module = c.req.param("module") as any; 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) => { hono.post(
// @todo: require auth (admin) "/add/:module/:path",
const module = c.req.param("module") as any; permission(SystemPermissions.configWrite, {
const value = await c.req.json(); context: (c) => ({
const path = c.req.param("path") as string; 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)) { if (this.app.modules.get(module).schema().has(path)) {
return c.json( return c.json(
{ success: false, path, error: "Path already exists" }, { success: false, path, error: "Path already exists" },
{ status: 400 }, { status: 400 },
); );
} }
return await handleConfigUpdateResponse(c, async () => { return await handleConfigUpdateResponse(c, async () => {
await manager.mutateConfigSafe(module).patch(path, value); await manager.mutateConfigSafe(module).patch(path, value);
return { return {
success: true, success: true,
module, module,
config: this.app.module[module].config, config: this.app.module[module].config,
}; };
}); });
}); },
);
hono.patch( hono.patch(
"/patch/:module/:path", "/patch/:module/:path",
permission(SystemPermissions.configWrite), permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => { async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
@@ -239,7 +259,11 @@ export class SystemController extends Controller {
hono.put( hono.put(
"/overwrite/:module/:path", "/overwrite/:module/:path",
permission(SystemPermissions.configWrite), permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => { async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
@@ -259,7 +283,11 @@ export class SystemController extends Controller {
hono.delete( hono.delete(
"/remove/:module/:path", "/remove/:module/:path",
permission(SystemPermissions.configWrite), permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => { async (c) => {
// @todo: require auth (admin) // @todo: require auth (admin)
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
@@ -296,7 +324,7 @@ export class SystemController extends Controller {
const { module } = c.req.valid("param"); const { module } = c.req.valid("param");
if (secrets) { if (secrets) {
this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, { this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
module, module,
}); });
} }
@@ -330,7 +358,11 @@ export class SystemController extends Controller {
summary: "Get the schema for a module", summary: "Get the schema for a module",
tags: ["system"], tags: ["system"],
}), }),
permission(SystemPermissions.schemaRead), permission(SystemPermissions.schemaRead, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
jsc( jsc(
"query", "query",
s s
@@ -347,12 +379,12 @@ export class SystemController extends Controller {
const readonly = this.app.isReadOnly(); const readonly = this.app.isReadOnly();
if (config) { if (config) {
this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c, { this.ctx.guard.granted(SystemPermissions.configRead, c, {
module, module,
}); });
} }
if (secrets) { if (secrets) {
this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c, { this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
module, module,
}); });
} }
@@ -395,7 +427,7 @@ export class SystemController extends Controller {
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })), jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
async (c) => { async (c) => {
const options = c.req.valid("query") as Record<string, boolean>; const options = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c, {}); this.ctx.guard.granted(SystemPermissions.build, c);
await this.app.build(options); await this.app.build(options);
return c.json({ return c.json({
@@ -467,7 +499,7 @@ export class SystemController extends Controller {
const { version, ...appConfig } = this.app.toJSON(); const { version, ...appConfig } = this.app.toJSON();
mcp.resource("system_config", "bknd://system/config", async (c) => { 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(), { return c.json(this.app.toJSON(), {
title: "System Config", title: "System Config",
@@ -477,7 +509,9 @@ export class SystemController extends Controller {
"system_config_module", "system_config_module",
"bknd://system/config/{module}", "bknd://system/config/{module}",
async (c, { 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; const m = this.app.modules.get(module as any) as Module;
return c.json(m.toJSON(), { return c.json(m.toJSON(), {
@@ -489,7 +523,7 @@ export class SystemController extends Controller {
}, },
) )
.resource("system_schema", "bknd://system/schema", async (c) => { .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(), { return c.json(this.app.getSchema(), {
title: "System Schema", title: "System Schema",
@@ -499,7 +533,9 @@ export class SystemController extends Controller {
"system_schema_module", "system_schema_module",
"bknd://system/schema/{module}", "bknd://system/schema/{module}",
async (c, { 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); const m = this.app.modules.get(module as any);
return c.json(m.getSchema().toJSON(), { return c.json(m.getSchema().toJSON(), {