Merge pull request #280 from bknd-io/feat/advanced-permissions

feat: advanced permissions (~RLS)
This commit is contained in:
dswbx
2025-10-24 15:18:05 +02:00
committed by GitHub
63 changed files with 3024 additions and 463 deletions

View File

@@ -201,7 +201,10 @@ describe("mcp auth", async () => {
},
return_config: true,
});
expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]);
expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([
"read",
"write",
]);
// update role
await tool(server, "config_auth_roles_update", {
@@ -210,13 +213,15 @@ describe("mcp auth", async () => {
permissions: ["read"],
},
});
expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]);
expect(app.toJSON().auth.roles?.guest?.permissions?.map((p) => p.permission)).toEqual([
"read",
]);
// get role
const getGuestRole = await tool(server, "config_auth_roles_get", {
key: "guest",
});
expect(getGuestRole.value.permissions).toEqual(["read"]);
expect(getGuestRole.value.permissions.map((p) => p.permission)).toEqual(["read"]);
// remove role
await tool(server, "config_auth_roles_remove", {

View File

@@ -1,9 +1,31 @@
import { describe, expect, test } from "bun:test";
import { Guard } from "../../../src/auth/authorize/Guard";
import { Guard, type GuardConfig } from "auth/authorize/Guard";
import { Permission } from "auth/authorize/Permission";
import { Role, type RoleSchema } from "auth/authorize/Role";
import { objectTransform, s } from "bknd/utils";
function createGuard(
permissionNames: string[],
roles?: Record<string, Omit<RoleSchema, "name">>,
config?: GuardConfig,
) {
const _roles = roles
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
return Role.create(name, { permissions, is_default, implicit_allow });
})
: {};
const _permissions = permissionNames.map((name) => new Permission(name));
return new Guard(_permissions, Object.values(_roles), config);
}
describe("authorize", () => {
const read = new Permission("read", {
filterable: true,
});
const write = new Permission("write");
test("basic", async () => {
const guard = Guard.create(
const guard = createGuard(
["read", "write"],
{
admin: {
@@ -16,14 +38,14 @@ describe("authorize", () => {
role: "admin",
};
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
expect(() => guard.granted("something")).toThrow();
expect(() => guard.granted(new Permission("something"), {})).toThrow();
});
test("with default", async () => {
const guard = Guard.create(
const guard = createGuard(
["read", "write"],
{
admin: {
@@ -37,26 +59,26 @@ describe("authorize", () => {
{ enabled: true },
);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(false);
expect(guard.granted(read, {})).toBeUndefined();
expect(() => guard.granted(write, {})).toThrow();
const user = {
role: "admin",
};
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
});
test("guard implicit allow", async () => {
const guard = Guard.create([], {}, { enabled: false });
const guard = createGuard([], {}, { enabled: false });
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted(write, {})).toBeUndefined();
});
test("role implicit allow", async () => {
const guard = Guard.create(["read", "write"], {
const guard = createGuard(["read", "write"], {
admin: {
implicit_allow: true,
},
@@ -66,12 +88,12 @@ describe("authorize", () => {
role: "admin",
};
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
});
test("guard with guest role implicit allow", async () => {
const guard = Guard.create(["read", "write"], {
const guard = createGuard(["read", "write"], {
guest: {
implicit_allow: true,
is_default: true,
@@ -79,7 +101,143 @@ describe("authorize", () => {
});
expect(guard.getUserRole()?.name).toBe("guest");
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted(write, {})).toBeUndefined();
});
describe("cases", () => {
test("guest none, member deny if user.enabled is false", () => {
const guard = createGuard(
["read"],
{
guest: {
is_default: true,
},
member: {
permissions: [
{
permission: "read",
policies: [
{
condition: {},
effect: "filter",
filter: {
type: "member",
},
},
{
condition: {
"user.enabled": false,
},
effect: "deny",
},
],
},
],
},
},
{ enabled: true },
);
expect(() => guard.granted(read, { role: "guest" })).toThrow();
// member is allowed, because default role permission effect is allow
// and no deny policy is met
expect(guard.granted(read, { role: "member" })).toBeUndefined();
// member is allowed, because deny policy is not met
expect(guard.granted(read, { role: "member", enabled: true })).toBeUndefined();
// member is denied, because deny policy is met
expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow();
// get the filter for member role
expect(guard.filters(read, { role: "member" }).filter).toEqual({
type: "member",
});
// get filter for guest
expect(guard.filters(read, {}).filter).toBeUndefined();
});
test("guest should only read posts that are public", () => {
const read = new Permission(
"read",
{
// make this permission filterable
// without this, `filter` policies have no effect
filterable: true,
},
// expect the context to match this schema
// otherwise exit with 500 to ensure proper policy checking
s.object({
entity: s.string(),
}),
);
const guard = createGuard(
["read"],
{
guest: {
// this permission is applied if no (or invalid) role is provided
is_default: true,
permissions: [
{
permission: "read",
// effect deny means only having this permission, doesn't guarantee access
effect: "deny",
policies: [
{
// only if this condition is met
condition: {
entity: {
$in: ["posts"],
},
},
// the effect is allow
effect: "allow",
},
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
public: true,
},
},
],
},
],
},
// members should be allowed to read all
member: {
permissions: [
{
permission: "read",
},
],
},
},
{ enabled: true },
);
// guest can only read posts
expect(guard.granted(read, {}, { entity: "posts" })).toBeUndefined();
expect(() => guard.granted(read, {}, { entity: "users" })).toThrow();
// and guests can only read public posts
expect(guard.filters(read, {}, { entity: "posts" }).filter).toEqual({
public: true,
});
// member can read posts and users
expect(guard.granted(read, { role: "member" }, { entity: "posts" })).toBeUndefined();
expect(guard.granted(read, { role: "member" }, { entity: "users" })).toBeUndefined();
// member should not have a filter
expect(
guard.filters(read, { role: "member" }, { entity: "posts" }).filter,
).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,327 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import * as proto from "data/prototype";
import { mergeObject } from "core/utils/objects";
import type { App, DB } from "bknd";
import type { CreateUserPayload } from "auth/AppAuth";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
const app = createApp({
config: mergeObject(
{
data: proto
.em(
{
users: proto.systemEntity("users", {}),
posts: proto.entity("posts", {
title: proto.text(),
content: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
},
({ relation }, { users, posts, comments }) => {
relation(posts).manyToOne(users);
relation(comments).manyToOne(posts);
},
)
.toJSON(),
auth: {
enabled: true,
jwt: {
secret: "secret",
},
},
},
config,
),
});
await app.build();
return app;
}
async function createUsers(app: App, users: CreateUserPayload[]) {
return Promise.all(
users.map(async (user) => {
return await app.createUser(user);
}),
);
}
async function loadFixtures(app: App, fixtures: Record<string, any[]> = {}) {
const results = {} as any;
for (const [entity, data] of Object.entries(fixtures)) {
results[entity] = await app.em
.mutator(entity as any)
.insertMany(data)
.then((result) => result.data);
}
return results;
}
describe("data permissions", async () => {
const app = await makeApp({
server: {
mcp: {
enabled: true,
},
},
auth: {
guard: {
enabled: true,
},
roles: {
guest: {
is_default: true,
permissions: [
{
permission: "system.access.api",
},
{
permission: "data.entity.read",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.create",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.update",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.delete",
policies: [
{
condition: { entity: "posts" },
},
{
condition: { entity: "posts" },
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
],
},
},
},
});
const users = [
{ email: "foo@example.com", password: "password" },
{ email: "bar@example.com", password: "password" },
];
const fixtures = {
posts: [
{ content: "post 1", users_id: 1 },
{ content: "post 2", users_id: 2 },
{ content: "post 3", users_id: null },
],
comments: [
{ content: "comment 1", posts_id: 1 },
{ content: "comment 2", posts_id: 2 },
{ content: "comment 3", posts_id: 3 },
],
};
await createUsers(app, users);
const results = await loadFixtures(app, fixtures);
describe("http", async () => {
it("read many", async () => {
// many only includes posts with users_id is null
const res = await app.server.request("/api/data/entity/posts");
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
// same with /query
{
const res = await app.server.request("/api/data/entity/posts/query", {
method: "POST",
});
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
}
});
it("read one", async () => {
// one only includes posts with users_id is null
{
const res = await app.server.request("/api/data/entity/posts/1");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(404);
expect(data).toBeUndefined();
}
// read one by allowed id
{
const res = await app.server.request("/api/data/entity/posts/3");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.posts[2]);
}
});
it("read many by reference", async () => {
const res = await app.server.request("/api/data/entity/posts/1/comments");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.comments.filter((c: any) => c.posts_id === 1));
});
it("mutation create one", async () => {
// not allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
}
// allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4", users_id: null }),
});
expect(res.status).toBe(201);
}
});
it("mutation update one", async () => {
// update one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "PATCH",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
{
// update one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "PATCH",
body: JSON.stringify({ content: "post 3 (updated)" }),
});
expect(res.status).toBe(200);
expect(await res.json().then((r: any) => r.data.content)).toBe("post 3 (updated)");
}
});
it("mutation update many", async () => {
// update many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
update: { content: "post 4" },
where: { users_id: { $isnull: 0 } },
}),
});
expect(res.status).toBe(200); // because filtered
const _data = await res.json().then((r: any) => r.data.map((p: any) => p.users_id));
expect(_data.every((u: any) => u === null)).toBe(true);
// verify
const data = await app.em
.repo("posts")
.findMany({ select: ["content", "users_id"] })
.then((r) => r.data);
// expect non null users_id to not have content "post 4"
expect(
data.filter((p: any) => p.users_id !== null).every((p: any) => p.content !== "post 4"),
).toBe(true);
// expect null users_id to have content "post 4"
expect(
data.filter((p: any) => p.users_id === null).every((p: any) => p.content === "post 4"),
).toBe(true);
});
const count = async () => {
const {
data: { count: _count },
} = await app.em.repo("posts").count();
return _count;
};
it("mutation delete one", async () => {
const initial = await count();
// delete one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "DELETE",
});
expect(res.status).toBe(403);
expect(await count()).toBe(initial);
{
// delete one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(await count()).toBe(initial - 1);
}
});
it("mutation delete many", async () => {
// delete many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
where: {},
}),
});
expect(res.status).toBe(200);
// only deleted posts with users_id is null
const remaining = await app.em
.repo("posts")
.findMany()
.then((r) => r.data);
expect(remaining.every((p: any) => p.users_id !== null)).toBe(true);
});
});
});

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.skip("SystemController", () => {
it("...", async () => {
const app = await makeApp();
const controller = new SystemController(app);
const hono = controller.getController();
console.log(getPermissionRoutes(hono));
});
});

View File

@@ -0,0 +1,502 @@
import { describe, it, expect } from "bun:test";
import { s } from "bknd/utils";
import { Permission } from "auth/authorize/Permission";
import { Policy } from "auth/authorize/Policy";
import { Hono } from "hono";
import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware";
import { auth } from "auth/middlewares/auth.middleware";
import { Guard, type GuardConfig } from "auth/authorize/Guard";
import { Role, RolePermission } from "auth/authorize/Role";
import { Exception } from "bknd";
describe("Permission", () => {
it("works with minimal schema", () => {
expect(() => new Permission("test")).not.toThrow();
});
it("parses context", () => {
const p = new Permission(
"test3",
{
filterable: true,
},
s.object({
a: s.string(),
}),
);
// @ts-expect-error
expect(() => p.parseContext({ a: [] })).toThrow();
expect(p.parseContext({ a: "test" })).toEqual({ a: "test" });
// @ts-expect-error
expect(p.parseContext({ a: 1 })).toEqual({ a: "1" });
});
});
describe("Policy", () => {
it("works with minimal schema", () => {
expect(() => new Policy().toJSON()).not.toThrow();
});
it("checks condition", () => {
const p = new Policy({
condition: {
a: 1,
},
});
expect(p.meetsCondition({ a: 1 })).toBe(true);
expect(p.meetsCondition({ a: 2 })).toBe(false);
expect(p.meetsCondition({ a: 1, b: 1 })).toBe(true);
expect(p.meetsCondition({})).toBe(false);
const p2 = new Policy({
condition: {
a: { $gt: 1 },
$or: {
b: { $lt: 2 },
},
},
});
expect(p2.meetsCondition({ a: 2 })).toBe(true);
expect(p2.meetsCondition({ a: 1 })).toBe(false);
expect(p2.meetsCondition({ a: 1, b: 1 })).toBe(true);
});
it("filters", () => {
const p = new Policy({
filter: {
age: { $gt: 18 },
},
});
const subjects = [{ age: 19 }, { age: 17 }, { age: 12 }];
expect(p.getFiltered(subjects)).toEqual([{ age: 19 }]);
expect(p.meetsFilter({ age: 19 })).toBe(true);
expect(p.meetsFilter({ age: 17 })).toBe(false);
expect(p.meetsFilter({ age: 12 })).toBe(false);
});
it("replaces placeholders", () => {
const p = new Policy({
condition: {
a: "@auth.username",
},
filter: {
a: "@auth.username",
},
});
const vars = { auth: { username: "test" } };
expect(p.meetsCondition({ a: "test" }, vars)).toBe(true);
expect(p.meetsCondition({ a: "test2" }, vars)).toBe(false);
expect(p.meetsCondition({ a: "test2" })).toBe(false);
expect(p.meetsFilter({ a: "test" }, vars)).toBe(true);
expect(p.meetsFilter({ a: "test2" }, vars)).toBe(false);
expect(p.meetsFilter({ a: "test2" })).toBe(false);
});
});
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({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
]);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(guard.filters(p, { role: r.name }, { a: 1 }).filter).toEqual({ foo: "bar" });
expect(guard.filters(p, { role: r.name }, { a: 2 }).filter).toBeUndefined();
// if no user context given, filter cannot be applied
expect(guard.filters(p, {}, { a: 1 }).filter).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({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
],
true,
);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 1 },
).filter,
).toEqual({ foo: "bar" });
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 2 },
).filter,
).toBeUndefined();
// if no user context given, the default role is applied
// hence it can be found
expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" });
});
});
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("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 },
},
// default effect is allow
}),
],
// change default effect to deny if no condition is met
"deny",
),
]);
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);
});
it("checks context on routes with permissions", async () => {
const make = (user: any) => {
const p = new Permission(
"test",
{},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
return makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user });
await next();
})
.get(
"/valid",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid",
permission(p, {
// @ts-expect-error
context: (c) => ({
b: "1",
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid2",
permission(p, {
// @ts-expect-error
context: (c) => ({}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid3",
// @ts-expect-error
permission(p),
async (c) => c.text("test"),
);
};
const hono = make({ id: 0, role: "test" });
const valid = await hono.request("/valid");
expect(valid.status).toBe(200);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.status).toBe(500);
{
const hono = make(null);
const valid = await hono.request("/valid");
expect(valid.status).toBe(403);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.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(p.name, json);
expect(r2.toJSON()).toEqual(r.toJSON());
});
});

View File

@@ -66,4 +66,14 @@ describe("object-query", () => {
expect(result).toBe(expected);
}
});
test("paths", () => {
const result = validate({ "user.age": { $lt: 18 } }, { user: { age: 17 } });
expect(result).toBe(true);
});
test("empty filters", () => {
const result = validate({}, { user: { age: 17 } });
expect(result).toBe(true);
});
});

