feat: add per-tool permission model with category and per-tool modes

This commit is contained in:
SnapOtter
2026-06-13 22:11:03 +08:00
parent 3cc4ef6895
commit 9fa23f4543
3 changed files with 73 additions and 2 deletions
+42
View File
@@ -86,6 +86,48 @@ export function requirePermission(
}; };
} }
export async function hasToolAccess(role: string, toolId: string): Promise<boolean> {
// 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( export async function requireOwnershipOrPermission(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply, reply: FastifyReply,
+22 -2
View File
@@ -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({ const createRoleSchema = z.object({
name: roleNameField, name: roleNameField,
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
permissions: z.array(z.string()).min(1, "At least one permission is required"), permissions: z.array(z.string()).min(1, "At least one permission is required"),
toolPermissions: toolPermissionsSchema,
}); });
const updateRoleSchema = z.object({ const updateRoleSchema = z.object({
name: roleNameField.optional(), name: roleNameField.optional(),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
permissions: z.array(z.string()).optional(), permissions: z.array(z.string()).optional(),
toolPermissions: toolPermissionsSchema,
}); });
export async function rolesRoutes(app: FastifyInstance): Promise<void> { export async function rolesRoutes(app: FastifyInstance): Promise<void> {
@@ -72,6 +82,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
name: r.name, name: r.name,
description: r.description, description: r.description,
permissions: r.permissions, permissions: r.permissions,
toolPermissions: r.toolPermissions ?? null,
isBuiltin: r.isBuiltin, isBuiltin: r.isBuiltin,
userCount: countMap.get(r.name) ?? 0, userCount: countMap.get(r.name) ?? 0,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
@@ -92,7 +103,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
code: "VALIDATION_ERROR", 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)); const invalid = permissions.filter((p) => !ALL_PERMISSIONS.includes(p as Permission));
if (invalid.length > 0) { if (invalid.length > 0) {
@@ -112,17 +123,23 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
name, name,
description: description?.trim() ?? "", description: description?.trim() ?? "",
permissions, permissions,
toolPermissions: toolPermissions ?? null,
isBuiltin: false, isBuiltin: false,
createdBy: user.id, 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({ return reply.status(201).send({
id, id,
name, name,
description: description?.trim() ?? "", description: description?.trim() ?? "",
permissions, permissions,
toolPermissions: toolPermissions ?? null,
isBuiltin: false, isBuiltin: false,
}); });
}); });
@@ -175,6 +192,9 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
} }
updates.permissions = body.permissions; updates.permissions = body.permissions;
} }
if (body.toolPermissions !== undefined) {
updates.toolPermissions = body.toolPermissions ?? null;
}
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
if (body.name) { if (body.name) {
+9
View File
@@ -201,6 +201,15 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
`/api/v1/tools/${config.toolId}`, `/api/v1/tools/${config.toolId}`,
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => { 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 jobId = randomUUID();
const maxInputs = config.maxInputs ?? 1; const maxInputs = config.maxInputs ?? 1;
let filename = "image"; let filename = "image";