mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
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:
20
app/__test__/auth/authorize/SystemController.spec.ts
Normal file
20
app/__test__/auth/authorize/SystemController.spec.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
app/src/auth/authorize/Permission.ts
Normal file
68
app/src/auth/authorize/Permission.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/src/auth/authorize/Policy.ts
Normal file
42
app/src/auth/authorize/Policy.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
93
app/src/auth/middlewares/permission.middleware.ts
Normal file
93
app/src/auth/middlewares/permission.middleware.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { auth, permission } from "auth/middlewares";
|
export { auth } from "auth/middlewares/auth.middleware";
|
||||||
|
export { permission } from "auth/middlewares/permission.middleware";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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")!);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(), {
|
||||||
|
|||||||
Reference in New Issue
Block a user