View File

@@ -194,6 +194,182 @@ describe("Core Utils", async () => {
expect(result).toEqual(expected);
}
});
test("recursivelyReplacePlaceholders", () => {
// test basic replacement with simple pattern
const obj1 = { a: "Hello, {$name}!", b: { c: "Hello, {$name}!" } };
const variables1 = { name: "John" };
const result1 = utils.recursivelyReplacePlaceholders(obj1, /\{\$(\w+)\}/g, variables1);
expect(result1).toEqual({ a: "Hello, John!", b: { c: "Hello, John!" } });
// test the specific example from the user request
const obj2 = { some: "value", here: "@auth.user" };
const variables2 = { auth: { user: "what" } };
const result2 = utils.recursivelyReplacePlaceholders(obj2, /^@([a-z\.]+)$/, variables2);
expect(result2).toEqual({ some: "value", here: "what" });
// test with arrays
const obj3 = { items: ["@config.name", "static", "@config.version"] };
const variables3 = { config: { name: "MyApp", version: "1.0.0" } };
const result3 = utils.recursivelyReplacePlaceholders(obj3, /^@([a-z\.]+)$/, variables3);
expect(result3).toEqual({ items: ["MyApp", "static", "1.0.0"] });
// test with nested objects and deep paths
const obj4 = {
user: "@auth.user.name",
settings: {
theme: "@ui.theme",
nested: {
value: "@deep.nested.value",
},
},
};
const variables4 = {
auth: { user: { name: "Alice" } },
ui: { theme: "dark" },
deep: { nested: { value: "found" } },
};
const result4 = utils.recursivelyReplacePlaceholders(obj4, /^@([a-z\.]+)$/, variables4);
expect(result4).toEqual({
user: "Alice",
settings: {
theme: "dark",
nested: {
value: "found",
},
},
});
// test with missing paths (should return original match)
const obj5 = { value: "@missing.path" };
const variables5 = { existing: "value" };
const result5 = utils.recursivelyReplacePlaceholders(obj5, /^@([a-z\.]+)$/, variables5);
expect(result5).toEqual({ value: "@missing.path" });
// test with non-matching strings (should remain unchanged)
const obj6 = { value: "normal string", other: "not@matching" };
const variables6 = { some: "value" };
const result6 = utils.recursivelyReplacePlaceholders(obj6, /^@([a-z\.]+)$/, variables6);
expect(result6).toEqual({ value: "normal string", other: "not@matching" });
// test with primitive values (should handle gracefully)
expect(
utils.recursivelyReplacePlaceholders("@test.value", /^@([a-z\.]+)$/, {
test: { value: "replaced" },
}),
).toBe("replaced");
expect(utils.recursivelyReplacePlaceholders(123, /^@([a-z\.]+)$/, {})).toBe(123);
expect(utils.recursivelyReplacePlaceholders(null, /^@([a-z\.]+)$/, {})).toBe(null);
// test type preservation for full string matches
const variables7 = { test: { value: 123, flag: true, data: null, arr: [1, 2, 3] } };
const result7 = utils.recursivelyReplacePlaceholders(
{
number: "@test.value",
boolean: "@test.flag",
nullValue: "@test.data",
array: "@test.arr",
},
/^@([a-z\.]+)$/,
variables7,
null,
);
expect(result7).toEqual({
number: 123,
boolean: true,
nullValue: null,
array: [1, 2, 3],
});
// test partial string replacement (should convert to string)
const result8 = utils.recursivelyReplacePlaceholders(
{ message: "The value is @test.value!" },
/@([a-z\.]+)/g,
variables7,
);
expect(result8).toEqual({ message: "The value is 123!" });
// test with fallback parameter
const obj9 = { user: "@user.id", config: "@config.theme" };
const variables9 = {}; // empty context
const result9 = utils.recursivelyReplacePlaceholders(
obj9,
/^@([a-z\.]+)$/,
variables9,
null,
);
expect(result9).toEqual({ user: null, config: null });
// test with fallback for partial matches
const obj10 = { message: "Hello @user.name, welcome!" };
const variables10 = {}; // empty context
const result10 = utils.recursivelyReplacePlaceholders(
obj10,
/@([a-z\.]+)/g,
variables10,
"Guest",
);
expect(result10).toEqual({ message: "Hello Guest, welcome!" });
// test with different fallback types
const obj11 = {
stringFallback: "@missing.string",
numberFallback: "@missing.number",
booleanFallback: "@missing.boolean",
objectFallback: "@missing.object",
};
const variables11 = {};
const result11 = utils.recursivelyReplacePlaceholders(
obj11,
/^@([a-z\.]+)$/,
variables11,
"default",
);
expect(result11).toEqual({
stringFallback: "default",
numberFallback: "default",
booleanFallback: "default",
objectFallback: "default",
});
// test fallback with arrays
const obj12 = { items: ["@item1", "@item2", "static"] };
const variables12 = { item1: "found" }; // item2 is missing
const result12 = utils.recursivelyReplacePlaceholders(
obj12,
/^@([a-zA-Z0-9\.]+)$/,
variables12,
"missing",
);
expect(result12).toEqual({ items: ["found", "missing", "static"] });
// test fallback with nested objects
const obj13 = {
user: "@user.id",
settings: {
theme: "@theme.name",
nested: {
value: "@deep.value",
},
},
};
const variables13 = {}; // empty context
const result13 = utils.recursivelyReplacePlaceholders(
obj13,
/^@([a-z\.]+)$/,
variables13,
null,
);
expect(result13).toEqual({
user: null,
settings: {
theme: null,
nested: {
value: null,
},
},
});
});
});
describe("file", async () => {

View File

@@ -30,9 +30,9 @@ describe("some tests", async () => {
const query = await em.repository(users).findId(1);
expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? order by "users"."id" asc limit ? offset ?',
);
expect(query.parameters).toEqual([1, 1]);
expect(query.parameters).toEqual([1, 1, 0]);
expect(query.data).toBeUndefined();
});

View File

@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/auth/middlewares";
import { auth } from "../../src/modules/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper";

View File

@@ -65,7 +65,7 @@
"hono": "4.8.3",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.8.5",
"jsonv-ts": "0.9.1",
"kysely": "0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",

View File

@@ -2,7 +2,7 @@ import type { DB, PrimaryFieldType } from "bknd";
import * as AuthPermissions from "auth/auth-permissions";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
import { $console, secureRandomString, transformObject } from "bknd/utils";
import { $console, secureRandomString, transformObject, pickKeys } from "bknd/utils";
import type { Entity, EntityManager } from "data/entities";
import { em, entity, enumm, type FieldSchema } from "data/prototype";
import { Module } from "modules/Module";
@@ -61,7 +61,7 @@ export class AppAuth extends Module<AppAuthSchema> {
// register roles
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
return Role.create({ name, ...role });
return Role.create(name, role);
});
this.ctx.guard.setRoles(Object.values(roles));
this.ctx.guard.setConfig(this.config.guard ?? {});
@@ -113,6 +113,19 @@ export class AppAuth extends Module<AppAuthSchema> {
return authConfigSchema;
}
getGuardContextSchema() {
const userschema = this.getUsersEntity().toSchema() as any;
return {
type: "object",
properties: {
user: {
type: "object",
properties: pickKeys(userschema.properties, this.config.jwt.fields as any),
},
},
};
}
get authenticator(): Authenticator {
this.throwIfNotBuilt();
return this._authenticator!;
@@ -210,10 +223,12 @@ export class AppAuth extends Module<AppAuthSchema> {
}
const strategies = this.authenticator.getStrategies();
const roles = Object.fromEntries(this.ctx.guard.getRoles().map((r) => [r.name, r.toJSON()]));
return {
...this.config,
...this.authenticator.toJSON(secrets),
roles,
strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy),
...strategy.toJSON(secrets),

View File

@@ -60,7 +60,10 @@ export class AuthController extends Controller {
if (create) {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
permission(AuthPermissions.createUser, {}),
permission(DataPermissions.entityCreate, {
context: (c) => ({ entity: this.auth.config.entity_name }),
}),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
@@ -223,7 +226,6 @@ export class AuthController extends Controller {
const roles = Object.keys(this.auth.config.roles ?? {});
mcp.tool(
// @todo: needs permission
"auth_user_create",
{
description: "Create a new user",
@@ -238,14 +240,13 @@ export class AuthController extends Controller {
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c);
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
return c.json(await this.auth.createUser(params));
},
);
mcp.tool(
// @todo: needs permission
"auth_user_token",
{
description: "Get a user token",
@@ -255,7 +256,7 @@ export class AuthController extends Controller {
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c);
await c.context.ctx().helper.granted(c, AuthPermissions.createToken);
const user = await getUser(params);
return c.json({ user, token: await this.auth.authenticator.jwt(user) });
@@ -263,7 +264,6 @@ export class AuthController extends Controller {
);
mcp.tool(
// @todo: needs permission
"auth_user_password_change",
{
description: "Change a user's password",
@@ -274,7 +274,7 @@ export class AuthController extends Controller {
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c);
await c.context.ctx().helper.granted(c, AuthPermissions.changePassword);
const user = await getUser(params);
if (!(await this.auth.changePassword(user.id, params.password))) {
@@ -285,7 +285,6 @@ export class AuthController extends Controller {
);
mcp.tool(
// @todo: needs permission
"auth_user_password_test",
{
description: "Test a user's password",
@@ -295,7 +294,7 @@ export class AuthController extends Controller {
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c);
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword);
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
const controller = pw.getController(this.auth.authenticator);

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 updateUser = new Permission("auth.user.update");

View File

@@ -1,6 +1,7 @@
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { objectTransform, s } from "bknd/utils";
import { roleSchema } from "auth/authorize/Role";
import { objectTransform, omitKeys, pick, s } from "bknd/utils";
import { $object, $record } from "modules/mcp";
export const Strategies = {
@@ -40,11 +41,8 @@ export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth
const guardConfigSchema = s.object({
enabled: s.boolean({ default: false }).optional(),
});
export const guardRoleSchema = s.strictObject({
permissions: s.array(s.string()).optional(),
is_default: s.boolean().optional(),
implicit_allow: s.boolean().optional(),
});
export const guardRoleSchema = roleSchema;
export const authConfigSchema = $object(
"config_auth",

View File

@@ -6,10 +6,8 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt";
import { type CookieOptions, serializeSigned } from "hono/utils/cookie";
import type { ServerEnv } from "modules/Controller";
import { pick } from "lodash-es";
import { InvalidConditionsException } from "auth/errors";
import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
import { $object } from "modules/mcp";
import { s, parse, secret, runtimeSupports, truncate, $console, pickKeys } from "bknd/utils";
import type { AuthStrategy } from "./strategies/Strategy";
type Input = any; // workaround
@@ -230,7 +228,7 @@ export class Authenticator<
// @todo: add jwt tests
async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
const user = pick(_user, this.config.jwt.fields);
const user = pickKeys(_user, this.config.jwt.fields as any);
const payload: JWTPayload = {
...user,
@@ -256,7 +254,7 @@ export class Authenticator<
}
async safeAuthResponse(_user: User): Promise<AuthResponse> {
const user = pick(_user, this.config.jwt.fields) as SafeUser;
const user = pickKeys(_user, this.config.jwt.fields as any) as SafeUser;
return {
user,
token: await this.jwt(user),

View File

@@ -1,9 +1,11 @@
import { Exception } from "core/errors";
import { $console, objectTransform } from "bknd/utils";
import { Permission } from "core/security/Permission";
import { $console, mergeObject, type s } from "bknd/utils";
import type { Permission, PermissionContext } from "auth/authorize/Permission";
import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller";
import { Role } from "./Role";
import type { Role } from "./Role";
import { HttpStatus } from "bknd/utils";
import type { Policy, PolicySchema } from "./Policy";
export type GuardUserContext = {
role?: string | null;
@@ -12,41 +14,43 @@ export type GuardUserContext = {
export type GuardConfig = {
enabled?: boolean;
context?: object;
};
export type GuardContext = Context<ServerEnv> | GuardUserContext;
export class Guard {
permissions: Permission[];
roles?: Role[];
config?: GuardConfig;
export class GuardPermissionsException extends Exception {
override name = "PermissionsException";
override code = HttpStatus.FORBIDDEN;
constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) {
constructor(
public permission: Permission,
public policy?: Policy,
public description?: string,
) {
super(`Permission "${permission.name}" not granted`);
}
override toJSON(): any {
return {
...super.toJSON(),
description: this.description,
permission: this.permission.name,
policy: this.policy?.toJSON(),
};
}
}
export class Guard {
constructor(
public permissions: Permission<any, any, any, any>[] = [],
public roles: Role[] = [],
public config?: GuardConfig,
) {
this.permissions = permissions;
this.roles = roles;
this.config = config;
}
static create(
permissionNames: string[],
roles?: Record<
string,
{
permissions?: string[];
is_default?: boolean;
implicit_allow?: boolean;
}
>,
config?: GuardConfig,
) {
const _roles = roles
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow);
})
: {};
const _permissions = permissionNames.map((name) => new Permission(name));
return new Guard(_permissions, Object.values(_roles), config);
}
getPermissionNames(): string[] {
return this.permissions.map((permission) => permission.name);
}
@@ -73,7 +77,7 @@ export class Guard {
return this;
}
registerPermission(permission: Permission) {
registerPermission(permission: Permission<any, any, any, any>) {
if (this.permissions.find((p) => p.name === permission.name)) {
throw new Error(`Permission ${permission.name} already exists`);
}
@@ -82,9 +86,13 @@ export class Guard {
return this;
}
registerPermissions(permissions: Record<string, Permission>);
registerPermissions(permissions: Permission[]);
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
registerPermissions(permissions: Record<string, Permission<any, any, any, any>>);
registerPermissions(permissions: Permission<any, any, any, any>[]);
registerPermissions(
permissions:
| Permission<any, any, any, any>[]
| Record<string, Permission<any, any, any, any>>,
) {
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
for (const permission of p) {
@@ -117,56 +125,197 @@ export class Guard {
return this.config?.enabled === true;
}
hasPermission(permission: Permission, user?: GuardUserContext): boolean;
hasPermission(name: string, user?: GuardUserContext): boolean;
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
if (!this.isEnabled()) {
return true;
}
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
$console.debug("guard: checking permission", {
name,
user: { id: user?.id, role: user?.role },
});
const exists = this.permissionExists(name);
if (!exists) {
throw new Error(`Permission ${name} does not exist`);
}
const role = this.getUserRole(user);
if (!role) {
$console.debug("guard: user has no role, denying");
return false;
} else if (role.implicit_allow === true) {
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return true;
}
const rolePermission = role.permissions.find(
(rolePermission) => rolePermission.permission.name === name,
);
$console.debug("guard: rolePermission, allowing?", {
permission: name,
role: role.name,
allowing: !!rolePermission,
});
return !!rolePermission;
}
granted(permission: Permission | string, c?: GuardContext): boolean {
private collect(permission: Permission, c: GuardContext | undefined, context: any) {
const user = c && "get" in c ? c.get("auth")?.user : c;
return this.hasPermission(permission as any, user);
const ctx = {
...((context ?? {}) as any),
...this.config?.context,
user,
};
const exists = this.permissionExists(permission.name);
const role = this.getUserRole(user);
const rolePermission = role?.permissions.find(
(rolePermission) => rolePermission.permission.name === permission.name,
);
return {
ctx,
user,
exists,
role,
rolePermission,
};
}
throwUnlessGranted(permission: Permission | string, c: GuardContext) {
if (!this.granted(permission, c)) {
throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403,
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: _ctx, exists, role, rolePermission } = this.collect(permission, c, context);
// validate context
let ctx = Object.assign({}, _ctx);
if (permission.context) {
ctx = permission.parseContext(ctx);
}
$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) {
throw new GuardPermissionsException(permission, undefined, "User has no role");
}
if (!rolePermission) {
if (role.implicit_allow === true) {
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return;
}
throw new GuardPermissionsException(
permission,
undefined,
`Role "${role.name}" does not have required permission`,
);
}
if (rolePermission?.policies.length > 0) {
$console.debug("guard: rolePermission has policies, checking");
// set the default effect of the role permission
let allowed = rolePermission.effect === "allow";
for (const policy of rolePermission.policies) {
$console.debug("guard: checking policy", { policy: policy.toJSON(), ctx });
// skip filter policies
if (policy.content.effect === "filter") continue;
// if condition is met, check the effect
const meets = policy.meetsCondition(ctx);
if (meets) {
$console.debug("guard: policy meets condition");
// if deny, then break early
if (policy.content.effect === "deny") {
$console.debug("guard: policy is deny, setting allowed to false");
allowed = false;
break;
// if allow, set allow but continue checking
} else if (policy.content.effect === "allow") {
allowed = true;
}
} else {
$console.debug("guard: policy does not meet condition");
}
}
if (!allowed) {
throw new GuardPermissionsException(permission, undefined, "Policy condition unmet");
}
}
$console.debug("guard allowing", {
permission: permission.name,
role: role.name,
});
}
filters<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
);
filters<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext);
filters<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
) {
if (!permission.isFilterable()) {
throw new GuardPermissionsException(permission, undefined, "Permission is not filterable");
}
const {
ctx: _ctx,
exists,
role,
user,
rolePermission,
} = this.collect(permission, c, context);
// validate context
let ctx = Object.assign(
{
user,
},
_ctx,
);
if (permission.context) {
ctx = permission.parseContext(ctx, {
coerceDropUnknown: false,
});
}
const filters: PolicySchema["filter"][] = [];
const policies: Policy[] = [];
if (exists && role && rolePermission && rolePermission.policies.length > 0) {
for (const policy of rolePermission.policies) {
if (policy.content.effect === "filter") {
const meets = policy.meetsCondition(ctx);
if (meets) {
policies.push(policy);
filters.push(policy.getReplacedFilter(ctx));
}
}
}
}
const filter = filters.length > 0 ? mergeObject({}, ...filters) : undefined;
return {
filters,
filter,
policies,
merge: (givenFilter: object | undefined) => {
return mergeObject(givenFilter ?? {}, filter ?? {});
},
matches: (subject: object | object[], opts?: { throwOnError?: boolean }) => {
const subjects = Array.isArray(subject) ? subject : [subject];
if (policies.length > 0) {
for (const policy of policies) {
for (const subject of subjects) {
if (!policy.meetsFilter(subject, ctx)) {
if (opts?.throwOnError) {
throw new GuardPermissionsException(
permission,
policy,
"Policy filter not met",
);
}
return false;
}
}
}
}
return true;
},
};
}
}

View File

@@ -0,0 +1,77 @@
import { s, type ParseOptions, parse, InvalidSchemaError, HttpStatus } from "bknd/utils";
export const permissionOptionsSchema = s
.strictObject({
description: s.string(),
filterable: s.boolean(),
})
.partial();
export type TPermission = {
name: string;
description?: string;
filterable?: boolean;
context?: any;
};
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) {
// @todo: allow additional properties
if (!this.context) return ctx;
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,52 @@
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>,
// @todo: potentially remove this, and invert from rolePermission.effect
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>, fallback?: any) {
return vars
? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars, fallback)
: context;
}
getReplacedFilter(context: object, fallback?: any) {
if (!this.content.filter) return context;
return this.replace(this.content.filter!, context, fallback);
}
meetsCondition(context: object, vars?: Record<string, any>) {
if (!this.content.condition) return true;
return query.validate(this.replace(this.content.condition!, vars), context);
}
meetsFilter(subject: object, vars?: Record<string, any>) {
if (!this.content.filter) return true;
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,39 @@
import { Permission } from "core/security/Permission";
import { s } from "bknd/utils";
import { Permission } from "./Permission";
import { Policy, policySchema } from "./Policy";
// default effect is allow for backward compatibility
const defaultEffect = "allow";
export const rolePermissionSchema = s.strictObject({
permission: s.string(),
effect: s.string({ enum: ["allow", "deny"], default: defaultEffect }).optional(),
policies: s.array(policySchema).optional(),
});
export type RolePermissionSchema = s.Static<typeof rolePermissionSchema>;
export const roleSchema = s.strictObject({
// @todo: remove anyOf, add migration
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 {
constructor(
public permission: Permission,
public config?: any,
public permission: Permission<any, any, any, any>,
public policies: Policy[] = [],
public effect: "allow" | "deny" = defaultEffect,
) {}
toJSON() {
return {
permission: this.permission.name,
policies: this.policies.map((p) => p.toJSON()),
effect: this.effect,
};
}
}
export class Role {
@@ -15,31 +44,23 @@ export class Role {
public implicit_allow: boolean = false,
) {}
static createWithPermissionNames(
name: string,
permissionNames: string[],
is_default: boolean = false,
implicit_allow: boolean = false,
) {
return new Role(
name,
permissionNames.map((name) => new RolePermission(new Permission(name))),
is_default,
implicit_allow,
);
static create(name: string, config: RoleSchema) {
const permissions =
config.permissions?.map((p: string | RolePermissionSchema) => {
if (typeof p === "string") {
return new RolePermission(new Permission(p), []);
}
const policies = p.policies?.map((policy) => new Policy(policy));
return new RolePermission(new Permission(p.permission), policies, p.effect);
}) ?? [];
return new Role(name, permissions, config.is_default, config.implicit_allow);
}
static create(config: {
name: string;
permissions?: string[];
is_default?: boolean;
implicit_allow?: boolean;
}) {
return new Role(
config.name,
config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
config.is_default,
config.implicit_allow,
);
toJSON() {
return {
permissions: this.permissions.map((p) => p.toJSON()),
is_default: this.is_default,
implicit_allow: this.implicit_allow,
};
}
}

View File

@@ -1,4 +1,3 @@
import type { Permission } from "core/security/Permission";
import { $console, patternMatch } from "bknd/utils";
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
@@ -49,7 +48,7 @@ export const auth = (options?: {
// make sure to only register once
if (authCtx.registered) {
skipped = true;
$console.warn(`auth middleware already registered for ${getPath(c)}`);
$console.debug(`auth middleware already registered for ${getPath(c)}`);
} else {
authCtx.registered = true;
@@ -67,48 +66,3 @@ export const auth = (options?: {
authCtx.resolved = false;
authCtx.user = undefined;
});
export const permission = (
permission: Permission | Permission[],
options?: {
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
},
) =>
// @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 permissions = Array.isArray(permission) ? permission : [permission];
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (permissions.every((p) => guard.granted(p, c))) {
returned = await options?.onGranted?.(c);
} else {
returned = await options?.onDenied?.(c);
}
if (returned instanceof Response) {
return returned;
}
} else {
permissions.some((p) => guard.throwUnlessGranted(p, c));
}
}
await next();
});

View File

@@ -0,0 +1,94 @@
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";
import { GuardPermissionsException } from "auth/authorize/Guard";
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), GuardPermissionsException)) {
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,4 +1,5 @@
import type { PrimaryFieldType } from "core/config";
import { getPath, invariant } from "bknd/utils";
export type Primitive = PrimaryFieldType | string | number | boolean;
export function isPrimitive(value: any): value is Primitive {
@@ -67,8 +68,9 @@ function _convert<Exps extends Expressions>(
expressions: Exps,
path: string[] = [],
): FilterQuery<Exps> {
invariant(typeof $query === "object", "$query must be an object");
const ExpressionConditionKeys = expressions.map((e) => e.key);
const keys = Object.keys($query);
const keys = Object.keys($query ?? {});
const operands = [OperandOr] as const;
const newQuery: FilterQuery<Exps> = {};
@@ -157,7 +159,7 @@ function _build<Exps extends Expressions>(
// check $and
for (const [key, value] of Object.entries($and)) {
for (const [$op, $v] of Object.entries(value)) {
const objValue = options.value_is_kv ? key : options.object[key];
const objValue = options.value_is_kv ? key : getPath(options.object, key);
result.$and.push(__validate($op, $v, objValue, [key]));
result.keys.add(key);
}
@@ -165,7 +167,7 @@ function _build<Exps extends Expressions>(
// check $or
for (const [key, value] of Object.entries($or ?? {})) {
const objValue = options.value_is_kv ? key : options.object[key];
const objValue = options.value_is_kv ? key : getPath(options.object, key);
for (const [$op, $v] of Object.entries(value)) {
result.$or.push(__validate($op, $v, objValue, [key]));

View File

@@ -1,11 +0,0 @@
export class Permission<Name extends string = string> {
constructor(public name: Name) {
this.name = name;
}
toJSON() {
return {
name: this.name,
};
}
}

View File

@@ -372,7 +372,7 @@ export function isEqual(value1: any, value2: any): boolean {
export function getPath(
object: object,
_path: string | (string | number)[],
defaultValue = undefined,
defaultValue: any = undefined,
): any {
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
@@ -512,3 +512,43 @@ export function convertNumberedObjectToArray(obj: object): any[] | object {
}
return obj;
}
export function recursivelyReplacePlaceholders(
obj: any,
pattern: RegExp,
variables: Record<string, any>,
fallback?: any,
) {
if (typeof obj === "string") {
// check if the entire string matches the pattern
const match = obj.match(pattern);
if (match && match[0] === obj && match[1]) {
// full string match - replace with the actual value (preserving type)
const key = match[1];
const value = getPath(variables, key, null);
return value !== null ? value : fallback !== undefined ? fallback : obj;
}
// partial match - use string replacement
if (pattern.test(obj)) {
return obj.replace(pattern, (match, key) => {
const value = getPath(variables, key, null);
// convert to string for partial replacements
return value !== null
? String(value)
: fallback !== undefined
? String(fallback)
: match;
});
}
}
if (Array.isArray(obj)) {
return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables, fallback));
}
if (obj && typeof obj === "object") {
return Object.entries(obj).reduce((acc, [key, value]) => {
acc[key] = recursivelyReplacePlaceholders(value, pattern, variables, fallback);
return acc;
}, {} as object);
}
return obj;
}

View File

@@ -61,3 +61,19 @@ export function invariant(condition: boolean | any, message: string) {
throw new Error(message);
}
}
export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
try {
fn();
return false;
} catch (e) {
if (instance) {
if (e instanceof instance) {
return true;
}
// if instance given but not what expected, throw
throw 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";
export { validator as jsc, type Options } from "jsonv-ts/hono";
@@ -58,7 +60,10 @@ export const stringIdentifier = s.string({
maxLength: 150,
});
export class InvalidSchemaError extends Error {
export class InvalidSchemaError extends Exception {
override name = "InvalidSchemaError";
override code = HttpStatus.UNPROCESSABLE_ENTITY;
constructor(
public schema: s.Schema,
public value: unknown,

View File

@@ -9,6 +9,7 @@ import {
pickKeys,
mcpTool,
convertNumberedObjectToArray,
mergeObject,
} from "bknd/utils";
import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema";
@@ -43,7 +44,7 @@ export class DataController extends Controller {
override getController() {
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi, {}));
const entitiesEnum = this.getEntitiesEnum(this.em);
// info
@@ -59,7 +60,7 @@ export class DataController extends Controller {
// sync endpoint
hono.get(
"/sync",
permission(DataPermissions.databaseSync),
permission(DataPermissions.databaseSync, {}),
mcpTool("data_sync", {
// @todo: should be removed if readonly
annotations: {
@@ -96,7 +97,9 @@ export class DataController extends Controller {
// read entity schema
hono.get(
"/schema.json",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Retrieve data schema",
tags: ["data"],
@@ -122,7 +125,9 @@ export class DataController extends Controller {
// read schema
hono.get(
"/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Retrieve entity schema",
tags: ["data"],
@@ -176,7 +181,9 @@ export class DataController extends Controller {
*/
hono.get(
"/info/:entity",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Retrieve entity info",
tags: ["data"],
@@ -228,7 +235,9 @@ export class DataController extends Controller {
// fn: count
hono.post(
"/:entity/fn/count",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Count entities",
tags: ["data"],
@@ -251,7 +260,9 @@ export class DataController extends Controller {
// fn: exists
hono.post(
"/:entity/fn/exists",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Check if entity exists",
tags: ["data"],
@@ -300,16 +311,26 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("query", repoQuery, { skipOpenAPI: true }),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
entity,
});
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findMany(options);
const result = await this.em.repository(entity).findMany({
...options,
where: merge(options.where),
});
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -323,7 +344,9 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_read_one", {
inputSchema: {
param: s.object({ entity: entitiesEnum, id: idType }),
@@ -341,11 +364,19 @@ export class DataController extends Controller {
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
if (!this.entityExists(entity) || !id) {
return this.notFound(c);
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(id, options);
const { merge } = this.ctx.guard.filters(
DataPermissions.entityRead,
c,
c.req.valid("param"),
);
const id_name = this.em.entity(entity).getPrimaryField().name;
const result = await this.em
.repository(entity)
.findOne(merge({ [id_name]: id }), options);
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -359,7 +390,9 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ ...c.req.param() }) as any,
}),
jsc(
"param",
s.object({
@@ -376,9 +409,20 @@ export class DataController extends Controller {
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em
const { entity: newEntity } = this.em
.repository(entity)
.findManyByReference(id, reference, options);
.getEntityByReference(reference);
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
entity: newEntity.name,
id,
reference,
});
const result = await this.em.repository(entity).findManyByReference(id, reference, {
...options,
where: merge(options.where),
});
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -405,7 +449,9 @@ export class DataController extends Controller {
},
tags: ["data"],
}),
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
mcpTool("data_entity_read_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
@@ -420,7 +466,13 @@ export class DataController extends Controller {
return this.notFound(c);
}
const options = c.req.valid("json") as RepoQuery;
const result = await this.em.repository(entity).findMany(options);
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
entity,
});
const result = await this.em.repository(entity).findMany({
...options,
where: merge(options.where),
});
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -436,7 +488,9 @@ export class DataController extends Controller {
summary: "Insert one or many",
tags: ["data"],
}),
permission(DataPermissions.entityCreate),
permission(DataPermissions.entityCreate, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_insert"),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
@@ -453,6 +507,12 @@ export class DataController extends Controller {
// to transform all validation targets into a single object
const body = convertNumberedObjectToArray(_body);
this.ctx.guard
.filters(DataPermissions.entityCreate, c, {
entity,
})
.matches(body, { throwOnError: true });
if (Array.isArray(body)) {
const result = await this.em.mutator(entity).insertMany(body);
return c.json(result, 201);
@@ -470,7 +530,9 @@ export class DataController extends Controller {
summary: "Update many",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
permission(DataPermissions.entityUpdate, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_update_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
@@ -497,7 +559,10 @@ export class DataController extends Controller {
update: EntityData;
where: RepoQuery["where"];
};
const result = await this.em.mutator(entity).updateWhere(update, where);
const { merge } = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
entity,
});
const result = await this.em.mutator(entity).updateWhere(update, merge(where));
return c.json(result);
},
@@ -510,7 +575,9 @@ export class DataController extends Controller {
summary: "Update one",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
permission(DataPermissions.entityUpdate, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_update_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})),
@@ -520,6 +587,17 @@ export class DataController extends Controller {
return this.notFound(c);
}
const body = (await c.req.json()) as EntityData;
const fns = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
entity,
id,
});
// if it has filters attached, fetch entry and make the check
if (fns.filters.length > 0) {
const { data } = await this.em.repository(entity).findId(id);
fns.matches(data, { throwOnError: true });
}
const result = await this.em.mutator(entity).updateOne(id, body);
return c.json(result);
@@ -533,7 +611,9 @@ export class DataController extends Controller {
summary: "Delete one",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
permission(DataPermissions.entityDelete, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_delete_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => {
@@ -541,6 +621,18 @@ export class DataController extends Controller {
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const fns = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
entity,
id,
});
// if it has filters attached, fetch entry and make the check
if (fns.filters.length > 0) {
const { data } = await this.em.repository(entity).findId(id);
fns.matches(data, { throwOnError: true });
}
const result = await this.em.mutator(entity).deleteOne(id);
return c.json(result);
@@ -554,7 +646,9 @@ export class DataController extends Controller {
summary: "Delete many",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
permission(DataPermissions.entityDelete, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_delete_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
@@ -569,7 +663,10 @@ export class DataController extends Controller {
return this.notFound(c);
}
const where = (await c.req.json()) as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where);
const { merge } = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
entity,
});
const result = await this.em.mutator(entity).deleteWhere(merge(where));
return c.json(result);
},

View File

@@ -1,4 +1,4 @@
import type { DB as DefaultDB, PrimaryFieldType } from "bknd";
import type { DB as DefaultDB, EntityRelation, PrimaryFieldType } from "bknd";
import { $console } from "bknd/utils";
import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely";
@@ -280,16 +280,11 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>,
): Promise<RepositoryResult<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery(
{
..._options,
where: { [this.entity.getPrimaryField().name]: id },
limit: 1,
},
["offset", "sort"],
);
if (typeof id === "undefined" || id === null) {
throw new InvalidSearchParamsException("id is required");
}
return this.single(qb, options) as any;
return this.findOne({ [this.entity.getPrimaryField().name]: id }, _options);
}
async findOne(
@@ -315,23 +310,27 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return res as any;
}
getEntityByReference(reference: string): { entity: Entity; relation: EntityRelation } {
const listable_relations = this.em.relations.listableRelationsOf(this.entity);
const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
if (!relation) {
throw new Error(
`Relation "${reference}" not found or not listable on entity "${this.entity.name}"`,
);
}
return {
entity: relation.other(this.entity).entity,
relation,
};
}
// @todo: add unit tests, specially for many to many
async findManyByReference(
id: PrimaryFieldType,
reference: string,
_options?: Partial<Omit<RepoQuery, "limit" | "offset">>,
): Promise<RepositoryResult<EntityData>> {
const entity = this.entity;
const listable_relations = this.em.relations.listableRelationsOf(entity);
const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
if (!relation) {
throw new Error(
`Relation "${reference}" not found or not listable on entity "${entity.name}"`,
);
}
const newEntity = relation.other(entity).entity;
const { entity: newEntity, relation } = this.getEntityByReference(reference);
const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference);
if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) {
throw new Error(

View File

@@ -1,9 +1,51 @@
import { Permission } from "core/security/Permission";
import { Permission } from "auth/authorize/Permission";
import { s } from "bknd/utils";
export const entityRead = new Permission("data.entity.read");
export const entityCreate = new Permission("data.entity.create");
export const entityUpdate = new Permission("data.entity.update");
export const entityDelete = new Permission("data.entity.delete");
export const entityRead = new Permission(
"data.entity.read",
{
filterable: true,
},
s.object({
entity: s.string(),
id: s.anyOf([s.number(), s.string()]).optional(),
}),
);
/**
* Filter filters content given
*/
export const entityCreate = new Permission(
"data.entity.create",
{
filterable: true,
},
s.object({
entity: s.string(),
}),
);
/**
* Filter filters where clause
*/
export const entityUpdate = new Permission(
"data.entity.update",
{
filterable: true,
},
s.object({
entity: s.string(),
id: s.anyOf([s.number(), s.string()]).optional(),
}),
);
export const entityDelete = new Permission(
"data.entity.delete",
{
filterable: true,
},
s.object({
entity: s.string(),
id: s.anyOf([s.number(), s.string()]).optional(),
}),
);
export const databaseSync = new Permission("data.database.sync");
export const rawQuery = new Permission("data.raw.query");
export const rawMutate = new Permission("data.raw.mutate");

View File

@@ -45,7 +45,7 @@ export type { MaybePromise, Merge } from "core/types";
export { Exception, BkndError } from "core/errors";
export { isDebug, env } from "core/env";
export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config";
export { Permission } from "core/security/Permission";
export { Permission } from "auth/authorize/Permission";
export { getFlashMessage } from "core/server/flash";
export * from "core/drivers";
export { Event, InvalidEventReturn } from "core/events/Event";

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { ConfigUpdateResponse } from "modules/server/SystemController";
import { ModuleApi } from "./ModuleApi";
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
import type { TPermission } from "auth/authorize/Permission";
export type ApiSchemaResponse = {
version: number;
@@ -54,4 +55,8 @@ export class SystemApi extends ModuleApi<any> {
removeConfig<Module extends ModuleKey>(module: Module, path: string) {
return this.delete<ConfigUpdateResponse>(["config", "remove", module, path]);
}
permissions() {
return this.get<{ permissions: TPermission[]; context: object }>("permissions");
}
}

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,10 +1,35 @@
import { Permission } from "core/security/Permission";
import { Permission } from "auth/authorize/Permission";
import { s } from "bknd/utils";
export const accessAdmin = new Permission("system.access.admin");
export const accessApi = new Permission("system.access.api");
export const configRead = new Permission("system.config.read");
export const configReadSecrets = new Permission("system.config.read.secrets");
export const configWrite = new Permission("system.config.write");
export const schemaRead = new Permission("system.schema.read");
export const configRead = new Permission(
"system.config.read",
{},
s.object({
module: s.string().optional(),
}),
);
export const configReadSecrets = new Permission(
"system.config.read.secrets",
{},
s.object({
module: s.string().optional(),
}),
);
export const configWrite = new Permission(
"system.config.write",
{},
s.object({
module: s.string().optional(),
}),
);
export const schemaRead = new Permission(
"system.schema.read",
{},
s.object({
module: s.string().optional(),
}),
);
export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp");

View File

@@ -114,8 +114,9 @@ export class AdminController extends Controller {
}),
permission(SystemPermissions.schemaRead, {
onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning");
addFlashMessage(c, "You are not allowed to read the schema", "warning");
},
context: (c) => ({}),
}),
async (c) => {
const obj: AdminBkndWindowContext = {
@@ -139,17 +140,19 @@ export class AdminController extends Controller {
}
if (auth_enabled) {
const options = {
onGranted: async (c) => {
// @todo: add strict test to permissions middleware?
if (c.get("auth")?.user) {
$console.log("redirecting to success");
return c.redirect(authRoutes.success);
}
},
context: (c) => ({}),
};
const redirectRouteParams = [
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
// @ts-ignore
onGranted: async (c) => {
// @todo: add strict test to permissions middleware?
if (c.get("auth")?.user) {
$console.log("redirecting to success");
return c.redirect(authRoutes.success);
}
},
}),
permission(SystemPermissions.accessAdmin, options as any),
permission(SystemPermissions.schemaRead, options),
async (c) => {
return c.html(c.get("html")!);
},

View File

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

View File

@@ -17,6 +17,7 @@ import {
mcp as mcpMiddleware,
isNode,
type McpServer,
threw,
} from "bknd/utils";
import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller";
@@ -32,6 +33,7 @@ import { getVersion } from "core/env";
import type { Module } from "modules/Module";
import { getSystemMcp } from "modules/mcp/system-mcp";
import type { DbModuleManager } from "modules/db/DbModuleManager";
import type { TPermission } from "auth/authorize/Permission";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
@@ -46,7 +48,8 @@ export type SchemaResponse = {
schema: ModuleSchemas;
readonly: boolean;
config: ModuleConfigs;
permissions: string[];
//permissions: string[];
permissions: TPermission[];
};
export class SystemController extends Controller {
@@ -67,10 +70,13 @@ export class SystemController extends Controller {
if (!config.mcp.enabled) {
return;
}
const { permission } = this.middlewares;
this.registerMcp();
app.server.use(
app.server.all(
config.mcp.path,
permission(SystemPermissions.mcp, {}),
mcpMiddleware({
setup: async () => {
if (!this._mcpServer) {
@@ -108,7 +114,6 @@ export class SystemController extends Controller {
explainEndpoint: true,
},
endpoint: {
path: config.mcp.path as any,
// @ts-ignore
_init: isNode() ? { duplex: "half" } : {},
},
@@ -119,7 +124,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares;
// don't add auth again, it's already added in getController
const hono = this.create().use(permission(SystemPermissions.configRead));
const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
if (!this.app.isReadOnly()) {
const manager = this.app.modules as DbModuleManager;
@@ -130,7 +135,11 @@ export class SystemController extends Controller {
summary: "Get the raw config",
tags: ["system"],
}),
permission([SystemPermissions.configReadSecrets]),
permission(SystemPermissions.configReadSecrets, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch().then((r) => r?.configs));
@@ -165,7 +174,11 @@ export class SystemController extends Controller {
hono.post(
"/set/:module",
permission(SystemPermissions.configWrite),
permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
async (c) => {
const module = c.req.param("module") as any;
@@ -194,32 +207,44 @@ export class SystemController extends Controller {
},
);
hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
// @todo: require auth (admin)
const module = c.req.param("module") as any;
const value = await c.req.json();
const path = c.req.param("path") as string;
hono.post(
"/add/:module/:path",
permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => {
// @todo: require auth (admin)
const module = c.req.param("module") as any;
const value = await c.req.json();
const path = c.req.param("path") as string;
if (this.app.modules.get(module).schema().has(path)) {
return c.json(
{ success: false, path, error: "Path already exists" },
{ status: 400 },
);
}
if (this.app.modules.get(module).schema().has(path)) {
return c.json(
{ success: false, path, error: "Path already exists" },
{ status: 400 },
);
}
return await handleConfigUpdateResponse(c, async () => {
await manager.mutateConfigSafe(module).patch(path, value);
return {
success: true,
module,
config: this.app.module[module].config,
};
});
});
return await handleConfigUpdateResponse(c, async () => {
await manager.mutateConfigSafe(module).patch(path, value);
return {
success: true,
module,
config: this.app.module[module].config,
};
});
},
);
hono.patch(
"/patch/:module/:path",
permission(SystemPermissions.configWrite),
permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => {
// @todo: require auth (admin)
const module = c.req.param("module") as any;
@@ -239,7 +264,11 @@ export class SystemController extends Controller {
hono.put(
"/overwrite/:module/:path",
permission(SystemPermissions.configWrite),
permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => {
// @todo: require auth (admin)
const module = c.req.param("module") as any;
@@ -259,7 +288,11 @@ export class SystemController extends Controller {
hono.delete(
"/remove/:module/:path",
permission(SystemPermissions.configWrite),
permission(SystemPermissions.configWrite, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
async (c) => {
// @todo: require auth (admin)
const module = c.req.param("module") as any;
@@ -295,7 +328,11 @@ export class SystemController extends Controller {
const { secrets } = c.req.valid("query");
const { module } = c.req.valid("param");
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
if (secrets) {
this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
module,
});
}
const config = this.app.toJSON(secrets);
@@ -326,7 +363,11 @@ export class SystemController extends Controller {
summary: "Get the schema for a module",
tags: ["system"],
}),
permission(SystemPermissions.schemaRead),
permission(SystemPermissions.schemaRead, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
jsc(
"query",
s
@@ -340,10 +381,22 @@ export class SystemController extends Controller {
async (c) => {
const module = c.req.param("module") as ModuleKey | undefined;
const { config, secrets, fresh } = c.req.valid("query");
const readonly = this.app.isReadOnly();
const readonly =
// either if app is read only in general
this.app.isReadOnly() ||
// or if user is not allowed to modify the config
threw(() => this.ctx.guard.granted(SystemPermissions.configWrite, c, { module }));
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
if (config) {
this.ctx.guard.granted(SystemPermissions.configRead, c, {
module,
});
}
if (secrets) {
this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
module,
});
}
const { version, ...schema } = this.app.getSchema();
@@ -368,11 +421,23 @@ export class SystemController extends Controller {
readonly,
schema,
config: config ? this.app.toJSON(secrets) : undefined,
permissions: this.app.modules.ctx().guard.getPermissionNames(),
permissions: this.app.modules.ctx().guard.getPermissions(),
});
},
);
hono.get(
"/permissions",
describeRoute({
summary: "Get the permissions",
tags: ["system"],
}),
(c) => {
const permissions = this.app.modules.ctx().guard.getPermissions();
return c.json({ permissions, context: this.app.module.auth.getGuardContextSchema() });
},
);
hono.post(
"/build",
describeRoute({
@@ -383,7 +448,7 @@ export class SystemController extends Controller {
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
async (c) => {
const options = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
this.ctx.guard.granted(SystemPermissions.build, c);
await this.app.build(options);
return c.json({
@@ -455,7 +520,7 @@ export class SystemController extends Controller {
const { version, ...appConfig } = this.app.toJSON();
mcp.resource("system_config", "bknd://system/config", async (c) => {
await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c);
await c.context.ctx().helper.granted(c, SystemPermissions.configRead, {});
return c.json(this.app.toJSON(), {
title: "System Config",
@@ -465,7 +530,9 @@ export class SystemController extends Controller {
"system_config_module",
"bknd://system/config/{module}",
async (c, { module }) => {
await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c);
await this.ctx.helper.granted(c, SystemPermissions.configRead, {
module,
});
const m = this.app.modules.get(module as any) as Module;
return c.json(m.toJSON(), {
@@ -477,7 +544,7 @@ export class SystemController extends Controller {
},
)
.resource("system_schema", "bknd://system/schema", async (c) => {
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {});
return c.json(this.app.getSchema(), {
title: "System Schema",
@@ -487,7 +554,9 @@ export class SystemController extends Controller {
"system_schema_module",
"bknd://system/schema/{module}",
async (c, { module }) => {
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {
module,
});
const m = this.app.modules.get(module as any);
return c.json(m.getSchema().toJSON(), {

View File

@@ -15,13 +15,14 @@ import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message";
import { useNavigate } from "ui/lib/routes";
import type { BkndAdminProps } from "ui/Admin";
import type { TPermission } from "auth/authorize/Permission";
export type BkndContext = {
version: number;
readonly: boolean;
schema: ModuleSchemas;
config: ModuleConfigs;
permissions: string[];
permissions: TPermission[];
hasSecrets: boolean;
requireSecrets: () => Promise<void>;
actions: ReturnType<typeof getSchemaActions>;

View File

@@ -1,6 +1,6 @@
import type { Api } from "Api";
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
import useSWRInfinite from "swr/infinite";
import { useApi } from "ui/client";
import { useState } from "react";
@@ -89,3 +89,25 @@ export const useInvalidate = (options?: { exact?: boolean }) => {
return mutate((k) => typeof k === "string" && k.startsWith(key));
};
};
const mountOnceCache = new Map<string, any>();
/**
* Simple middleware to only load on first mount.
*/
export const mountOnce: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
if (typeof key === "string") {
if (mountOnceCache.has(key)) {
return useSWRNext(key, fetcher, {
...config,
revalidateOnMount: false,
});
}
const swr = useSWRNext(key, fetcher, config);
if (swr.data) {
mountOnceCache.set(key, true);
}
return swr;
}
return useSWRNext(key, fetcher, config);
};

View File

@@ -49,7 +49,7 @@ export function useBkndAuth() {
has_admin: Object.entries(config.auth.roles ?? {}).some(
([name, role]) =>
role.implicit_allow ||
minimum_permissions.every((p) => role.permissions?.includes(p)),
minimum_permissions.every((p) => role.permissions?.some((p) => p.permission === p)),
),
},
routes: {

View File

@@ -5,13 +5,15 @@ import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link";
const sizes = {
smaller: "px-1.5 py-1 rounded-md gap-1 !text-xs",
small: "px-2 py-1.5 rounded-md gap-1 text-sm",
default: "px-3 py-2.5 rounded-md gap-1.5",
large: "px-4 py-3 rounded-md gap-2.5 text-lg",
};
const iconSizes = {
small: 12,
smaller: 12,
small: 14,
default: 16,
large: 20,
};

View File

@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { useTheme } from "ui/client/use-theme";
import { cn } from "ui/lib/utils";
export type CodePreviewProps = {
code: string;
className?: string;
lang?: string;
theme?: string;
enabled?: boolean;
};
export const CodePreview = ({
code,
className,
lang = "typescript",
theme: _theme,
enabled = true,
}: CodePreviewProps) => {
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
const $theme = useTheme();
const theme = (_theme ?? $theme.theme === "dark") ? "github-dark" : "github-light";
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setHighlightedHtml(null);
async function highlightCode() {
try {
// Dynamically import Shiki from CDN
// @ts-expect-error - Dynamic CDN import
const { codeToHtml } = await import("https://esm.sh/shiki@3.13.0");
if (cancelled) return;
const html = await codeToHtml(code, {
lang,
theme,
structure: "inline",
});
if (cancelled) return;
setHighlightedHtml(html);
} catch (error) {
console.error("Failed to load Shiki:", error);
// Fallback to plain text if Shiki fails to load
if (!cancelled) {
setHighlightedHtml(code);
}
}
}
highlightCode();
return () => {
cancelled = true;
};
}, [code, enabled]);
if (!highlightedHtml) {
return <pre className={cn("select-text cursor-text", className)}>{code}</pre>;
}
return (
<pre
className={cn("select-text cursor-text", className)}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
);
};

View File

@@ -1,9 +1,38 @@
import { Suspense, lazy } from "react";
import { Suspense, lazy, useState } from "react";
import { twMerge } from "tailwind-merge";
import type { CodeEditorProps } from "./CodeEditor";
import { useDebouncedCallback } from "@mantine/hooks";
const CodeEditor = lazy(() => import("./CodeEditor"));
export function JsonEditor({ editable, className, ...props }: CodeEditorProps) {
export type JsonEditorProps = Omit<CodeEditorProps, "value" | "onChange"> & {
value?: object;
onChange?: (value: object) => void;
emptyAs?: "null" | "undefined";
};
export function JsonEditor({
editable,
className,
value,
onChange,
onBlur,
emptyAs = "undefined",
...props
}: JsonEditorProps) {
const [editorValue, setEditorValue] = useState<string | null | undefined>(
JSON.stringify(value, null, 2),
);
const handleChange = useDebouncedCallback((given: string) => {
const value = given === "" ? (emptyAs === "null" ? null : undefined) : given;
try {
setEditorValue(value);
onChange?.(value ? JSON.parse(value) : value);
} catch (e) {}
}, 500);
const handleBlur = (e) => {
setEditorValue(JSON.stringify(value, null, 2));
onBlur?.(e);
};
return (
<Suspense fallback={null}>
<CodeEditor
@@ -14,6 +43,9 @@ export function JsonEditor({ editable, className, ...props }: CodeEditorProps) {
)}
editable={editable}
_extensions={{ json: true }}
value={editorValue ?? undefined}
onChange={handleChange}
onBlur={handleBlur}
{...props}
/>
</Suspense>

View File

@@ -28,8 +28,9 @@ export const Group = <E extends ElementType = "div">({
return (
<Tag
{...props}
data-role="group"
className={twMerge(
"flex flex-col gap-1.5",
"flex flex-col gap-1.5 w-full",
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
as === "fieldset" && error && "border-red-500",
error && "text-red-500",

View File

@@ -5,19 +5,29 @@ import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { useEvent } from "ui/hooks/use-event";
import { FieldComponent } from "./Field";
import { FieldWrapper } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form";
import { Field, FieldComponent, type FieldProps } from "./Field";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { FormContextOverride, useDerivedFieldContext, useFormValue } from "./Form";
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
export const ArrayField = ({ path = "" }: { path?: string }) => {
export type ArrayFieldProps = {
path?: string;
labelAdd?: string;
wrapperProps?: Omit<FieldwrapperProps, "name" | "children">;
};
export const ArrayField = ({
path = "",
labelAdd = "Add",
wrapperProps = { wrapper: "fieldset" },
}: ArrayFieldProps) => {
const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path);
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
// if unique items with enum
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
return (
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
<FieldWrapper {...wrapperProps} name={path} schema={schema}>
<FieldComponent
required
name={path}
@@ -35,7 +45,7 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
}
return (
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
<FieldWrapper {...wrapperProps} name={path} schema={schema}>
<ArrayIterator name={path}>
{({ value }) =>
value?.map((v, index: number) => (
@@ -44,17 +54,21 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
}
</ArrayIterator>
<div className="flex flex-row">
<ArrayAdd path={path} schema={schema} />
<ArrayAdd path={path} schema={schema} label={labelAdd} />
</div>
</FieldWrapper>
);
};
const ArrayItem = memo(({ path, index, schema }: any) => {
const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => {
const {
value,
path: absolutePath,
...ctx
} = useDerivedFieldContext(path, (ctx) => {
return ctx.value?.[index];
});
const itemPath = suffixPath(path, index);
const itemPath = suffixPath(absolutePath, index);
let subschema = schema.items;
const itemsMultiSchema = getMultiSchema(schema.items);
if (itemsMultiSchema) {
@@ -62,10 +76,6 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
subschema = _subschema;
}
const handleUpdate = useEvent((pointer: string, value: any) => {
ctx.setValue(pointer, value);
});
const handleDelete = useEvent((pointer: string) => {
ctx.deleteValue(pointer);
});
@@ -76,21 +86,26 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
);
return (
<div key={itemPath} className="flex flex-row gap-2">
<FieldComponent
name={itemPath}
schema={subschema!}
value={value}
onChange={(e) => {
handleUpdate(itemPath, coerce(e.target.value, subschema!));
}}
className="w-full"
/>
{DeleteButton}
</div>
<FormContextOverride prefix={itemPath} schema={subschema!}>
<div className="flex flex-row gap-2 w-full">
{/* another wrap is required for primitive schemas */}
<AnotherField label={false} />
{DeleteButton}
</div>
</FormContextOverride>
);
}, isEqual);
const AnotherField = (props: Partial<FieldProps>) => {
const { value } = useFormValue("");
const inputProps = {
// @todo: check, potentially just provide value
value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined,
};
return <Field name={""} label={false} {...props} inputProps={inputProps} />;
};
const ArrayIterator = memo(
({ name, children }: any) => {
return children(useFormValue(name));
@@ -98,19 +113,25 @@ const ArrayIterator = memo(
(prev, next) => prev.value?.length === next.value?.length,
);
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
const ArrayAdd = ({
schema,
path: _path,
label = "Add",
}: { schema: JsonSchema; path: string; label?: string }) => {
const {
setValue,
value: { currentIndex },
path,
...ctx
} = useDerivedFieldContext(path, (ctx) => {
} = useDerivedFieldContext(_path, (ctx) => {
return { currentIndex: ctx.value?.length ?? 0 };
});
const itemsMultiSchema = getMultiSchema(schema.items);
const options = { addOptionalProps: true };
function handleAdd(template?: any) {
const newPath = suffixPath(path, currentIndex);
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items));
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items, options));
}
if (itemsMultiSchema) {
@@ -121,14 +142,14 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
}}
items={itemsMultiSchema.map((s, i) => ({
label: s!.title ?? `Option ${i + 1}`,
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!)),
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!, options)),
}))}
onClickItem={console.log}
>
<Button IconLeft={IconLibraryPlus}>Add</Button>
<Button IconLeft={IconLibraryPlus}>{label}</Button>
</Dropdown>
);
}
return <Button onClick={() => handleAdd()}>Add</Button>;
return <Button onClick={() => handleAdd()}>{label}</Button>;
};

View File

@@ -72,7 +72,7 @@ const FieldImpl = ({
);
if (isType(schema.type, "object")) {
return <ObjectField path={name} />;
return <ObjectField path={name} wrapperProps={props} />;
}
if (isType(schema.type, "array")) {
@@ -217,14 +217,14 @@ export type CustomFieldProps<Data = any> = {
) => React.ReactNode;
};
export const CustomField = <Data = any>({
export function CustomField<Data = any>({
path: _path,
valueStrict = true,
deriveFn,
children,
}: CustomFieldProps<Data>) => {
}: CustomFieldProps<Data>) {
const ctx = useDerivedFieldContext(_path, deriveFn);
const $value = useFormValue(ctx.path, { strict: valueStrict });
const $value = useFormValue(_path, { strict: valueStrict });
const setValue = (value: any) => ctx.setValue(ctx.path, value);
return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue });
};
}

View File

@@ -1,4 +1,4 @@
import { IconBug } from "@tabler/icons-react";
import { IconBug, IconInfoCircle } from "@tabler/icons-react";
import type { JsonSchema } from "json-schema-library";
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
import { IconButton } from "ui/components/buttons/IconButton";
@@ -11,6 +11,8 @@ import {
} from "ui/components/form/json-schema-form/Form";
import { Popover } from "ui/components/overlay/Popover";
import { getLabel } from "./utils";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "@mantine/core";
export type FieldwrapperProps = {
name: string;
@@ -23,8 +25,9 @@ export type FieldwrapperProps = {
children: ReactElement | ReactNode;
errorPlacement?: "top" | "bottom";
description?: string;
descriptionPlacement?: "top" | "bottom";
descriptionPlacement?: "top" | "bottom" | "label";
fieldId?: string;
className?: string;
};
export function FieldWrapper({
@@ -38,6 +41,7 @@ export function FieldWrapper({
descriptionPlacement = "bottom",
children,
fieldId,
className,
...props
}: FieldwrapperProps) {
const errors = useFormError(name, { strict: true });
@@ -50,17 +54,23 @@ export function FieldWrapper({
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
);
const Description = description && (
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
{description}
</Formy.Help>
);
const Description = description ? (
["top", "bottom"].includes(descriptionPlacement) ? (
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
{description}
</Formy.Help>
) : (
<Tooltip label={description}>
<IconInfoCircle className="size-4 opacity-50" />
</Tooltip>
)
) : null;
return (
<Formy.Group
error={errors.length > 0}
as={wrapper === "fieldset" ? "fieldset" : "div"}
className={hidden ? "hidden" : "relative"}
className={twMerge(hidden ? "hidden" : "relative", className)}
>
{errorPlacement === "top" && Errors}
<FieldDebug name={name} schema={schema} required={required} />
@@ -69,14 +79,15 @@ export function FieldWrapper({
<Formy.Label
as={wrapper === "fieldset" ? "legend" : "label"}
htmlFor={fieldId}
className="self-start"
className="self-start flex flex-row gap-1 items-center"
>
{label} {required && <span className="font-medium opacity-30">*</span>}
{descriptionPlacement === "label" && Description}
</Formy.Label>
)}
{descriptionPlacement === "top" && Description}
<div className="flex flex-row gap-2">
<div className="flex flex-row flex-grow gap-2">
<div className="flex flex-1 flex-col gap-3">
{Children.count(children) === 1 && isValidElement(children)
? cloneElement(children, {

View File

@@ -80,6 +80,7 @@ export function Form<
onInvalidSubmit,
validateOn = "submit",
hiddenSubmit = true,
beforeSubmit,
ignoreKeys = [],
options = {},
readOnly = false,
@@ -90,6 +91,7 @@ export function Form<
initialOpts?: LibTemplateOptions;
ignoreKeys?: string[];
onChange?: (data: Partial<Data>, name: string, value: any, context: FormContext<Data>) => void;
beforeSubmit?: (data: Data) => Data;
onSubmit?: (data: Data) => void | Promise<void>;
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
hiddenSubmit?: boolean;
@@ -128,7 +130,7 @@ export function Form<
if (errors.length === 0) {
await onSubmit(data as Data);
} else {
console.log("invalid", errors);
console.error("form: invalid", { data, errors });
onInvalidSubmit?.(errors, data);
}
} catch (e) {
@@ -177,7 +179,8 @@ export function Form<
});
const validate = useEvent((_data?: Partial<Data>) => {
const actual = _data ?? getCurrentState()?.data;
const before = beforeSubmit ?? ((a: any) => a);
const actual = before((_data as any) ?? getCurrentState()?.data);
const errors = lib.validate(actual, schema);
setFormState((prev) => ({ ...prev, errors }));
return { data: actual, errors };
@@ -378,5 +381,5 @@ export function FormDebug({ force = false }: { force?: boolean }) {
if (options?.debug !== true && force !== true) return null;
const ctx = useFormStateSelector((s) => s);
return <JsonViewer json={ctx} expand={99} />;
return <JsonViewer json={ctx} expand={99} showCopy />;
}

View File

@@ -2,7 +2,8 @@ import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
import { AnyOfField } from "./AnyOfField";
import { Field } from "./Field";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { type JSONSchema, useDerivedFieldContext } from "./Form";
import { type JSONSchema, useDerivedFieldContext, useFormValue } from "./Form";
import { JsonEditor } from "ui/components/code/JsonEditor";
export type ObjectFieldProps = {
path?: string;
@@ -11,7 +12,7 @@ export type ObjectFieldProps = {
};
export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => {
const { schema, ...ctx } = useDerivedFieldContext(path);
const { schema } = useDerivedFieldContext(path);
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
@@ -24,7 +25,7 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
{...wrapperProps}
>
{properties.length === 0 ? (
<i className="opacity-50">No properties</i>
<ObjectJsonField path={path} />
) : (
properties.map(([prop, schema]) => {
const name = [path, prop].filter(Boolean).join(".");
@@ -40,3 +41,9 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
</FieldWrapper>
);
};
export const ObjectJsonField = ({ path }: { path: string }) => {
const { value } = useFormValue(path);
const { setValue, path: absolutePath } = useDerivedFieldContext(path);
return <JsonEditor value={value} onChange={(value) => setValue(absolutePath, value)} />;
};

View File

@@ -67,18 +67,23 @@ export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data
return false;
}
const childSchema = lib.getSchema({ pointer, data, schema });
if (typeof childSchema === "object" && "const" in childSchema) {
return true;
try {
const childSchema = lib.getSchema({ pointer, data, schema });
if (typeof childSchema === "object" && "const" in childSchema) {
return true;
}
} catch (e) {
return false;
}
const parentPointer = getParentPointer(pointer);
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
const l = pointer.split("/").pop();
const required = parentSchema?.required?.includes(l);
return !!required;
} catch (e) {
console.error("isRequired", { pointer, schema, data, e });
console.warn("isRequired", { pointer, schema, data, e });
return false;
}
}

View File

@@ -10,23 +10,13 @@ export default function JsonField({
readonly,
...props
}: FieldProps) {
const value = JSON.stringify(formData, null, 2);
function handleChange(data) {
try {
onChange(JSON.parse(data));
} catch (err) {
console.error(err);
}
}
const isDisabled = disabled || readonly;
const id = props.idSchema.$id;
return (
<div className="flex flex-col gap-2">
<Label label={props.name} id={id} />
<JsonEditor value={value} editable={!isDisabled} onChange={handleChange} />
<JsonEditor value={formData} editable={!isDisabled} onChange={onChange} />
</div>
);
}

View File

@@ -1,17 +1,48 @@
import { useRef } from "react";
import { TbDots } from "react-icons/tb";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Message } from "ui/components/display/Message";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useState } from "react";
import { useNavigate } from "ui/lib/routes";
import { isDebug } from "core/env";
import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { IconButton } from "ui/components/buttons/IconButton";
import { TbAdjustments, TbDots, TbFilter, TbTrash, TbInfoCircle, TbCodeDots } from "react-icons/tb";
import { Button } from "ui/components/buttons/Button";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useNavigate } from "ui/lib/routes";
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form";
import { routes } from "ui/lib/routes";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import * as Formy from "ui/components/form/Formy";
import { ucFirst, s, transformObject, isObject, autoFormatString } from "bknd/utils";
import type { ModuleSchemas } from "bknd";
import {
CustomField,
Field,
FieldWrapper,
Form,
FormContextOverride,
FormDebug,
ObjectJsonField,
Subscribe,
useDerivedFieldContext,
useFormContext,
useFormError,
useFormValue,
} from "ui/components/form/json-schema-form";
import type { TPermission } from "auth/authorize/Permission";
import type { RoleSchema } from "auth/authorize/Role";
import { SegmentedControl, Tooltip } from "@mantine/core";
import { Popover } from "ui/components/overlay/Popover";
import { cn } from "ui/lib/utils";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { mountOnce, useApiQuery } from "ui/client";
import { CodePreview } from "ui/components/code/CodePreview";
import type { JsonError } from "json-schema-library";
import { Alert } from "ui/components/display/Alert";
export function AuthRolesEdit(props) {
useBrowserTitle(["Auth", "Roles", props.params.role]);
const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Roles & Permissions" />;
@@ -20,32 +51,69 @@ export function AuthRolesEdit(props) {
return <AuthRolesEditInternal {...props} />;
}
function AuthRolesEditInternal({ params }) {
// currently for backward compatibility
function getSchema(authSchema: ModuleSchemas["auth"]) {
const roles = authSchema.properties.roles.additionalProperties;
return {
...roles,
properties: {
...roles.properties,
permissions: {
...roles.properties.permissions.anyOf[1],
},
},
};
}
const formConfig = {
options: {
debug: isDebug(),
},
};
function AuthRolesEditInternal({ params }: { params: { role: string } }) {
const [navigate] = useNavigate();
const { config, actions } = useBkndAuth();
const { config, schema: authSchema, actions } = useBkndAuth();
const [error, setError] = useState<JsonError[]>();
const roleName = params.role;
const role = config.roles?.[roleName];
const formRef = useRef<AuthRoleFormRef>(null);
const { readonly } = useBknd();
const { readonly, permissions } = useBknd();
const schema = getSchema(authSchema);
const data = {
...role,
// this is to maintain array structure
permissions: permissions.map((p) => {
return role?.permissions?.find((v: any) => v.permission === p.name);
}),
};
async function handleUpdate() {
console.log("data", formRef.current?.isValid());
if (!formRef.current?.isValid()) return;
const data = formRef.current?.getData();
const success = await actions.roles.patch(roleName, data);
async function handleDelete() {
const success = await actions.roles.delete(roleName);
if (success) {
navigate(routes.auth.roles.list());
}
}
async function handleDelete() {
if (await actions.roles.delete(roleName)) {
navigate(routes.auth.roles.list());
}
async function handleUpdate(data: any) {
setError(undefined);
await actions.roles.patch(roleName, data);
}
return (
<>
<Form
schema={schema as any}
initialValues={data}
{...formConfig}
beforeSubmit={(data) => {
return {
...data,
permissions: [...Object.values(data.permissions).filter(Boolean)],
};
}}
onSubmit={handleUpdate}
onInvalidSubmit={(errors) => {
setError(errors);
}}
>
<AppShell.SectionHeader
right={
<>
@@ -69,9 +137,23 @@ function AuthRolesEditInternal({ params }) {
<IconButton Icon={TbDots} />
</Dropdown>
{!readonly && (
<Button variant="primary" onClick={handleUpdate}>
Update
</Button>
<Subscribe
selector={(state) => ({
dirty: state.dirty,
errors: state.errors.length > 0,
submitting: state.submitting,
})}
>
{({ dirty, errors, submitting }) => (
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
)}
</Subscribe>
)}
</>
}
@@ -85,8 +167,368 @@ function AuthRolesEditInternal({ params }) {
/>
</AppShell.SectionHeader>
<AppShell.Scrollable>
<AuthRoleForm ref={formRef} role={role} />
{error && <Alert.Exception message={"Invalid form data"} />}
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
<div className="flex flex-col gap-2">
<Permissions />
</div>
<div className="flex flex-col gap-4">
<Field
label="Should this role be the default?"
name="is_default"
description="In case an user is not assigned any role, this role will be assigned by default."
descriptionPlacement="top"
/>
<Field
label="Implicit allow missing permissions?"
name="implicit_allow"
description="This should be only used for admins. If a permission is not explicitly denied, it will be allowed."
descriptionPlacement="top"
/>
</div>
</div>
<FormDebug />
</AppShell.Scrollable>
</>
</Form>
);
}
type PermissionsData = Exclude<RoleSchema["permissions"], string[] | undefined>;
type PermissionData = PermissionsData[number];
const Permissions = () => {
const { permissions } = useBknd();
const grouped = permissions.reduce(
(acc, permission, index) => {
const [group, name] = permission.name.split(".") as [string, string];
if (!acc[group]) acc[group] = [];
acc[group].push({ index, permission });
return acc;
},
{} as Record<string, { index: number; permission: TPermission }[]>,
);
return (
<div className="flex flex-col gap-10">
{Object.entries(grouped).map(([group, rows]) => {
return (
<div className="flex flex-col gap-2" key={group}>
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3 items-start">
{rows.map(({ index, permission }) => (
<Permission key={permission.name} permission={permission} index={index} />
))}
</div>
</div>
);
})}
</div>
);
};
const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => {
const path = `permissions.${index}`;
const { value } = useDerivedFieldContext("permissions", (ctx) => {
const v = ctx.value;
if (!Array.isArray(v)) return undefined;
const v2 = v.find((v) => v && v.permission === permission.name);
return {
set: !!v2,
policies: (v2?.policies?.length ?? 0) as number,
};
});
const { setValue } = useFormContext();
const [open, setOpen] = useState(false);
const policiesCount = value?.policies ?? 0;
const isSet = value?.set ?? false;
async function handleSwitch() {
if (isSet) {
setValue(path, undefined);
setOpen(false);
} else {
setValue(path, {
permission: permission.name,
policies: [],
effect: "allow",
});
}
}
function toggleOpen() {
setOpen((o) => !o);
}
return (
<>
<div
key={permission.name}
className={cn("flex flex-col border border-muted", open && "border-primary/20")}
>
<div className={cn("flex flex-row gap-2 justify-between", open && "bg-primary/5")}>
<div className="py-4 px-4 font-mono leading-none flex flex-row gap-2 items-center">
{permission.name}
{permission.filterable && (
<Tooltip label="Permission supports filtering">
<TbFilter className="opacity-50" />
</Tooltip>
)}
</div>
<div className="flex flex-grow" />
<div className="flex flex-row gap-1 items-center px-2">
<div className="relative flex flex-row gap-1 items-center">
{policiesCount > 0 && (
<div className="bg-primary/80 text-background rounded-full size-5 flex items-center justify-center text-sm font-bold pointer-events-none">
{policiesCount}
</div>
)}
<IconButton
size="md"
variant="ghost"
disabled={!isSet}
Icon={TbAdjustments}
className={cn("disabled:opacity-20")}
onClick={toggleOpen}
/>
</div>
<Formy.Switch size="sm" checked={isSet} onChange={handleSwitch} />
</div>
</div>
{open && (
<div className="px-3.5 py-3.5">
<Policies path={`permissions.${index}.policies`} permission={permission} />
</div>
)}
</div>
</>
);
};
const Policies = ({ path, permission }: { path: string; permission: TPermission }) => {
const {
setValue,
schema: policySchema,
lib,
deleteValue,
value,
} = useDerivedFieldContext(path, ({ value }) => {
return {
policies: (value?.length ?? 0) as number,
};
});
const policiesCount = value?.policies ?? 0;
function handleAdd() {
setValue(
`${path}.${policiesCount}`,
lib.getTemplate(undefined, policySchema!.items, {
addOptionalProps: true,
}),
);
}
function handleDelete(index: number) {
deleteValue(`${path}.${index}`);
}
return (
<div className={cn("flex flex-col", policiesCount > 0 && "gap-8")}>
<div className="flex flex-col gap-5">
{policiesCount > 0 &&
Array.from({ length: policiesCount }).map((_, i) => (
<FormContextOverride key={i} prefix={`${path}.${i}`} schema={policySchema.items!}>
{i > 0 && <div className="h-px bg-muted" />}
<div className="flex flex-row gap-2 items-start">
<div className="flex flex-col flex-grow w-full">
<Policy permission={permission} />
</div>
<IconButton Icon={TbTrash} onClick={() => handleDelete(i)} size="sm" />
</div>
</FormContextOverride>
))}
</div>
<div className="flex flex-row justify-center">
<Button onClick={handleAdd}>Add Policy</Button>
</div>
</div>
);
};
const mergeSchemas = (...schemas: object[]) => {
return s.allOf(schemas.filter(Boolean).map(s.fromSchema));
};
function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
if (!isObject(schema) || !Array.isArray(entities) || entities.length === 0) return schema;
return transformObject(schema, (sub, name) => {
if (name === "properties") {
return transformObject(sub as Record<string, any>, (propConfig, propKey) => {
if (propKey === "entity" && propConfig.type === "string") {
return {
...propConfig,
enum: entities,
};
}
return propConfig;
});
}
return sub;
});
}
const Policy = ({
permission,
}: {
permission: TPermission;
}) => {
const { value } = useDerivedFieldContext("", ({ value }) => ({
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
}));
const $bknd = useBknd();
const $permissions = useApiQuery((api) => api.system.permissions(), {
use: [mountOnce],
});
const entities = Object.keys($bknd.config.data.entities ?? {});
const ctx = $permissions.data
? mergeSchemas(
$permissions.data.context,
replaceEntitiesEnum(permission.context ?? null, entities),
)
: undefined;
return (
<div className="flex flex-col gap-2">
<Field name="description" />
<CustomFieldWrapper
name="condition"
label="Condition"
description="The condition that must be met for the policy to be applied."
schema={
ctx && {
name: "Context",
content: s.toTypes(ctx, "Context"),
}
}
>
<ObjectJsonField path="condition" />
</CustomFieldWrapper>
<CustomField path="effect">
{({ value, setValue }) => (
<FieldWrapper
name="effect"
label="Effect"
descriptionPlacement="label"
description="The effect of the policy to take effect on met condition."
>
<SegmentedControl
className="border border-muted"
defaultValue={value}
onChange={(value) => setValue(value)}
data={
["allow", "deny", permission.filterable ? "filter" : undefined]
.filter(Boolean)
.map((effect) => ({
label: ucFirst(effect ?? ""),
value: effect,
})) as any
}
/>
</FieldWrapper>
)}
</CustomField>
{value?.effect === "filter" && (
<CustomFieldWrapper
name="filter"
label="Filter"
description="Filter to apply to all queries on met condition."
schema={
ctx && {
name: "Variables",
content: s.toTypes(ctx, "Variables"),
}
}
>
<ObjectJsonField path="filter" />
</CustomFieldWrapper>
)}
</div>
);
};
const CustomFieldWrapper = ({
children,
name,
label,
description,
schema,
}: {
children: React.ReactNode;
name: string;
label: string;
description: string;
schema?: {
name: string;
content: string | object;
};
}) => {
const errors = useFormError(name, { strict: true });
const Errors = errors.length > 0 && (
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
);
return (
<Formy.Group as="div">
<Formy.Label
as="label"
htmlFor={name}
className="flex flex-row gap-1 justify-between items-center"
>
<div className="flex flex-row gap-1 items-center">
{label}
{description && (
<Tooltip label={description}>
<TbInfoCircle className="size-4 opacity-50" />
</Tooltip>
)}
</div>
{schema && (
<div>
<Popover
overlayProps={{
className: "max-w-none",
}}
position="bottom-end"
target={() =>
typeof schema.content === "object" ? (
<JsonViewer
className="w-auto max-w-120 bg-background pr-3 text-sm"
json={schema.content}
title={schema.name}
expand={5}
/>
) : (
<CodePreview
code={schema.content}
lang="typescript"
className="w-auto max-w-120 bg-background p-3 text-sm"
/>
)
}
>
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
{autoFormatString(schema.name)}
</Button>
</Popover>
</div>
)}
</Formy.Label>
{children}
{Errors}
</Formy.Group>
);
};

View File

@@ -12,8 +12,21 @@ import { CellValue, DataTable } from "../../components/table/DataTable";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
import { useBknd } from "ui/client/bknd";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { Message } from "ui/components/display/Message";
export function AuthRolesList() {
export function AuthRolesList(props) {
useBrowserTitle(["Auth", "Roles"]);
const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Auth Roles" />;
}
return <AuthRolesListInternal {...props} />;
}
function AuthRolesListInternal() {
const [navigate] = useNavigate();
const { config, actions } = useBkndAuth();
const { readonly } = useBknd();
@@ -21,7 +34,10 @@ export function AuthRolesList() {
const data = Object.values(
transformObject(config.roles ?? {}, (role, name) => ({
role: name,
permissions: role.permissions,
permissions: role.permissions?.map((p) => p.permission) as string[],
policies: role.permissions
?.flatMap((p) => p.policies?.length ?? 0)
.reduce((acc, curr) => acc + curr, 0),
is_default: role.is_default ?? false,
implicit_allow: role.implicit_allow ?? false,
})),
@@ -94,6 +110,9 @@ const renderValue = ({ value, property }) => {
if (["is_default", "implicit_allow"].includes(property)) {
return value ? <span>Yes</span> : <span className="opacity-50">No</span>;
}
if (property === "policies") {
return value ? <span>{value}</span> : <span className="opacity-50">0</span>;
}
if (property === "permissions") {
const max = 3;

View File

@@ -34,7 +34,11 @@ export const AuthRoleForm = forwardRef<
getValues,
} = useForm({
resolver: standardSchemaResolver(schema),
defaultValues: role,
defaultValues: {
...role,
// compat
permissions: role?.permissions?.map((p) => p.permission),
},
});
useImperativeHandle(ref, () => ({
@@ -47,7 +51,7 @@ export const AuthRoleForm = forwardRef<
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
<div className="flex flex-col gap-2">
{/*<h3 className="font-semibold">Role Permissions</h3>*/}
<Permissions control={control} permissions={permissions} />
<Permissions control={control} permissions={permissions.map((p) => p.name)} />
</div>
<div className="flex flex-col gap-4">
<Input.Wrapper
@@ -111,8 +115,6 @@ const Permissions = ({
{} as Record<string, string[]>,
);
console.log("grouped", grouped);
//console.log("fieldState", fieldState, value);
return (
<div className="flex flex-col gap-10">
{Object.entries(grouped).map(([group, permissions]) => {
@@ -121,7 +123,7 @@ const Permissions = ({
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3">
{permissions.map((permission) => {
const selected = data.includes(permission);
const selected = data.includes(permission as any);
return (
<div key={permission} className="flex flex-col border border-muted">
<div className="flex flex-row gap-2 justify-between">

View File

@@ -63,10 +63,10 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
} catch (e) {}
console.log("_s", _s);
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
if (_s.permissions) {
/* if (_s.permissions) {
roleSchema.properties.permissions.items.enum = _s.permissions;
roleSchema.properties.permissions.uniqueItems = true;
}
} */
return (
<Route path="/auth" nest>

View File

@@ -27,6 +27,7 @@ import SortableTest from "./tests/sortable-test";
import { SqlAiTest } from "./tests/sql-ai-test";
import Themes from "./tests/themes";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import CodeEditorTest from "./tests/code-editor-test";
const tests = {
DropdownTest,
@@ -52,6 +53,7 @@ const tests = {
JsonSchemaForm3,
FormyTest,
HtmlFormTest,
CodeEditorTest,
} as const;
export default function TestRoutes() {

View File

@@ -0,0 +1,13 @@
import { useState } from "react";
import { JsonEditor } from "ui/components/code/JsonEditor";
import { JsonViewer } from "ui/components/code/JsonViewer";
export default function CodeEditorTest() {
const [value, setValue] = useState({});
return (
<div className="flex flex-col p-4">
<JsonEditor value={value} onChange={setValue} />
<JsonViewer json={value} expand={9} />
</div>
);
}

View File

@@ -56,6 +56,14 @@ const authSchema = {
},
} as const satisfies JSONSchema;
const objectCodeSchema = {
type: "object",
properties: {
name: { type: "string" },
config: { type: "object", properties: {} },
},
};
const formOptions = {
debug: true,
};
@@ -77,6 +85,45 @@ export default function JsonSchemaForm3() {
{/* <Form schema={_schema.auth.toJSON()} options={formOptions} /> */}
<Form
schema={objectCodeSchema as any}
options={formOptions}
initialValues={{
name: "Peter",
config: {
foo: "bar",
},
}}
/>
<Form
schema={s
.object({
name: s.string(),
props: s.array(
s.object({
age: s.number(),
config: s.object({}),
}),
),
})
.toJSON()}
options={formOptions}
initialValues={{
name: "Peter",
props: [{ age: 20, config: { foo: "bar" } }],
}}
/>
<Form
schema={s
.object({
name: s.string(),
props: s.array(s.anyOf([s.string(), s.number()])),
})
.toJSON()}
options={formOptions}
/>
{/* <Form
options={{
anyOfNoneSelectedMode: "first",
debug: true,
@@ -98,7 +145,7 @@ export default function JsonSchemaForm3() {
.optional(),
})
.toJSON()}
/>
/> */}
{/*<Form
onChange={(data) => console.log("change", data)}

View File

@@ -39,7 +39,7 @@ export default function ToolsMcp() {
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
<TbWorld />
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-mono leading-none">
<span className="block truncate text-sm font-mono leading-none select-text">
{window.location.origin + mcpPath}
</span>
</div>

View File

@@ -12,6 +12,7 @@ import * as Formy from "ui/components/form/Formy";
import { appShellStore } from "ui/store";
import { Icon } from "ui/components/display/Icon";
import { useMcpClient } from "./hooks/use-mcp-client";
import { Tooltip } from "@mantine/core";
export function Sidebar({ open, toggle }) {
const client = useMcpClient();
@@ -48,7 +49,11 @@ export function Sidebar({ open, toggle }) {
toggle={toggle}
renderHeaderRight={() => (
<div className="flex flex-row gap-2 items-center">
{error && <Icon.Err title={error} className="size-5 pointer-events-auto" />}
{error && (
<Tooltip label={error}>
<Icon.Err className="size-5 pointer-events-auto" />
</Tooltip>
)}
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
{tools.length}
</span>

View File

@@ -15,7 +15,7 @@
},
"app": {
"name": "bknd",
"version": "0.18.0-rc.6",
"version": "0.18.1",
"bin": "./dist/cli/index.js",
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
@@ -35,7 +35,7 @@
"hono": "4.8.3",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.8.4",
"jsonv-ts": "0.9.1",
"kysely": "0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
@@ -1243,7 +1243,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="],
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -2529,7 +2529,7 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"jsonv-ts": ["jsonv-ts@0.8.4", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-TZOyAVGBZxHuzk09NgJCx2dbeh0XqVWVKHU1PtIuvjT9XO7zhvAD02RcVisJoUdt2rJNt3zlyeNQ2b8MMPc+ug=="],
"jsonv-ts": ["jsonv-ts@0.9.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-sQZn7kdSMK9m3hLWvTLyNk2zCUmte2lVWIcK02633EwMosk/VAdRgpMyfMDMV6/ZzSMI0/SwevkUbkxdGQrWtg=="],
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
@@ -3847,6 +3847,8 @@
"@bknd/plasmic/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@bknd/postgres/@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
"@bknd/sqlocal/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@bundled-es-modules/cookie/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -4093,7 +4095,7 @@
"@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
"@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
"@types/bun/bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
"@typescript-eslint/experimental-utils/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="],
@@ -4701,6 +4703,8 @@
"@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="],
"@bknd/postgres/@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
"@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250917.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0kL/kFnKUSycoo7b3PgM0nRyZ+1MGQAKaXtE6a2+SAeUkZ2FLnuFWmASi0s4rlWGsf/rlTw4AwXROePir9dUcQ=="],