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(
request: FastifyRequest,
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({
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<void> {
@@ -72,6 +82,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
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<void> {
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<void> {
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<void> {
}
updates.permissions = body.permissions;
}
if (body.toolPermissions !== undefined) {
updates.toolPermissions = body.toolPermissions ?? null;
}
await db.transaction(async (tx) => {
if (body.name) {
+9
View File
@@ -201,6 +201,15 @@ export function createToolRoute<T>(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";