Update permissions handling and enhance Guard functionality

- Bump `jsonv-ts` dependency to 0.8.6.
- Refactor permission checks in the `Guard` class to improve context validation and error handling.
- Update tests to reflect changes in permission handling, ensuring robust coverage for new scenarios.
- Introduce new test cases for data permissions, enhancing overall test coverage and reliability.
This commit is contained in:
dswbx
2025-10-21 16:44:08 +02:00
parent 0347efa592
commit 38902ebcba
20 changed files with 859 additions and 153 deletions

View File

@@ -152,12 +152,12 @@ describe("authorize", () => {
expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow(); expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow();
// get the filter for member role // get the filter for member role
expect(guard.getPolicyFilter(read, { role: "member" })).toEqual({ expect(guard.filters(read, { role: "member" }).filter).toEqual({
type: "member", type: "member",
}); });
// get filter for guest // get filter for guest
expect(guard.getPolicyFilter(read, {})).toBeUndefined(); expect(guard.filters(read, {}).filter).toBeUndefined();
}); });
test("guest should only read posts that are public", () => { test("guest should only read posts that are public", () => {
@@ -226,7 +226,7 @@ describe("authorize", () => {
expect(() => guard.granted(read, {}, { entity: "users" })).toThrow(); expect(() => guard.granted(read, {}, { entity: "users" })).toThrow();
// and guests can only read public posts // and guests can only read public posts
expect(guard.getPolicyFilter(read, {}, { entity: "posts" })).toEqual({ expect(guard.filters(read, {}, { entity: "posts" }).filter).toEqual({
public: true, public: true,
}); });
@@ -236,7 +236,7 @@ describe("authorize", () => {
// member should not have a filter // member should not have a filter
expect( expect(
guard.getPolicyFilter(read, { role: "member" }, { entity: "posts" }), guard.filters(read, { role: "member" }, { entity: "posts" }).filter,
).toBeUndefined(); ).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

@@ -10,7 +10,7 @@ async function makeApp(config: Partial<CreateAppConfig> = {}) {
return app; return app;
} }
describe("SystemController", () => { describe.skip("SystemController", () => {
it("...", async () => { it("...", async () => {
const app = await makeApp(); const app = await makeApp();
const controller = new SystemController(app); const controller = new SystemController(app);

View File

@@ -122,26 +122,10 @@ describe("Guard", () => {
const guard = new Guard([p], [r], { const guard = new Guard([p], [r], {
enabled: true, enabled: true,
}); });
expect( expect(guard.filters(p, { role: r.name }, { a: 1 }).filter).toEqual({ foo: "bar" });
guard.getPolicyFilter( expect(guard.filters(p, { role: r.name }, { a: 2 }).filter).toBeUndefined();
p,
{
role: r.name,
},
{ a: 1 },
),
).toEqual({ foo: "bar" });
expect(
guard.getPolicyFilter(
p,
{
role: r.name,
},
{ a: 2 },
),
).toBeUndefined();
// if no user context given, filter cannot be applied // if no user context given, filter cannot be applied
expect(guard.getPolicyFilter(p, {}, { a: 1 })).toBeUndefined(); expect(guard.filters(p, {}, { a: 1 }).filter).toBeUndefined();
}); });
it("collects filters for default role", () => { it("collects filters for default role", () => {
@@ -172,26 +156,26 @@ describe("Guard", () => {
}); });
expect( expect(
guard.getPolicyFilter( guard.filters(
p, p,
{ {
role: r.name, role: r.name,
}, },
{ a: 1 }, { a: 1 },
), ).filter,
).toEqual({ foo: "bar" }); ).toEqual({ foo: "bar" });
expect( expect(
guard.getPolicyFilter( guard.filters(
p, p,
{ {
role: r.name, role: r.name,
}, },
{ a: 2 }, { a: 2 },
), ).filter,
).toBeUndefined(); ).toBeUndefined();
// if no user context given, the default role is applied // if no user context given, the default role is applied
// hence it can be found // hence it can be found
expect(guard.getPolicyFilter(p, {}, { a: 1 })).toEqual({ foo: "bar" }); expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" });
}); });
}); });

View File

@@ -272,6 +272,7 @@ describe("Core Utils", async () => {
}, },
/^@([a-z\.]+)$/, /^@([a-z\.]+)$/,
variables7, variables7,
null,
); );
expect(result7).toEqual({ expect(result7).toEqual({
number: 123, number: 123,
@@ -288,20 +289,85 @@ describe("Core Utils", async () => {
); );
expect(result8).toEqual({ message: "The value is 123!" }); expect(result8).toEqual({ message: "The value is 123!" });
// test mixed scenarios // test with fallback parameter
const obj9 = { user: "@user.id", config: "@config.theme" };
const variables9 = {}; // empty context
const result9 = utils.recursivelyReplacePlaceholders( const result9 = utils.recursivelyReplacePlaceholders(
{ obj9,
fullMatch: "@test.value", // should preserve number type
partialMatch: "Value: @test.value", // should convert to string
noMatch: "static text",
},
/^@([a-z\.]+)$/, /^@([a-z\.]+)$/,
variables7, variables9,
null,
); );
expect(result9).toEqual({ expect(result9).toEqual({ user: null, config: null });
fullMatch: 123, // number preserved
partialMatch: "Value: @test.value", // no replacement (pattern requires full match) // test with fallback for partial matches
noMatch: "static text", 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,
},
},
}); });
}); });
}); });

