mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add per-tool permission model with category and per-tool modes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user