mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Merge remote-tracking branch 'origin/main' into feat/data-ui-fillable-visible
This commit is contained in:
@@ -8,6 +8,7 @@ export type BaseModuleApiOptions = {
|
||||
host: string;
|
||||
basepath?: string;
|
||||
token?: string;
|
||||
credentials?: RequestCredentials;
|
||||
headers?: Headers;
|
||||
token_transport?: "header" | "cookie" | "none";
|
||||
verbose?: boolean;
|
||||
@@ -106,6 +107,7 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
|
||||
|
||||
const request = new Request(url, {
|
||||
..._init,
|
||||
credentials: this.options.credentials,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { entityTypes } from "data/entities/Entity";
|
||||
import { isEqual } from "lodash-es";
|
||||
import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module";
|
||||
import type { EntityRelation } from "data/relations";
|
||||
import type { Permission } from "core/security/Permission";
|
||||
import type { Permission, PermissionContext } from "auth/authorize/Permission";
|
||||
import { Exception } from "core/errors";
|
||||
import { invariant, isPlainObject } from "bknd/utils";
|
||||
|
||||
@@ -114,10 +114,20 @@ export class ModuleHelper {
|
||||
entity.__replaceField(name, newField);
|
||||
}
|
||||
|
||||
async throwUnlessGranted(
|
||||
permission: Permission | string,
|
||||
async granted<P extends Permission<any, any, any, any>>(
|
||||
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
|
||||
) {
|
||||
permission: P,
|
||||
context: PermissionContext<P>,
|
||||
): Promise<void>;
|
||||
async granted<P extends Permission<any, any, undefined, any>>(
|
||||
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
|
||||
permission: P,
|
||||
): Promise<void>;
|
||||
async granted<P extends Permission<any, any, any, any>>(
|
||||
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
|
||||
permission: P,
|
||||
context?: PermissionContext<P>,
|
||||
): Promise<void> {
|
||||
invariant(c.context.app, "app is not available in mcp context");
|
||||
const auth = c.context.app.module.auth;
|
||||
if (!auth.enabled) return;
|
||||
@@ -127,12 +137,6 @@ export class ModuleHelper {
|
||||
}
|
||||
|
||||
const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any);
|
||||
|
||||
if (!this.ctx.guard.granted(permission, user)) {
|
||||
throw new Exception(
|
||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||
403,
|
||||
);
|
||||
}
|
||||
this.ctx.guard.granted(permission, user as any, context as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ConfigUpdateResponse } from "modules/server/SystemController";
|
||||
import { ModuleApi } from "./ModuleApi";
|
||||
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
|
||||
import type { TPermission } from "auth/authorize/Permission";
|
||||
|
||||
export type ApiSchemaResponse = {
|
||||
version: number;
|
||||
@@ -54,4 +55,8 @@ export class SystemApi extends ModuleApi<any> {
|
||||
removeConfig<Module extends ModuleKey>(module: Module, path: string) {
|
||||
return this.delete<ConfigUpdateResponse>(["config", "remove", module, path]);
|
||||
}
|
||||
|
||||
permissions() {
|
||||
return this.get<{ permissions: TPermission[]; context: object }>("permissions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ export class DbModuleManager extends ModuleManager {
|
||||
private readonly _booted_with?: "provided" | "partial";
|
||||
private _stable_configs: ModuleConfigs | undefined;
|
||||
|
||||
// config used when syncing database
|
||||
public buildSyncConfig: { force?: boolean; drop?: boolean } = { force: true };
|
||||
|
||||
constructor(connection: Connection, options?: Partial<ModuleManagerOptions>) {
|
||||
let initial = {} as InitialModuleConfigs;
|
||||
let booted_with = "partial" as any;
|
||||
@@ -393,7 +396,7 @@ export class DbModuleManager extends ModuleManager {
|
||||
|
||||
const version_before = this.version();
|
||||
const [_version, _configs] = await migrate(version_before, result.configs.json, {
|
||||
db: this.db
|
||||
db: this.db,
|
||||
});
|
||||
|
||||
this._version = _version;
|
||||
@@ -463,7 +466,7 @@ export class DbModuleManager extends ModuleManager {
|
||||
this.logger.log("db sync requested");
|
||||
|
||||
// sync db
|
||||
await ctx.em.schema().sync({ force: true });
|
||||
await ctx.em.schema().sync(this.buildSyncConfig);
|
||||
state.synced = true;
|
||||
|
||||
// save
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { auth, permission } from "auth/middlewares";
|
||||
export { auth } from "auth/middlewares/auth.middleware";
|
||||
export { permission } from "auth/middlewares/permission.middleware";
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
import { Permission } from "core/security/Permission";
|
||||
import { Permission } from "auth/authorize/Permission";
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const accessAdmin = new Permission("system.access.admin");
|
||||
export const accessApi = new Permission("system.access.api");
|
||||
export const configRead = new Permission("system.config.read");
|
||||
export const configReadSecrets = new Permission("system.config.read.secrets");
|
||||
export const configWrite = new Permission("system.config.write");
|
||||
export const schemaRead = new Permission("system.schema.read");
|
||||
export const configRead = new Permission(
|
||||
"system.config.read",
|
||||
{},
|
||||
s.object({
|
||||
module: s.string().optional(),
|
||||
}),
|
||||
);
|
||||
export const configReadSecrets = new Permission(
|
||||
"system.config.read.secrets",
|
||||
{},
|
||||
s.object({
|
||||
module: s.string().optional(),
|
||||
}),
|
||||
);
|
||||
export const configWrite = new Permission(
|
||||
"system.config.write",
|
||||
{},
|
||||
s.object({
|
||||
module: s.string().optional(),
|
||||
}),
|
||||
);
|
||||
export const schemaRead = new Permission(
|
||||
"system.schema.read",
|
||||
{},
|
||||
s.object({
|
||||
module: s.string().optional(),
|
||||
}),
|
||||
);
|
||||
export const build = new Permission("system.build");
|
||||
export const mcp = new Permission("system.mcp");
|
||||
|
||||
@@ -114,8 +114,9 @@ export class AdminController extends Controller {
|
||||
}),
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
onDenied: async (c) => {
|
||||
addFlashMessage(c, "You not allowed to read the schema", "warning");
|
||||
addFlashMessage(c, "You are not allowed to read the schema", "warning");
|
||||
},
|
||||
context: (c) => ({}),
|
||||
}),
|
||||
async (c) => {
|
||||
const obj: AdminBkndWindowContext = {
|
||||
@@ -139,17 +140,19 @@ export class AdminController extends Controller {
|
||||
}
|
||||
|
||||
if (auth_enabled) {
|
||||
const options = {
|
||||
onGranted: async (c) => {
|
||||
// @todo: add strict test to permissions middleware?
|
||||
if (c.get("auth")?.user) {
|
||||
$console.log("redirecting to success");
|
||||
return c.redirect(authRoutes.success);
|
||||
}
|
||||
},
|
||||
context: (c) => ({}),
|
||||
};
|
||||
const redirectRouteParams = [
|
||||
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
|
||||
// @ts-ignore
|
||||
onGranted: async (c) => {
|
||||
// @todo: add strict test to permissions middleware?
|
||||
if (c.get("auth")?.user) {
|
||||
$console.log("redirecting to success");
|
||||
return c.redirect(authRoutes.success);
|
||||
}
|
||||
},
|
||||
}),
|
||||
permission(SystemPermissions.accessAdmin, options as any),
|
||||
permission(SystemPermissions.schemaRead, options),
|
||||
async (c) => {
|
||||
return c.html(c.get("html")!);
|
||||
},
|
||||
|
||||
@@ -52,11 +52,16 @@ export class AppServer extends Module<AppServerConfig> {
|
||||
}
|
||||
|
||||
override async build() {
|
||||
const origin = this.config.cors.origin ?? "";
|
||||
const origin = this.config.cors.origin ?? "*";
|
||||
const origins = origin.includes(",") ? origin.split(",").map((o) => o.trim()) : [origin];
|
||||
const all_origins = origins.includes("*");
|
||||
this.client.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: origin.includes(",") ? origin.split(",").map((o) => o.trim()) : origin,
|
||||
origin: (origin: string) => {
|
||||
if (all_origins) return origin;
|
||||
return origins.includes(origin) ? origin : undefined;
|
||||
},
|
||||
allowMethods: this.config.cors.allow_methods,
|
||||
allowHeaders: this.config.cors.allow_headers,
|
||||
credentials: this.config.cors.allow_credentials,
|
||||
@@ -87,6 +92,10 @@ export class AppServer extends Module<AppServerConfig> {
|
||||
}
|
||||
|
||||
if (err instanceof AuthException) {
|
||||
if (isDebug()) {
|
||||
return c.json(err.toJSON(), err.code);
|
||||
}
|
||||
|
||||
return c.json(err.toJSON(), err.getSafeErrorAndCode().code);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
mcp as mcpMiddleware,
|
||||
isNode,
|
||||
type McpServer,
|
||||
threw,
|
||||
} from "bknd/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { Controller } from "modules/Controller";
|
||||
@@ -32,6 +33,7 @@ import { getVersion } from "core/env";
|
||||
import type { Module } from "modules/Module";
|
||||
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||
import type { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import type { TPermission } from "auth/authorize/Permission";
|
||||
|
||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||
success: true;
|
||||
@@ -46,7 +48,8 @@ export type SchemaResponse = {
|
||||
schema: ModuleSchemas;
|
||||
readonly: boolean;
|
||||
config: ModuleConfigs;
|
||||
permissions: string[];
|
||||
//permissions: string[];
|
||||
permissions: TPermission[];
|
||||
};
|
||||
|
||||
export class SystemController extends Controller {
|
||||
@@ -67,10 +70,14 @@ export class SystemController extends Controller {
|
||||
if (!config.mcp.enabled) {
|
||||
return;
|
||||
}
|
||||
const { permission, auth } = this.middlewares;
|
||||
|
||||
this.registerMcp();
|
||||
|
||||
app.server.use(
|
||||
app.server.all(
|
||||
config.mcp.path,
|
||||
auth(),
|
||||
permission(SystemPermissions.mcp, {}),
|
||||
mcpMiddleware({
|
||||
setup: async () => {
|
||||
if (!this._mcpServer) {
|
||||
@@ -108,7 +115,6 @@ export class SystemController extends Controller {
|
||||
explainEndpoint: true,
|
||||
},
|
||||
endpoint: {
|
||||
path: config.mcp.path as any,
|
||||
// @ts-ignore
|
||||
_init: isNode() ? { duplex: "half" } : {},
|
||||
},
|
||||
@@ -119,7 +125,7 @@ export class SystemController extends Controller {
|
||||
private registerConfigController(client: Hono<any>): void {
|
||||
const { permission } = this.middlewares;
|
||||
// don't add auth again, it's already added in getController
|
||||
const hono = this.create().use(permission(SystemPermissions.configRead));
|
||||
const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
|
||||
|
||||
if (!this.app.isReadOnly()) {
|
||||
const manager = this.app.modules as DbModuleManager;
|
||||
@@ -130,7 +136,11 @@ export class SystemController extends Controller {
|
||||
summary: "Get the raw config",
|
||||
tags: ["system"],
|
||||
}),
|
||||
permission([SystemPermissions.configReadSecrets]),
|
||||
permission(SystemPermissions.configReadSecrets, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @ts-expect-error "fetch" is private
|
||||
return c.json(await this.app.modules.fetch().then((r) => r?.configs));
|
||||
@@ -165,7 +175,11 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.post(
|
||||
"/set/:module",
|
||||
permission(SystemPermissions.configWrite),
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
|
||||
async (c) => {
|
||||
const module = c.req.param("module") as any;
|
||||
@@ -194,32 +208,44 @@ export class SystemController extends Controller {
|
||||
},
|
||||
);
|
||||
|
||||
hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
const value = await c.req.json();
|
||||
const path = c.req.param("path") as string;
|
||||
hono.post(
|
||||
"/add/:module/:path",
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
const value = await c.req.json();
|
||||
const path = c.req.param("path") as string;
|
||||
|
||||
if (this.app.modules.get(module).schema().has(path)) {
|
||||
return c.json(
|
||||
{ success: false, path, error: "Path already exists" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (this.app.modules.get(module).schema().has(path)) {
|
||||
return c.json(
|
||||
{ success: false, path, error: "Path already exists" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return await handleConfigUpdateResponse(c, async () => {
|
||||
await manager.mutateConfigSafe(module).patch(path, value);
|
||||
return {
|
||||
success: true,
|
||||
module,
|
||||
config: this.app.module[module].config,
|
||||
};
|
||||
});
|
||||
});
|
||||
return await handleConfigUpdateResponse(c, async () => {
|
||||
await manager.mutateConfigSafe(module).patch(path, value);
|
||||
return {
|
||||
success: true,
|
||||
module,
|
||||
config: this.app.module[module].config,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
hono.patch(
|
||||
"/patch/:module/:path",
|
||||
permission(SystemPermissions.configWrite),
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
@@ -239,7 +265,11 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.put(
|
||||
"/overwrite/:module/:path",
|
||||
permission(SystemPermissions.configWrite),
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
@@ -259,7 +289,11 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.delete(
|
||||
"/remove/:module/:path",
|
||||
permission(SystemPermissions.configWrite),
|
||||
permission(SystemPermissions.configWrite, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
@@ -295,7 +329,11 @@ export class SystemController extends Controller {
|
||||
const { secrets } = c.req.valid("query");
|
||||
const { module } = c.req.valid("param");
|
||||
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
||||
if (secrets) {
|
||||
this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
|
||||
module,
|
||||
});
|
||||
}
|
||||
|
||||
const config = this.app.toJSON(secrets);
|
||||
|
||||
@@ -326,7 +364,11 @@ export class SystemController extends Controller {
|
||||
summary: "Get the schema for a module",
|
||||
tags: ["system"],
|
||||
}),
|
||||
permission(SystemPermissions.schemaRead),
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
context: (c) => ({
|
||||
module: c.req.param("module"),
|
||||
}),
|
||||
}),
|
||||
jsc(
|
||||
"query",
|
||||
s
|
||||
@@ -340,10 +382,22 @@ export class SystemController extends Controller {
|
||||
async (c) => {
|
||||
const module = c.req.param("module") as ModuleKey | undefined;
|
||||
const { config, secrets, fresh } = c.req.valid("query");
|
||||
const readonly = this.app.isReadOnly();
|
||||
const readonly =
|
||||
// either if app is read only in general
|
||||
this.app.isReadOnly() ||
|
||||
// or if user is not allowed to modify the config
|
||||
threw(() => this.ctx.guard.granted(SystemPermissions.configWrite, c, { module }));
|
||||
|
||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
||||
if (config) {
|
||||
this.ctx.guard.granted(SystemPermissions.configRead, c, {
|
||||
module,
|
||||
});
|
||||
}
|
||||
if (secrets) {
|
||||
this.ctx.guard.granted(SystemPermissions.configReadSecrets, c, {
|
||||
module,
|
||||
});
|
||||
}
|
||||
|
||||
const { version, ...schema } = this.app.getSchema();
|
||||
|
||||
@@ -368,11 +422,23 @@ export class SystemController extends Controller {
|
||||
readonly,
|
||||
schema,
|
||||
config: config ? this.app.toJSON(secrets) : undefined,
|
||||
permissions: this.app.modules.ctx().guard.getPermissionNames(),
|
||||
permissions: this.app.modules.ctx().guard.getPermissions(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
hono.get(
|
||||
"/permissions",
|
||||
describeRoute({
|
||||
summary: "Get the permissions",
|
||||
tags: ["system"],
|
||||
}),
|
||||
(c) => {
|
||||
const permissions = this.app.modules.ctx().guard.getPermissions();
|
||||
return c.json({ permissions, context: this.app.module.auth.getGuardContextSchema() });
|
||||
},
|
||||
);
|
||||
|
||||
hono.post(
|
||||
"/build",
|
||||
describeRoute({
|
||||
@@ -383,7 +449,7 @@ export class SystemController extends Controller {
|
||||
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
|
||||
async (c) => {
|
||||
const options = c.req.valid("query") as Record<string, boolean>;
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
|
||||
this.ctx.guard.granted(SystemPermissions.build, c);
|
||||
|
||||
await this.app.build(options);
|
||||
return c.json({
|
||||
@@ -455,7 +521,7 @@ export class SystemController extends Controller {
|
||||
const { version, ...appConfig } = this.app.toJSON();
|
||||
|
||||
mcp.resource("system_config", "bknd://system/config", async (c) => {
|
||||
await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||
await c.context.ctx().helper.granted(c, SystemPermissions.configRead, {});
|
||||
|
||||
return c.json(this.app.toJSON(), {
|
||||
title: "System Config",
|
||||
@@ -465,7 +531,9 @@ export class SystemController extends Controller {
|
||||
"system_config_module",
|
||||
"bknd://system/config/{module}",
|
||||
async (c, { module }) => {
|
||||
await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||
await this.ctx.helper.granted(c, SystemPermissions.configRead, {
|
||||
module,
|
||||
});
|
||||
|
||||
const m = this.app.modules.get(module as any) as Module;
|
||||
return c.json(m.toJSON(), {
|
||||
@@ -477,7 +545,7 @@ export class SystemController extends Controller {
|
||||
},
|
||||
)
|
||||
.resource("system_schema", "bknd://system/schema", async (c) => {
|
||||
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
|
||||
await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {});
|
||||
|
||||
return c.json(this.app.getSchema(), {
|
||||
title: "System Schema",
|
||||
@@ -487,7 +555,9 @@ export class SystemController extends Controller {
|
||||
"system_schema_module",
|
||||
"bknd://system/schema/{module}",
|
||||
async (c, { module }) => {
|
||||
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
|
||||
await this.ctx.helper.granted(c, SystemPermissions.schemaRead, {
|
||||
module,
|
||||
});
|
||||
|
||||
const m = this.app.modules.get(module as any);
|
||||
return c.json(m.getSchema().toJSON(), {
|
||||
|
||||
Reference in New Issue
Block a user