mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
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:
@@ -61,7 +61,9 @@ export class AuthController extends Controller {
|
||||
hono.post(
|
||||
"/create",
|
||||
permission(AuthPermissions.createUser, {}),
|
||||
permission(DataPermissions.entityCreate, {}),
|
||||
permission(DataPermissions.entityCreate, {
|
||||
context: (c) => ({ entity: this.auth.config.entity_name }),
|
||||
}),
|
||||
describeRoute({
|
||||
summary: "Create a new user",
|
||||
tags: ["auth"],
|
||||
@@ -224,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",
|
||||
@@ -246,7 +247,6 @@ export class AuthController extends Controller {
|
||||
);
|
||||
|
||||
mcp.tool(
|
||||
// @todo: needs permission
|
||||
"auth_user_token",
|
||||
{
|
||||
description: "Get a user token",
|
||||
@@ -264,7 +264,6 @@ export class AuthController extends Controller {
|
||||
);
|
||||
|
||||
mcp.tool(
|
||||
// @todo: needs permission
|
||||
"auth_user_password_change",
|
||||
{
|
||||
description: "Change a user's password",
|
||||
@@ -286,7 +285,6 @@ export class AuthController extends Controller {
|
||||
);
|
||||
|
||||
mcp.tool(
|
||||
// @todo: needs permission
|
||||
"auth_user_password_test",
|
||||
{
|
||||
description: "Test a user's password",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Context } from "hono";
|
||||
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,
|
||||
c: GuardContext,
|
||||
context: PermissionContext<P>,
|
||||
): PolicySchema["filter"] | undefined;
|
||||
getPolicyFilter<P extends Permission<any, any, undefined, any>>(
|
||||
permission: P,
|
||||
c: GuardContext,
|
||||
): PolicySchema["filter"] | undefined;
|
||||
getPolicyFilter<P extends Permission<any, any, any, any>>(
|
||||
);
|
||||
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>,
|
||||
): PolicySchema["filter"] | undefined {
|
||||
) {
|
||||
if (!permission.isFilterable()) {
|
||||
$console.debug("getPolicyFilter: permission is not filterable, returning undefined");
|
||||
return;
|
||||
throw new GuardPermissionsException(permission, undefined, "Permission is not filterable");
|
||||
}
|
||||
|
||||
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
|
||||
let ctx = Object.assign({}, _ctx);
|
||||
let ctx = Object.assign(
|
||||
{
|
||||
user,
|
||||
},
|
||||
_ctx,
|
||||
);
|
||||
|
||||
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) {
|
||||
for (const policy of rolePermission.policies) {
|
||||
if (policy.content.effect === "filter") {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export class Permission<
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -21,8 +21,15 @@ export class Policy<Schema extends PolicySchema = PolicySchema> {
|
||||
}) as Schema;
|
||||
}
|
||||
|
||||
replace(context: object, vars?: Record<string, any>) {
|
||||
return vars ? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars) : context;
|
||||
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>) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -517,6 +517,7 @@ 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
|
||||
@@ -524,24 +525,28 @@ export function recursivelyReplacePlaceholders(
|
||||
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);
|
||||
return value !== undefined ? value : obj;
|
||||
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);
|
||||
const value = getPath(variables, key, null);
|
||||
// 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)) {
|
||||
return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables));
|
||||
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);
|
||||
acc[key] = recursivelyReplacePlaceholders(value, pattern, variables, fallback);
|
||||
return acc;
|
||||
}, {} as object);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
pickKeys,
|
||||
mcpTool,
|
||||
convertNumberedObjectToArray,
|
||||
mergeObject,
|
||||
} from "bknd/utils";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import type { AppDataConfig } from "../data-schema";
|
||||
@@ -95,7 +96,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"],
|
||||
@@ -121,7 +124,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"],
|
||||
@@ -161,7 +166,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"],
|
||||
@@ -213,7 +220,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"],
|
||||
@@ -236,7 +245,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"],
|
||||
@@ -285,16 +296,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 });
|
||||
},
|
||||
@@ -308,7 +329,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 }),
|
||||
@@ -326,11 +349,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 });
|
||||
},
|
||||
@@ -344,7 +375,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({
|
||||
@@ -361,9 +394,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 });
|
||||
},
|
||||
@@ -390,7 +434,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 }),
|
||||
@@ -405,7 +451,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 });
|
||||
},
|
||||
@@ -421,7 +473,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({}))])),
|
||||
@@ -438,6 +492,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);
|
||||
@@ -455,7 +515,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 }),
|
||||
@@ -482,7 +544,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);
|
||||
},
|
||||
@@ -495,7 +560,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({})),
|
||||
@@ -505,6 +572,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);
|
||||
@@ -518,7 +596,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) => {
|
||||
@@ -526,6 +606,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);
|
||||
@@ -539,7 +631,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 }),
|
||||
@@ -554,7 +648,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);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,9 +1,51 @@
|
||||
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");
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useBknd } from "ui/client/bknd";
|
||||
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 { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import { isDebug } from "core/env";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
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 { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||
import { routes } from "ui/lib/routes";
|
||||
@@ -18,17 +18,23 @@ import { ucFirst, type s } from "bknd/utils";
|
||||
import type { ModuleSchemas } from "bknd";
|
||||
import {
|
||||
ArrayField,
|
||||
CustomField,
|
||||
Field,
|
||||
FieldWrapper,
|
||||
Form,
|
||||
FormContextOverride,
|
||||
FormDebug,
|
||||
ObjectField,
|
||||
Subscribe,
|
||||
useDerivedFieldContext,
|
||||
useFormContext,
|
||||
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 { Indicator, SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { cn } from "ui/lib/utils";
|
||||
import type { PolicySchema } from "auth/authorize/Policy";
|
||||
|
||||
export function AuthRolesEdit(props) {
|
||||
useBrowserTitle(["Auth", "Roles", props.params.role]);
|
||||
@@ -66,21 +72,39 @@ function AuthRolesEditInternal({ params }) {
|
||||
const { config, schema: authSchema, actions } = useBkndAuth();
|
||||
const roleName = params.role;
|
||||
const role = config.roles?.[roleName];
|
||||
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 handleDelete() {}
|
||||
async function handleUpdate(data: any) {
|
||||
console.log("data", data);
|
||||
const success = await actions.roles.patch(roleName, data);
|
||||
console.log("success", success);
|
||||
/* if (success) {
|
||||
async function handleDelete() {
|
||||
const success = await actions.roles.delete(roleName);
|
||||
if (success) {
|
||||
navigate(routes.auth.roles.list());
|
||||
} */
|
||||
}
|
||||
}
|
||||
async function handleUpdate(data: any) {
|
||||
await actions.roles.patch(roleName, data);
|
||||
}
|
||||
|
||||
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
|
||||
right={
|
||||
<>
|
||||
@@ -196,14 +220,21 @@ const Permissions = () => {
|
||||
|
||||
const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => {
|
||||
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 [open, setOpen] = useState(false);
|
||||
const data = value as PermissionData | undefined;
|
||||
const policiesCount = data?.policies?.length ?? 0;
|
||||
const hasContext = !!permission.context;
|
||||
|
||||
async function handleSwitch() {
|
||||
if (data) {
|
||||
deleteValue(path);
|
||||
setValue(path, undefined);
|
||||
setOpen(false);
|
||||
} else {
|
||||
setValue(path, {
|
||||
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")}
|
||||
>
|
||||
<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">
|
||||
<Formy.Switch size="sm" checked={!!data} onChange={handleSwitch} />
|
||||
<Tooltip label="Customize" disabled>
|
||||
<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={!data}
|
||||
disabled={!data || !hasContext}
|
||||
Icon={TbAdjustments}
|
||||
className="disabled:opacity-20"
|
||||
className={cn("disabled:opacity-20", !hasContext && "!opacity-0")}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Formy.Switch size="sm" checked={!!data} onChange={handleSwitch} />
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-3.5 py-3.5">
|
||||
<ArrayField
|
||||
<Policies path={`permissions.${index}.policies`} permission={permission} />
|
||||
{/* <ArrayField
|
||||
path={`permissions.${index}.policies`}
|
||||
labelAdd="Add Policy"
|
||||
wrapperProps={{
|
||||
label: false,
|
||||
wrapper: "group",
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,9 @@ function AuthRolesListInternal() {
|
||||
transformObject(config.roles ?? {}, (role, name) => ({
|
||||
role: name,
|
||||
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,
|
||||
})),
|
||||
@@ -107,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;
|
||||
|
||||
Reference in New Issue
Block a user