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(
|
export async function requireOwnershipOrPermission(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
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({
|
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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user