View File

@@ -30,9 +30,9 @@ describe("some tests", async () => {
const query = await em.repository(users).findId(1); const query = await em.repository(users).findId(1);
expect(query.sql).toBe( 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(); expect(query.data).toBeUndefined();
}); });

View File

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

View File

@@ -61,7 +61,9 @@ export class AuthController extends Controller {
hono.post( hono.post(
"/create", "/create",
permission(AuthPermissions.createUser, {}), permission(AuthPermissions.createUser, {}),
permission(DataPermissions.entityCreate, {}), permission(DataPermissions.entityCreate, {
context: (c) => ({ entity: this.auth.config.entity_name }),
}),
describeRoute({ describeRoute({
summary: "Create a new user", summary: "Create a new user",
tags: ["auth"], tags: ["auth"],
@@ -224,7 +226,6 @@ export class AuthController extends Controller {
const roles = Object.keys(this.auth.config.roles ?? {}); const roles = Object.keys(this.auth.config.roles ?? {});
mcp.tool( mcp.tool(
// @todo: needs permission
"auth_user_create", "auth_user_create",
{ {
description: "Create a new user", description: "Create a new user",
@@ -246,7 +247,6 @@ export class AuthController extends Controller {
); );
mcp.tool( mcp.tool(
// @todo: needs permission
"auth_user_token", "auth_user_token",
{ {
description: "Get a user token", description: "Get a user token",
@@ -264,7 +264,6 @@ export class AuthController extends Controller {
); );
mcp.tool( mcp.tool(
// @todo: needs permission
"auth_user_password_change", "auth_user_password_change",
{ {
description: "Change a user's password", description: "Change a user's password",
@@ -286,7 +285,6 @@ export class AuthController extends Controller {
); );
mcp.tool( mcp.tool(
// @todo: needs permission
"auth_user_password_test", "auth_user_password_test",
{ {
description: "Test a user's password", description: "Test a user's password",

View File

@@ -1,5 +1,5 @@
import { Exception } from "core/errors"; import { Exception } from "core/errors";
import { $console, type s } from "bknd/utils"; import { $console, mergeObject, type s } from "bknd/utils";
import type { Permission, PermissionContext } from "auth/authorize/Permission"; import type { Permission, PermissionContext } from "auth/authorize/Permission";
import type { Context } from "hono"; import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
@@ -232,41 +232,85 @@ export class Guard {
}); });
} }
getPolicyFilter<P extends Permission<any, any, any, any>>( filters<P extends Permission<any, any, any, any>>(
permission: P, permission: P,
c: GuardContext, c: GuardContext,
context: PermissionContext<P>, context: PermissionContext<P>,
): PolicySchema["filter"] | undefined; );
getPolicyFilter<P extends Permission<any, any, undefined, any>>( filters<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext);
permission: P, filters<P extends Permission<any, any, any, any>>(
c: GuardContext,
): PolicySchema["filter"] | undefined;
getPolicyFilter<P extends Permission<any, any, any, any>>(
permission: P, permission: P,
c: GuardContext, c: GuardContext,
context?: PermissionContext<P>, context?: PermissionContext<P>,
): PolicySchema["filter"] | undefined { ) {
if (!permission.isFilterable()) { if (!permission.isFilterable()) {
$console.debug("getPolicyFilter: permission is not filterable, returning undefined"); throw new GuardPermissionsException(permission, undefined, "Permission is not filterable");
return;
} }
const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context); const {
ctx: _ctx,
exists,
role,
user,
rolePermission,
} = this.collect(permission, c, context);
// validate context // validate context
let ctx = Object.assign({}, _ctx); let ctx = Object.assign(
{
user,
},
_ctx,
);
if (permission.context) { if (permission.context) {
ctx = permission.parseContext(ctx); ctx = permission.parseContext(ctx, {
coerceDropUnknown: false,
});
} }
const filters: PolicySchema["filter"][] = [];
const policies: Policy[] = [];
if (exists && role && rolePermission && rolePermission.policies.length > 0) { if (exists && role && rolePermission && rolePermission.policies.length > 0) {
for (const policy of rolePermission.policies) { for (const policy of rolePermission.policies) {
if (policy.content.effect === "filter") { if (policy.content.effect === "filter") {
const meets = policy.meetsCondition(ctx); const meets = policy.meetsCondition(ctx);
return meets ? policy.content.filter : undefined; if (meets) {
policies.push(policy);
filters.push(policy.getReplacedFilter(ctx));
} }
} }
} }
return; }
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

@@ -54,6 +54,8 @@ export class Permission<
} }
parseContext(ctx: ContextValue, opts?: ParseOptions) { parseContext(ctx: ContextValue, opts?: ParseOptions) {
// @todo: allow additional properties
if (!this.context) return ctx;
try { try {
return this.context ? parse(this.context!, ctx, opts) : undefined; return this.context ? parse(this.context!, ctx, opts) : undefined;
} catch (e) { } catch (e) {

View File

@@ -21,8 +21,15 @@ export class Policy<Schema extends PolicySchema = PolicySchema> {
}) as Schema; }) as Schema;
} }
replace(context: object, vars?: Record<string, any>) { replace(context: object, vars?: Record<string, any>, fallback?: any) {
return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context; 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>) { meetsCondition(context: object, vars?: Record<string, any>) {

View File

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

View File

@@ -9,6 +9,7 @@ import {
pickKeys, pickKeys,
mcpTool, mcpTool,
convertNumberedObjectToArray, convertNumberedObjectToArray,
mergeObject,
} from "bknd/utils"; } from "bknd/utils";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema"; import type { AppDataConfig } from "../data-schema";
@@ -95,7 +96,9 @@ export class DataController extends Controller {
// read entity schema // read entity schema
hono.get( hono.get(
"/schema.json", "/schema.json",
permission(DataPermissions.entityRead, {}), permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({ describeRoute({
summary: "Retrieve data schema", summary: "Retrieve data schema",
tags: ["data"], tags: ["data"],
@@ -121,7 +124,9 @@ export class DataController extends Controller {
// read schema // read schema
hono.get( hono.get(
"/schemas/:entity/:context?", "/schemas/:entity/:context?",
permission(DataPermissions.entityRead, {}), permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({ describeRoute({
summary: "Retrieve entity schema", summary: "Retrieve entity schema",
tags: ["data"], tags: ["data"],
@@ -161,7 +166,9 @@ export class DataController extends Controller {
*/ */
hono.get( hono.get(
"/info/:entity", "/info/:entity",
permission(DataPermissions.entityRead, {}), permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({ describeRoute({
summary: "Retrieve entity info", summary: "Retrieve entity info",
tags: ["data"], tags: ["data"],
@@ -213,7 +220,9 @@ export class DataController extends Controller {
// fn: count // fn: count
hono.post( hono.post(
"/:entity/fn/count", "/:entity/fn/count",
permission(DataPermissions.entityRead, {}), permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({ describeRoute({
summary: "Count entities", summary: "Count entities",
tags: ["data"], tags: ["data"],
@@ -236,7 +245,9 @@ export class DataController extends Controller {
// fn: exists // fn: exists
hono.post( hono.post(
"/:entity/fn/exists", "/:entity/fn/exists",
permission(DataPermissions.entityRead, {}), permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({ describeRoute({
summary: "Check if entity exists", summary: "Check if entity exists",
tags: ["data"], tags: ["data"],
@@ -285,16 +296,26 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]), parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead, {}),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc("query", repoQuery, { skipOpenAPI: true }), jsc("query", repoQuery, { skipOpenAPI: true }),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
async (c) => { async (c) => {
const { entity } = c.req.valid("param"); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
entity,
});
const options = c.req.valid("query") as RepoQuery; 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 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
@@ -308,7 +329,9 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["offset", "sort", "select"]), parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead, {}), permission(DataPermissions.entityRead, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_read_one", { mcpTool("data_entity_read_one", {
inputSchema: { inputSchema: {
param: s.object({ entity: entitiesEnum, id: idType }), param: s.object({ entity: entitiesEnum, id: idType }),
@@ -326,11 +349,19 @@ export class DataController extends Controller {
jsc("query", repoQuery, { skipOpenAPI: true }), jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => { async (c) => {
const { entity, id } = c.req.valid("param"); const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity) || !id) {
return this.notFound(c); return this.notFound(c);
} }
const options = c.req.valid("query") as RepoQuery; 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 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
@@ -344,7 +375,9 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(), parameters: saveRepoQueryParams(),
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead, {}), permission(DataPermissions.entityRead, {
context: (c) => ({ ...c.req.param() }) as any,
}),
jsc( jsc(
"param", "param",
s.object({ s.object({
@@ -361,9 +394,20 @@ export class DataController extends Controller {
} }
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;
const result = await this.em const { entity: newEntity } = this.em
.repository(entity) .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 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
@@ -390,7 +434,9 @@ export class DataController extends Controller {
}, },
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead, {}), permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
mcpTool("data_entity_read_many", { mcpTool("data_entity_read_many", {
inputSchema: { inputSchema: {
param: s.object({ entity: entitiesEnum }), param: s.object({ entity: entitiesEnum }),
@@ -405,7 +451,13 @@ export class DataController extends Controller {
return this.notFound(c); return this.notFound(c);
} }
const options = c.req.valid("json") as RepoQuery; 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 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
@@ -421,7 +473,9 @@ export class DataController extends Controller {
summary: "Insert one or many", summary: "Insert one or many",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityCreate, {}), permission(DataPermissions.entityCreate, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_insert"), mcpTool("data_entity_insert"),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
@@ -438,6 +492,12 @@ export class DataController extends Controller {
// to transform all validation targets into a single object // to transform all validation targets into a single object
const body = convertNumberedObjectToArray(_body); const body = convertNumberedObjectToArray(_body);
this.ctx.guard
.filters(DataPermissions.entityCreate, c, {
entity,
})
.matches(body, { throwOnError: true });
if (Array.isArray(body)) { if (Array.isArray(body)) {
const result = await this.em.mutator(entity).insertMany(body); const result = await this.em.mutator(entity).insertMany(body);
return c.json(result, 201); return c.json(result, 201);
@@ -455,7 +515,9 @@ export class DataController extends Controller {
summary: "Update many", summary: "Update many",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityUpdate, {}), permission(DataPermissions.entityUpdate, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_update_many", { mcpTool("data_entity_update_many", {
inputSchema: { inputSchema: {
param: s.object({ entity: entitiesEnum }), param: s.object({ entity: entitiesEnum }),
@@ -482,7 +544,10 @@ export class DataController extends Controller {
update: EntityData; update: EntityData;
where: RepoQuery["where"]; 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); return c.json(result);
}, },
@@ -495,7 +560,9 @@ export class DataController extends Controller {
summary: "Update one", summary: "Update one",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityUpdate, {}), permission(DataPermissions.entityUpdate, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_update_one"), mcpTool("data_entity_update_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})), jsc("json", s.object({})),
@@ -505,6 +572,17 @@ export class DataController extends Controller {
return this.notFound(c); return this.notFound(c);
} }
const body = (await c.req.json()) as EntityData; 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); const result = await this.em.mutator(entity).updateOne(id, body);
return c.json(result); return c.json(result);
@@ -518,7 +596,9 @@ export class DataController extends Controller {
summary: "Delete one", summary: "Delete one",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityDelete, {}), permission(DataPermissions.entityDelete, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_delete_one"), mcpTool("data_entity_delete_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => { async (c) => {
@@ -526,6 +606,18 @@ export class DataController extends Controller {
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); 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); const result = await this.em.mutator(entity).deleteOne(id);
return c.json(result); return c.json(result);
@@ -539,7 +631,9 @@ export class DataController extends Controller {
summary: "Delete many", summary: "Delete many",
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityDelete, {}), permission(DataPermissions.entityDelete, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_delete_many", { mcpTool("data_entity_delete_many", {
inputSchema: { inputSchema: {
param: s.object({ entity: entitiesEnum }), param: s.object({ entity: entitiesEnum }),
@@ -554,7 +648,10 @@ export class DataController extends Controller {
return this.notFound(c); return this.notFound(c);
} }
const where = (await c.req.json()) as RepoQuery["where"]; 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); 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 { $console } from "bknd/utils";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely"; import { type SelectQueryBuilder, sql } from "kysely";
@@ -280,16 +280,11 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
id: PrimaryFieldType, id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>,
): Promise<RepositoryResult<TBD[TB] | undefined>> { ): Promise<RepositoryResult<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery( if (typeof id === "undefined" || id === null) {
{ throw new InvalidSearchParamsException("id is required");
..._options, }
where: { [this.entity.getPrimaryField().name]: id },
limit: 1,
},
["offset", "sort"],
);
return this.single(qb, options) as any; return this.findOne({ [this.entity.getPrimaryField().name]: id }, _options);
} }
async findOne( async findOne(
@@ -315,23 +310,27 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return res as any; 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 // @todo: add unit tests, specially for many to many
async findManyByReference( async findManyByReference(
id: PrimaryFieldType, id: PrimaryFieldType,
reference: string, reference: string,
_options?: Partial<Omit<RepoQuery, "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "limit" | "offset">>,
): Promise<RepositoryResult<EntityData>> { ): Promise<RepositoryResult<EntityData>> {
const entity = this.entity; const { entity: newEntity, relation } = this.getEntityByReference(reference);
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 refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference); const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference);
if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) { if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) {
throw new Error( throw new Error(

View File

@@ -1,9 +1,51 @@
import { Permission } from "auth/authorize/Permission"; import { Permission } from "auth/authorize/Permission";
import { s } from "bknd/utils";
export const entityRead = new Permission("data.entity.read"); export const entityRead = new Permission(
export const entityCreate = new Permission("data.entity.create"); "data.entity.read",
export const entityUpdate = new Permission("data.entity.update"); {
export const entityDelete = new Permission("data.entity.delete"); 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 databaseSync = new Permission("data.database.sync");
export const rawQuery = new Permission("data.raw.query"); export const rawQuery = new Permission("data.raw.query");
export const rawMutate = new Permission("data.raw.mutate"); export const rawMutate = new Permission("data.raw.mutate");

View File

@@ -217,14 +217,14 @@ export type CustomFieldProps<Data = any> = {
) => React.ReactNode; ) => React.ReactNode;
}; };
export const CustomField = <Data = any>({ export function CustomField<Data = any>({
path: _path, path: _path,
valueStrict = true, valueStrict = true,
deriveFn, deriveFn,
children, children,
}: CustomFieldProps<Data>) => { }: CustomFieldProps<Data>) {
const ctx = useDerivedFieldContext(_path, deriveFn); 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); const setValue = (value: any) => ctx.setValue(ctx.path, value);
return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue }); return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue });
}; }

View File

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

View File

@@ -2,12 +2,12 @@ import { useBknd } from "ui/client/bknd";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useRef, useState } from "react"; import { useState } from "react";
import { useNavigate } from "ui/lib/routes"; import { useNavigate } from "ui/lib/routes";
import { isDebug } from "core/env"; import { isDebug } from "core/env";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { TbAdjustments, TbDots, TbLock, TbLockOpen, TbLockOpen2 } from "react-icons/tb"; import { TbAdjustments, TbDots, TbFilter, TbTrash } from "react-icons/tb";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes } from "ui/lib/routes"; import { routes } from "ui/lib/routes";
@@ -18,17 +18,23 @@ import { ucFirst, type s } from "bknd/utils";
import type { ModuleSchemas } from "bknd"; import type { ModuleSchemas } from "bknd";
import { import {
ArrayField, ArrayField,
CustomField,
Field, Field,
FieldWrapper,
Form, Form,
FormContextOverride,
FormDebug, FormDebug,
ObjectField,
Subscribe, Subscribe,
useDerivedFieldContext,
useFormContext, useFormContext,
useFormValue, useFormValue,
} from "ui/components/form/json-schema-form"; } from "ui/components/form/json-schema-form";
import type { TPermission } from "auth/authorize/Permission"; import type { TPermission } from "auth/authorize/Permission";
import type { RoleSchema } from "auth/authorize/Role"; import type { RoleSchema } from "auth/authorize/Role";
import { SegmentedControl, Tooltip } from "@mantine/core"; import { Indicator, SegmentedControl, Tooltip } from "@mantine/core";
import { cn } from "ui/lib/utils"; import { cn } from "ui/lib/utils";
import type { PolicySchema } from "auth/authorize/Policy";
export function AuthRolesEdit(props) { export function AuthRolesEdit(props) {
useBrowserTitle(["Auth", "Roles", props.params.role]); useBrowserTitle(["Auth", "Roles", props.params.role]);
@@ -66,21 +72,39 @@ function AuthRolesEditInternal({ params }) {
const { config, schema: authSchema, actions } = useBkndAuth(); const { config, schema: authSchema, actions } = useBkndAuth();
const roleName = params.role; const roleName = params.role;
const role = config.roles?.[roleName]; const role = config.roles?.[roleName];
const { readonly } = useBknd(); const { readonly, permissions } = useBknd();
const schema = getSchema(authSchema); 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 handleDelete() {} async function handleDelete() {
async function handleUpdate(data: any) { const success = await actions.roles.delete(roleName);
console.log("data", data); if (success) {
const success = await actions.roles.patch(roleName, data);
console.log("success", success);
/* if (success) {
navigate(routes.auth.roles.list()); navigate(routes.auth.roles.list());
} */ }
}
async function handleUpdate(data: any) {
await actions.roles.patch(roleName, data);
} }
return ( return (
<Form schema={schema as any} initialValues={role} {...formConfig} onSubmit={handleUpdate}> <Form
schema={schema as any}
initialValues={data}
{...formConfig}
beforeSubmit={(data) => {
return {
...data,
permissions: [...Object.values(data.permissions)],
};
}}
onSubmit={handleUpdate}
>
<AppShell.SectionHeader <AppShell.SectionHeader
right={ right={
<> <>
@@ -196,14 +220,21 @@ const Permissions = () => {
const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => { const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => {
const path = `permissions.${index}`; const path = `permissions.${index}`;
const { value } = useFormValue(path); const { value } = useDerivedFieldContext("permissions", (ctx) => {
const v = ctx.value;
if (!Array.isArray(v)) return undefined;
return v.find((v) => v && v.permission === permission.name);
});
const { setValue, deleteValue } = useFormContext(); const { setValue, deleteValue } = useFormContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const data = value as PermissionData | undefined; const data = value as PermissionData | undefined;
const policiesCount = data?.policies?.length ?? 0;
const hasContext = !!permission.context;
async function handleSwitch() { async function handleSwitch() {
if (data) { if (data) {
deleteValue(path); setValue(path, undefined);
setOpen(false);
} else { } else {
setValue(path, { setValue(path, {
permission: permission.name, permission: permission.name,
@@ -220,34 +251,125 @@ const Permission = ({ permission, index }: { permission: TPermission; index?: nu
className={cn("flex flex-col border border-muted", open && "border-primary/20")} 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={cn("flex flex-row gap-2 justify-between", open && "bg-primary/5")}>
<div className="py-4 px-4 font-mono leading-none">{permission.name}</div> <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="flex flex-row gap-1 items-center px-2">
<Formy.Switch size="sm" checked={!!data} onChange={handleSwitch} /> <div className="relative flex flex-row gap-1 items-center">
<Tooltip label="Customize" disabled> {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 <IconButton
size="md" size="md"
variant="ghost" variant="ghost"
disabled={!data} disabled={!data || !hasContext}
Icon={TbAdjustments} Icon={TbAdjustments}
className="disabled:opacity-20" className={cn("disabled:opacity-20", !hasContext && "!opacity-0")}
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
/> />
</Tooltip> </div>
<Formy.Switch size="sm" checked={!!data} onChange={handleSwitch} />
</div> </div>
</div> </div>
{open && ( {open && (
<div className="px-3.5 py-3.5"> <div className="px-3.5 py-3.5">
<ArrayField <Policies path={`permissions.${index}.policies`} permission={permission} />
{/* <ArrayField
path={`permissions.${index}.policies`} path={`permissions.${index}.policies`}
labelAdd="Add Policy" labelAdd="Add Policy"
wrapperProps={{ wrapperProps={{
label: false, label: false,
wrapper: "group", wrapper: "group",
}} }}
/> /> */}
</div> </div>
)} )}
</div> </div>
</> </>
); );
}; };
const Policies = ({ path, permission }: { path: string; permission: TPermission }) => {
const { value: _value } = useFormValue(path);
const { setValue, schema: policySchema, lib, deleteValue } = useDerivedFieldContext(path);
const value = _value ?? [];
function handleAdd() {
setValue(
`${path}.${value.length}`,
lib.getTemplate(undefined, policySchema!.items, {
addOptionalProps: true,
}),
);
}
function handleDelete(index: number) {
deleteValue(`${path}.${index}`);
}
return (
<div className={cn("flex flex-col", value.length > 0 && "gap-8")}>
<div className="flex flex-col gap-5">
{value.map((policy, 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 Policy = ({
permission,
}: {
permission: TPermission;
}) => {
const { value } = useFormValue("");
return (
<div className="flex flex-col gap-2">
<Field name="description" />
<ObjectField path="condition" wrapperProps={{ wrapper: "group" }} />
<CustomField path="effect">
{({ value, setValue }) => (
<FieldWrapper name="effect" label="Effect">
<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" && (
<ObjectField path="filter" wrapperProps={{ wrapper: "group" }} />
)}
</div>
);
};

View File

@@ -35,6 +35,9 @@ function AuthRolesListInternal() {
transformObject(config.roles ?? {}, (role, name) => ({ transformObject(config.roles ?? {}, (role, name) => ({
role: name, role: name,
permissions: role.permissions?.map((p) => p.permission) as string[], 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, is_default: role.is_default ?? false,
implicit_allow: role.implicit_allow ?? false, implicit_allow: role.implicit_allow ?? false,
})), })),
@@ -107,6 +110,9 @@ const renderValue = ({ value, property }) => {
if (["is_default", "implicit_allow"].includes(property)) { if (["is_default", "implicit_allow"].includes(property)) {
return value ? <span>Yes</span> : <span className="opacity-50">No</span>; 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") { if (property === "permissions") {
const max = 3; const max = 3;

View File

@@ -15,7 +15,7 @@
}, },
"app": { "app": {
"name": "bknd", "name": "bknd",
"version": "0.18.0-rc.6", "version": "0.18.1",
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
@@ -35,7 +35,7 @@
"hono": "4.8.3", "hono": "4.8.3",
"json-schema-library": "10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.8.4", "jsonv-ts": "0.8.6",
"kysely": "0.27.6", "kysely": "0.27.6",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "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/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.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -2529,7 +2529,7 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], "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.8.6", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-z5jJ017LFOvAFFVodAIiCY024yW72RWc/K0Sct+OtuiLN+lKy+g0pI0jaz5JmuXaMIePc6HyopeeYHi8ffbYhw=="],
"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=="], "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/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=="], "@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=="], "@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=="], "@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.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
"@typescript-eslint/experimental-utils/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="], "@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=="], "@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=="], "@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=="], "@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=="],