added additional permissions, implemented mcp authentication

This commit is contained in:
dswbx
2025-08-07 15:20:29 +02:00
parent 42db5f55c7
commit 170ea2c45b
16 changed files with 144 additions and 74 deletions

View File

@@ -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);

View File

@@ -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");

View File

@@ -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<SafeUser | undefined> {
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<SafeUser | undefined> {
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) {

View File

@@ -11,6 +11,9 @@ export const mcp: CliCommand = (program) =>
program
.command("mcp")
.description("mcp server")
.option("--verbose", "verbose output")
.option("--config <config>", "config file")
.option("--db-url <db>", "database url, can be any valid sqlite url")
.option("--port <port>", "port to listen on", "3000")
.option("--path <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")}`,
);
}
}

View File

@@ -20,11 +20,15 @@ export const user: CliCommand = (program) => {
.addArgument(
new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
)
.option("--config <config>", "config file")
.option("--db-url <db>", "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",
});

View File

@@ -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,

View File

@@ -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<typeof fieldsSchema>;
export type TAppDataEntityFields = s.Static<typeof entityFields>;
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<typeof entitiesSchema>;

View File

@@ -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<typeof entityConfigSchema>;

View File

@@ -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;

View File

@@ -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<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type ModuleBuildContextMcpContext = {
app: App;
ctx: () => ModuleBuildContext;
};
export type ModuleBuildContext = {
connection: Connection;
server: Hono<ServerEnv>;
@@ -20,7 +25,7 @@ export type ModuleBuildContext = {
logger: DebugLogger;
flags: (typeof Module)["ctx_flags"];
helper: ModuleHelper;
mcp: McpServer<{ ctx: () => ModuleBuildContext }>;
mcp: McpServer<ModuleBuildContextMcpContext>;
};
export abstract class Module<Schema extends object = object> {

View File

@@ -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<ModuleBuildContext, "helper">) {}
@@ -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,
);
}
}
}

View File

@@ -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(),
});
}

View File

@@ -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}`,