From 9fa23f454322808d8c791b9d26b6bdbd1a5e9e73 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 22:11:03 +0800 Subject: [PATCH] feat: add per-tool permission model with category and per-tool modes --- apps/api/src/permissions.ts | 42 +++++++++++++++++++++++++++++ apps/api/src/routes/roles.ts | 24 +++++++++++++++-- apps/api/src/routes/tool-factory.ts | 9 +++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/api/src/permissions.ts b/apps/api/src/permissions.ts index b67c4803..755db47f 100644 --- a/apps/api/src/permissions.ts +++ b/apps/api/src/permissions.ts @@ -86,6 +86,48 @@ export function requirePermission( }; } +export async function hasToolAccess(role: string, toolId: string): Promise { + // Built-in roles have no tool restrictions + if (role in ROLE_PERMISSIONS) return true; + + try { + const [roleRow] = await db + .select({ toolPermissions: schema.roles.toolPermissions }) + .from(schema.roles) + .where(eq(schema.roles.name, role)) + .limit(1); + + // Role not found or no toolPermissions configured -- allow all + if (!roleRow?.toolPermissions) return true; + + const tp = roleRow.toolPermissions; + + if (tp.mode === "category") { + const { TOOLS } = await import("@snapotter/shared"); + const tool = TOOLS.find((t) => t.id === toolId); + if (!tool) return false; + return tp.allowed.includes(tool.modality ?? tool.category); + } + + if (tp.mode === "tool") { + // Per-tool mode requires enterprise license + let isEnterprise = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + isEnterprise = isFeatureEnabled("per_tool_permissions"); + } catch {} + + if (!isEnterprise) return true; // Graceful degradation -- no enterprise = allow all + return tp.allowed.includes(toolId); + } + + return true; // Unknown mode = allow + } catch { + // DB not yet available during early startup + return true; + } +} + export async function requireOwnershipOrPermission( request: FastifyRequest, reply: FastifyReply, diff --git a/apps/api/src/routes/roles.ts b/apps/api/src/routes/roles.ts index d8a1258a..c78cb37c 100644 --- a/apps/api/src/routes/roles.ts +++ b/apps/api/src/routes/roles.ts @@ -38,16 +38,26 @@ const roleNameField = z ), ); +const toolPermissionsSchema = z + .object({ + mode: z.enum(["category", "tool"]), + allowed: z.array(z.string()), + }) + .nullable() + .optional(); + const createRoleSchema = z.object({ name: roleNameField, description: z.string().max(500).optional(), permissions: z.array(z.string()).min(1, "At least one permission is required"), + toolPermissions: toolPermissionsSchema, }); const updateRoleSchema = z.object({ name: roleNameField.optional(), description: z.string().max(500).optional(), permissions: z.array(z.string()).optional(), + toolPermissions: toolPermissionsSchema, }); export async function rolesRoutes(app: FastifyInstance): Promise { @@ -72,6 +82,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { name: r.name, description: r.description, permissions: r.permissions, + toolPermissions: r.toolPermissions ?? null, isBuiltin: r.isBuiltin, userCount: countMap.get(r.name) ?? 0, createdAt: r.createdAt.toISOString(), @@ -92,7 +103,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise { code: "VALIDATION_ERROR", }); } - const { name, description, permissions } = parsed.data; + const { name, description, permissions, toolPermissions } = parsed.data; const invalid = permissions.filter((p) => !ALL_PERMISSIONS.includes(p as Permission)); if (invalid.length > 0) { @@ -112,17 +123,23 @@ export async function rolesRoutes(app: FastifyInstance): Promise { name, description: description?.trim() ?? "", permissions, + toolPermissions: toolPermissions ?? null, isBuiltin: false, createdBy: user.id, }); - await auditFromRequest(request)("ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name }); + await auditFromRequest(request)("ROLE_CREATED", { + adminId: user.id, + roleId: id, + roleName: name, + }); return reply.status(201).send({ id, name, description: description?.trim() ?? "", permissions, + toolPermissions: toolPermissions ?? null, isBuiltin: false, }); }); @@ -175,6 +192,9 @@ export async function rolesRoutes(app: FastifyInstance): Promise { } updates.permissions = body.permissions; } + if (body.toolPermissions !== undefined) { + updates.toolPermissions = body.toolPermissions ?? null; + } await db.transaction(async (tx) => { if (body.name) { diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 17fc6f9f..587cffa7 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -201,6 +201,15 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig `/api/v1/tools/${config.toolId}`, { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (request: FastifyRequest, reply: FastifyReply) => { + // Check per-tool access before processing uploads + const authUser = getAuthUser(request); + if (authUser) { + const { hasToolAccess } = await import("../permissions.js"); + if (!(await hasToolAccess(authUser.role, config.toolId))) { + return reply.status(403).send({ error: "You don't have permission to use this tool" }); + } + } + const jobId = randomUUID(); const maxInputs = config.maxInputs ?? 1; let filename = "image";