From 170ea2c45b687064d3a678b2f6238a8a32a24ee3 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 7 Aug 2025 15:20:29 +0200 Subject: [PATCH] added additional permissions, implemented mcp authentication --- .cursor/mcp.json | 10 ++++ app/package.json | 2 +- app/src/auth/api/AuthController.ts | 8 ++++ app/src/auth/auth-permissions.ts | 3 ++ app/src/auth/authenticate/Authenticator.ts | 55 +++++++++++++--------- app/src/cli/commands/mcp/mcp.ts | 25 +++++++--- app/src/cli/commands/user.ts | 4 ++ app/src/data/api/DataController.ts | 2 - app/src/data/data-schema.ts | 8 ++-- app/src/data/entities/Entity.ts | 19 ++++---- app/src/modules/Controller.ts | 16 +------ app/src/modules/Module.ts | 7 ++- app/src/modules/ModuleHelper.ts | 22 ++++++++- app/src/modules/ModuleManager.ts | 5 ++ app/src/modules/server/SystemController.ts | 28 +++++++---- bun.lock | 4 +- 16 files changed, 144 insertions(+), 74 deletions(-) create mode 100644 .cursor/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..064ec5c --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "bknd": { + "url": "http://localhost:3000/mcp", + "headers": { + "API_KEY": "value" + } + } + } +} diff --git a/app/package.json b/app/package.json index 8c7f6ab..3ef908d 100644 --- a/app/package.json +++ b/app/package.json @@ -64,7 +64,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.6.0", + "jsonv-ts": "0.6.2", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index f704c01..d54d1e2 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -236,6 +236,8 @@ export class AuthController extends Controller { }), }, async (params, c) => { + await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c); + return c.json(await this.auth.createUser(params)); }, ); @@ -251,6 +253,8 @@ export class AuthController extends Controller { }), }, async (params, c) => { + await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c); + const user = await getUser(params); return c.json({ user, token: await this.auth.authenticator.jwt(user) }); }, @@ -268,6 +272,8 @@ export class AuthController extends Controller { }), }, async (params, c) => { + await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c); + const user = await getUser(params); if (!(await this.auth.changePassword(user.id, params.password))) { throw new Error("Failed to change password"); @@ -287,6 +293,8 @@ export class AuthController extends Controller { }), }, async (params, c) => { + await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c); + const pw = this.auth.authenticator.strategy("password") as PasswordStrategy; const controller = pw.getController(this.auth.authenticator); diff --git a/app/src/auth/auth-permissions.ts b/app/src/auth/auth-permissions.ts index ed57c50..8b097e7 100644 --- a/app/src/auth/auth-permissions.ts +++ b/app/src/auth/auth-permissions.ts @@ -2,3 +2,6 @@ import { Permission } from "core/security/Permission"; export const createUser = new Permission("auth.user.create"); //export const updateUser = new Permission("auth.user.update"); +export const testPassword = new Permission("auth.user.password.test"); +export const changePassword = new Permission("auth.user.password.change"); +export const createToken = new Permission("auth.user.token.create"); diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index e775493..6c24c93 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -42,27 +42,25 @@ export interface UserPool { } const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds -export const cookieConfig = $object("config_auth_cookie", { - path: s.string({ default: "/" }), - sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), - secure: s.boolean({ default: true }), - httpOnly: s.boolean({ default: true }), - expires: s.number({ default: defaultCookieExpires }), // seconds - partitioned: s.boolean({ default: false }), - renew: s.boolean({ default: true }), - pathSuccess: s.string({ default: "/" }), - pathLoggedOut: s.string({ default: "/" }), -}) - .partial() - .strict(); +export const cookieConfig = s + .strictObject({ + path: s.string({ default: "/" }), + sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), + secure: s.boolean({ default: true }), + httpOnly: s.boolean({ default: true }), + expires: s.number({ default: defaultCookieExpires }), // seconds + partitioned: s.boolean({ default: false }), + renew: s.boolean({ default: true }), + pathSuccess: s.string({ default: "/" }), + pathLoggedOut: s.string({ default: "/" }), + }) + .partial(); // @todo: maybe add a config to not allow cookie/api tokens to be used interchangably? // see auth.integration test for further details -export const jwtConfig = $object( - "config_auth_jwt", +export const jwtConfig = s.strictObject( { - // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth secret: secret({ default: "" }), alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(), expires: s.number().optional(), // seconds @@ -72,7 +70,7 @@ export const jwtConfig = $object( { default: {}, }, -).strict(); +); export const authenticatorConfig = s.object({ jwt: jwtConfig, @@ -378,13 +376,24 @@ export class Authenticator< } // @todo: don't extract user from token, but from the database or cache - async resolveAuthFromRequest(c: Context): Promise { - let token: string | undefined; - if (c.req.raw.headers.has("Authorization")) { - const bearerHeader = String(c.req.header("Authorization")); - token = bearerHeader.replace("Bearer ", ""); + async resolveAuthFromRequest(c: Context | Request | Headers): Promise { + let headers: Headers; + let is_context = false; + if (c instanceof Headers) { + headers = c; + } else if (c instanceof Request) { + headers = c.headers; } else { - token = await this.getAuthCookie(c); + is_context = true; + headers = c.req.raw.headers; + } + + let token: string | undefined; + if (headers.has("Authorization")) { + const bearerHeader = String(headers.get("Authorization")); + token = bearerHeader.replace("Bearer ", ""); + } else if (is_context) { + token = await this.getAuthCookie(c as Context); } if (token) { diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts index 87f1a76..1b2c424 100644 --- a/app/src/cli/commands/mcp/mcp.ts +++ b/app/src/cli/commands/mcp/mcp.ts @@ -11,6 +11,9 @@ export const mcp: CliCommand = (program) => program .command("mcp") .description("mcp server") + .option("--verbose", "verbose output") + .option("--config ", "config file") + .option("--db-url ", "database url, can be any valid sqlite url") .option("--port ", "port to listen on", "3000") .option("--path ", "path to listen on", "/mcp") .option( @@ -21,12 +24,17 @@ export const mcp: CliCommand = (program) => .action(action); async function action(options: { + verbose?: boolean; + config?: string; + dbUrl?: string; port?: string; path?: string; token?: string; logLevel?: string; }) { const app = await makeAppFromEnv({ + config: options.config, + dbUrl: options.dbUrl, server: "node", }); @@ -83,11 +91,14 @@ async function action(options: { fetch: hono.fetch, port: Number(options.port) || 3000, }); - console.info(`Server is running on http://localhost:${options.port}${options.path}`); - console.info( - `⚙️ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`, - ); - console.info( - `📚 Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`, - ); + + if (options.verbose) { + console.info(`Server is running on http://localhost:${options.port}${options.path}`); + console.info( + `⚙️ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`, + ); + console.info( + `📚 Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`, + ); + } } diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 811f710..9920ef9 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -20,11 +20,15 @@ export const user: CliCommand = (program) => { .addArgument( new Argument("", "action to perform").choices(["create", "update", "token"]), ) + .option("--config ", "config file") + .option("--db-url ", "database url, can be any valid sqlite url") .action(action); }; async function action(action: "create" | "update" | "token", options: any) { const app = await makeAppFromEnv({ + config: options.config, + dbUrl: options.dbUrl, server: "node", }); diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index c905e5a..5b04a8f 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -78,9 +78,7 @@ export class DataController extends Controller { ), async (c) => { const { force, drop } = c.req.valid("query"); - //console.log("force", force); const tables = await this.em.schema().introspect(); - //console.log("tables", tables); const changes = await this.em.schema().sync({ force, drop, diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index 1aba102..6394ff9 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -29,15 +29,15 @@ export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => { ); }); export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject)); -export const entityFields = s.record(fieldsSchema); +export const entityFields = s.record(fieldsSchema, { default: {} }); export type TAppDataField = s.Static; export type TAppDataEntityFields = s.Static; export const entitiesSchema = s.strictObject({ name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI) - type: s.string({ enum: entityTypes, default: "regular" }), - config: entityConfigSchema, - fields: entityFields, + type: s.string({ enum: entityTypes, default: "regular" }).optional(), + config: entityConfigSchema.optional(), + fields: entityFields.optional(), }); export type TAppDataEntity = s.Static; diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index 10612b5..fcbe092 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -10,14 +10,17 @@ import { // @todo: entity must be migrated to typebox export const entityConfigSchema = s - .strictObject({ - name: s.string(), - name_singular: s.string(), - description: s.string(), - sort_field: s.string({ default: config.data.default_primary_field }), - sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), - primary_format: s.string({ enum: primaryFieldTypes }), - }) + .strictObject( + { + name: s.string(), + name_singular: s.string(), + description: s.string(), + sort_field: s.string({ default: config.data.default_primary_field }), + sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), + primary_format: s.string({ enum: primaryFieldTypes }), + }, + { default: {} }, + ) .partial(); export type EntityConfig = s.Static; diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index 3b8bd1d..db9f3d8 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -1,4 +1,4 @@ -import type { App, SafeUser } from "bknd"; +import type { App, Permission, SafeUser } from "bknd"; import { type Context, type Env, Hono } from "hono"; import * as middlewares from "modules/middlewares"; import type { EntityManager } from "data/entities"; @@ -19,20 +19,6 @@ export interface ServerEnv extends Env { [key: string]: any; } -/* export type ServerEnv = Env & { - Variables: { - app: App; - // to prevent resolving auth multiple times - auth?: { - resolved: boolean; - registered: boolean; - skip: boolean; - user?: SafeUser; - }; - html?: string; - }; -}; */ - export class Controller { protected middlewares = middlewares; diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index f5d855c..f4b610d 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -1,3 +1,4 @@ +import type { App } from "bknd"; import type { EventManager } from "core/events"; import type { Connection } from "data/connection"; import type { EntityManager } from "data/entities"; @@ -11,6 +12,10 @@ import type { McpServer } from "bknd/utils"; type PartialRec = { [P in keyof T]?: PartialRec }; +export type ModuleBuildContextMcpContext = { + app: App; + ctx: () => ModuleBuildContext; +}; export type ModuleBuildContext = { connection: Connection; server: Hono; @@ -20,7 +25,7 @@ export type ModuleBuildContext = { logger: DebugLogger; flags: (typeof Module)["ctx_flags"]; helper: ModuleHelper; - mcp: McpServer<{ ctx: () => ModuleBuildContext }>; + mcp: McpServer; }; export abstract class Module { diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index 5bfd30d..652f076 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -3,8 +3,11 @@ import { Entity } from "data/entities"; import type { EntityIndex, Field } from "data/fields"; import { entityTypes } from "data/entities/Entity"; import { isEqual } from "lodash-es"; -import type { ModuleBuildContext } from "./Module"; +import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module"; import type { EntityRelation } from "data/relations"; +import type { Permission } from "core/security/Permission"; +import { Exception } from "core/errors"; +import { invariant } from "bknd/utils"; export class ModuleHelper { constructor(protected ctx: Omit) {} @@ -110,4 +113,21 @@ export class ModuleHelper { entity.__replaceField(name, newField); } + + async throwUnlessGranted( + permission: Permission | string, + c: { context: ModuleBuildContextMcpContext; request: Request }, + ) { + invariant(c.context.app, "app is not available in mcp context"); + invariant(c.request instanceof Request, "request is not available in mcp context"); + + const user = await c.context.app.module.auth.authenticator.resolveAuthFromRequest(c.request); + + if (!this.ctx.guard.granted(permission, user)) { + throw new Exception( + `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, + 403, + ); + } + } } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index c2026a0..0201639 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -273,6 +273,11 @@ export class ModuleManager { : new EntityManager([], this.connection, [], [], this.emgr); this.guard = new Guard(); this.mcp = new McpServer(undefined as any, { + app: new Proxy(this, { + get: () => { + throw new Error("app is not available in mcp context"); + }, + }) as any, ctx: () => this.ctx(), }); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 7d15dc6..c863317 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -354,15 +354,19 @@ export class SystemController extends Controller { const { mcp } = this.app.modules.ctx(); const { version, ...appConfig } = this.app.toJSON(); - mcp.resource("system_config", "bknd://system/config", (c) => - c.json(this.app.toJSON(), { + mcp.resource("system_config", "bknd://system/config", async (c) => { + await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c); + + return c.json(this.app.toJSON(), { title: "System Config", - }), - ) + }); + }) .resource( "system_config_module", "bknd://system/config/{module}", - (c, { module }) => { + async (c, { module }) => { + await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c); + const m = this.app.modules.get(module as any) as Module; return c.json(m.toJSON(), { title: `Config for ${module}`, @@ -372,15 +376,19 @@ export class SystemController extends Controller { list: Object.keys(appConfig), }, ) - .resource("system_schema", "bknd://system/schema", (c) => - c.json(this.app.getSchema(), { + .resource("system_schema", "bknd://system/schema", async (c) => { + await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); + + return c.json(this.app.getSchema(), { title: "System Schema", - }), - ) + }); + }) .resource( "system_schema_module", "bknd://system/schema/{module}", - (c, { module }) => { + async (c, { module }) => { + await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); + const m = this.app.modules.get(module as any); return c.json(m.getSchema().toJSON(), { title: `Schema for ${module}`, diff --git a/bun.lock b/bun.lock index 670c067..2e0d4ed 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.6.0", + "jsonv-ts": "0.6.2", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2511,7 +2511,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.6.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-U9u2Gtv5NyYwMvSHPcOwd9Qgg4cUFPepI5vTkwU7Ib01ipEqaRrM/AM+REEbYqiI6LX+7YF+HcMbgCIHkUMhSQ=="], + "jsonv-ts": ["jsonv-ts@0.6.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-v4rIs0h7hoPiadotSNoLCvKyitUtboizbeydQjfBHb1HJG5ADda+BpNeRFGEGtq0m8405UmpEJ9l2Kt9J4SItQ=="], "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=="],