import { randomUUID } from "node:crypto"; import type { Permission } from "@snapotter/shared"; import { eq, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db, schema } from "../db/index.js"; import { auditFromRequest } from "../lib/audit.js"; import { permissionsNotHeldBy, requirePermission } from "../permissions.js"; const ALL_PERMISSIONS: Permission[] = [ "tools:use", "files:own", "files:all", "apikeys:own", "apikeys:all", "pipelines:own", "pipelines:all", "settings:read", "settings:write", "users:manage", "teams:manage", "features:manage", "system:health", "audit:read", "compliance:manage", "webhooks:manage", "security:manage", ]; const roleNameField = z .string() .transform((v) => v.trim().toLowerCase()) .pipe( z .string() .min(2, "Role name must be 2-30 characters") .max(30, "Role name must be 2-30 characters") .regex( /^[a-z0-9_-]+$/, "Role name can only contain lowercase letters, numbers, hyphens, and underscores", ), ); 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 { // GET /api/v1/roles — List all roles (requires audit:read to view) app.get("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => { const user = await requirePermission("audit:read")(request, reply); if (!user) return; const roles = await db.select().from(schema.roles); const userCounts = await db .select({ role: schema.users.role, count: sql`COUNT(*)::int`, }) .from(schema.users) .groupBy(schema.users.role); const countMap = new Map(userCounts.map((r) => [r.role, r.count])); return reply.send({ roles: roles.map((r) => ({ id: r.id, 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(), updatedAt: r.updatedAt.toISOString(), })), }); }); // POST /api/v1/roles — Create custom role app.post("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => { const user = await requirePermission("security:manage")(request, reply); if (!user) return; const parsed = createRoleSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.issues.map((i) => i.message).join("; "), code: "VALIDATION_ERROR", }); } const { name, description, permissions, toolPermissions } = parsed.data; const invalid = permissions.filter((p) => !ALL_PERMISSIONS.includes(p as Permission)); if (invalid.length > 0) { return reply .status(400) .send({ error: `Invalid permissions: ${invalid.join(", ")}`, code: "VALIDATION_ERROR" }); } const broader = await permissionsNotHeldBy(user, permissions); if (broader.length > 0) { return reply.status(403).send({ error: `Cannot grant permissions you don't have: ${broader.join(", ")}`, code: "ESCALATION_DENIED", }); } const [existing] = await db.select().from(schema.roles).where(eq(schema.roles.name, name)); if (existing) { return reply.status(409).send({ error: "Role name already exists", code: "CONFLICT" }); } const id = randomUUID(); await db.insert(schema.roles).values({ id, 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, }); return reply.status(201).send({ id, name, description: description?.trim() ?? "", permissions, toolPermissions: toolPermissions ?? null, isBuiltin: false, }); }); // PUT /api/v1/roles/:id — Update custom role app.put( "/api/v1/roles/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const user = await requirePermission("security:manage")(request, reply); if (!user) return; const { id } = request.params; const [role] = await db.select().from(schema.roles).where(eq(schema.roles.id, id)); if (!role) { return reply.status(404).send({ error: "Role not found", code: "NOT_FOUND" }); } if (role.isBuiltin) { return reply .status(400) .send({ error: "Cannot modify built-in roles", code: "VALIDATION_ERROR" }); } const parsed = updateRoleSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ error: parsed.error.issues.map((i) => i.message).join("; "), code: "VALIDATION_ERROR", }); } const body = parsed.data; const updates: Record = { updatedAt: new Date() }; if (body.name) { const [dup] = await db.select().from(schema.roles).where(eq(schema.roles.name, body.name)); if (dup && dup.id !== id) { return reply.status(409).send({ error: "Role name already exists", code: "CONFLICT" }); } updates.name = body.name; } if (body.description !== undefined) { updates.description = body.description.trim(); } if (body.permissions) { const invalid = body.permissions.filter((p) => !ALL_PERMISSIONS.includes(p as Permission)); if (invalid.length > 0) { return reply.status(400).send({ error: `Invalid permissions: ${invalid.join(", ")}`, code: "VALIDATION_ERROR", }); } const broader = await permissionsNotHeldBy(user, body.permissions); if (broader.length > 0) { return reply.status(403).send({ error: `Cannot grant permissions you don't have: ${broader.join(", ")}`, code: "ESCALATION_DENIED", }); } updates.permissions = body.permissions; } if (body.toolPermissions !== undefined) { updates.toolPermissions = body.toolPermissions ?? null; } await db.transaction(async (tx) => { if (body.name) { await tx .update(schema.users) .set({ role: body.name }) .where(eq(schema.users.role, role.name)); } await tx.update(schema.roles).set(updates).where(eq(schema.roles.id, id)); }); await auditFromRequest(request)("ROLE_UPDATED", { adminId: user.id, roleId: id }); return reply.send({ ok: true }); }, ); // DELETE /api/v1/roles/:id — Delete custom role app.delete( "/api/v1/roles/:id", async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const user = await requirePermission("security:manage")(request, reply); if (!user) return; const { id } = request.params; const [role] = await db.select().from(schema.roles).where(eq(schema.roles.id, id)); if (!role) { return reply.status(404).send({ error: "Role not found", code: "NOT_FOUND" }); } if (role.isBuiltin) { return reply .status(400) .send({ error: "Cannot delete built-in roles", code: "VALIDATION_ERROR" }); } await db.transaction(async (tx) => { await tx .update(schema.users) .set({ role: "user", updatedAt: new Date() }) .where(eq(schema.users.role, role.name)); await tx.delete(schema.roles).where(eq(schema.roles.id, id)); }); await auditFromRequest(request)("ROLE_DELETED", { adminId: user.id, roleId: id, roleName: role.name, }); return reply.send({ ok: true }); }, ); app.log.info("Roles routes registered"); }