feat: production-grade RBAC with editor role, custom roles, API key scoping, and audit log (#89)

* feat(rbac): add editor role, 3 new permissions, ownership helper

* feat(rbac): add audit_log table, apiKeys.permissions column, editor role to schema

* feat(rbac): wire requirePermission into all routes, add editor role support

* refactor(rbac): replace ad-hoc role checks with permission-based ownership

* feat(rbac): add audit log DB writes + query endpoint

Dual-write audit events to stdout (existing) and SQLite audit_log table.
Add GET /api/v1/audit-log with pagination, action filter, and date range
filtering, gated behind audit:read permission.

* feat(rbac): add API key permission scoping with ceiling enforcement

* feat(rbac): add escalation prevention and last-admin protection

* feat(rbac): add editor role to UI, API key permission scoping in settings

* test(rbac): add full permission matrix integration test

* test(rbac): add editor role E2E tests

* feat(rbac): add custom roles with CRUD API and DB-backed permission lookup

* feat(rbac): add API key expiration

* feat(rbac): add roles management UI and API key expiration to settings

* feat(rbac): add audit log UI to settings

* fix: remove any cast in API key permission validation

* test(rbac): add unit tests for username validation rules

* test(rbac): add unit tests for effective permissions and ownership

* test(rbac): add comprehensive route permission matrix (all routes × all roles)

* test(rbac): add auth route edge case tests (login failures, session expiry, password side effects)

* test(rbac): add escalation prevention tests (register, update, self-demote, last-admin)

* test(rbac): add ownership enforcement tests (files, pipelines, editor access, cross-user isolation)

* test(rbac): add API key edge cases (name validation, delete behavior, key revocation)

* test(rbac): add audit log edge cases (all events, pagination clamping, structure)

* test(rbac): add custom roles edge case tests (validation, CRUD, functional permissions)

* test(rbac): add comprehensive E2E tests (roles UI, audit log, custom role, API key scoping)
This commit is contained in:
Ashim
2026-04-22 18:10:04 +08:00
committed by GitHub
parent 2d7a61c18f
commit 5a45bcbc8f
40 changed files with 5054 additions and 87 deletions
+51 -9
View File
@@ -10,6 +10,7 @@ import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import { getPermissions, hasEffectivePermission } from "../permissions.js";
import { computeKeyPrefix, hashPassword, requireAuth } from "../plugins/auth.js";
export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
@@ -18,7 +19,11 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
const user = requireAuth(request, reply);
if (!user) return;
const body = request.body as { name?: string } | null;
const body = request.body as {
name?: string;
permissions?: string[];
expiresAt?: string;
} | null;
const name = body?.name?.trim() || "Default API Key";
if (name.length > 100) {
@@ -28,6 +33,36 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
});
}
let scopedPermissions: string[] | null = null;
if (Array.isArray(body?.permissions) && body.permissions.length > 0) {
const userPerms = getPermissions(user.role);
const permSet = new Set<string>(userPerms);
const invalid = body.permissions.filter((p: string) => !permSet.has(p));
if (invalid.length > 0) {
return reply.status(400).send({
error: `Cannot scope key with permissions you don't have: ${invalid.join(", ")}`,
code: "VALIDATION_ERROR",
});
}
scopedPermissions = body.permissions;
}
let expiresAt: Date | null = null;
if (body?.expiresAt) {
const parsed = new Date(body.expiresAt);
if (Number.isNaN(parsed.getTime())) {
return reply
.status(400)
.send({ error: "Invalid expiresAt date", code: "VALIDATION_ERROR" });
}
if (parsed <= new Date()) {
return reply
.status(400)
.send({ error: "expiresAt must be in the future", code: "VALIDATION_ERROR" });
}
expiresAt = parsed;
}
// Generate a raw API key: "si_" prefix + 48 random bytes as hex
const rawKey = `si_${randomBytes(48).toString("hex")}`;
const keyHash = await hashPassword(rawKey);
@@ -41,6 +76,8 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
keyHash,
keyPrefix,
name,
permissions: scopedPermissions ? JSON.stringify(scopedPermissions) : null,
expiresAt,
})
.run();
@@ -51,6 +88,8 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
id,
key: rawKey,
name,
permissions: scopedPermissions,
expiresAt: expiresAt?.toISOString() ?? null,
createdAt: new Date().toISOString(),
});
});
@@ -63,24 +102,27 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
const selectFields = {
id: schema.apiKeys.id,
name: schema.apiKeys.name,
permissions: schema.apiKeys.permissions,
createdAt: schema.apiKeys.createdAt,
lastUsedAt: schema.apiKeys.lastUsedAt,
expiresAt: schema.apiKeys.expiresAt,
};
const keys =
user.role === "admin"
? db.select(selectFields).from(schema.apiKeys).all()
: db
.select(selectFields)
.from(schema.apiKeys)
.where(eq(schema.apiKeys.userId, user.id))
.all();
const keys = hasEffectivePermission(user, "apikeys:all")
? db.select(selectFields).from(schema.apiKeys).all()
: db
.select(selectFields)
.from(schema.apiKeys)
.where(eq(schema.apiKeys.userId, user.id))
.all();
return reply.send({
apiKeys: keys.map((k) => ({
id: k.id,
name: k.name,
permissions: k.permissions ? JSON.parse(k.permissions) : null,
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
expiresAt: k.expiresAt?.toISOString() ?? null,
})),
});
});