mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
268 lines
8.5 KiB
TypeScript
268 lines
8.5 KiB
TypeScript
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<void> {
|
|
// 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<number>`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<string, unknown> = { 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");
|
|
}
|