mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -138,6 +138,9 @@ base.describe("RBAC - User sees restricted tabs", () => {
|
||||
await expect(page.getByRole("button", { name: /system settings/i })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /people/i })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /teams/i })).not.toBeVisible();
|
||||
|
||||
// Should NOT see editor-only tabs (requires settings:write)
|
||||
await expect(page.getByRole("button", { name: /ai features/i })).not.toBeVisible();
|
||||
});
|
||||
|
||||
base.test("user role gets 403 on admin API endpoints", async ({ page }) => {
|
||||
@@ -163,3 +166,96 @@ base.describe("RBAC - User sees restricted tabs", () => {
|
||||
expect(settingsRes.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Editor sees collaborative tabs ─────────────────────────────────
|
||||
|
||||
base.describe("RBAC - Editor sees collaborative tabs", () => {
|
||||
let adminToken: string;
|
||||
|
||||
base.beforeAll(async () => {
|
||||
adminToken = await getAdminToken();
|
||||
// Create editor user
|
||||
const createRes = await fetch(`${API}/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: authJson(adminToken),
|
||||
body: JSON.stringify({
|
||||
username: "editortest",
|
||||
password: "EditorTest1",
|
||||
role: "editor",
|
||||
}),
|
||||
});
|
||||
if (createRes.status !== 201 && createRes.status !== 409) {
|
||||
throw new Error(`Failed to create editor user: ${createRes.status}`);
|
||||
}
|
||||
|
||||
// Clear mustChangePassword
|
||||
const loginRes = await fetch(`${API}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "editortest", password: "EditorTest1" }),
|
||||
});
|
||||
if (!loginRes.ok) throw new Error(`Editor login failed: ${loginRes.status}`);
|
||||
const loginData = await loginRes.json();
|
||||
await fetch(`${API}/api/auth/change-password`, {
|
||||
method: "POST",
|
||||
headers: authJson(loginData.token),
|
||||
body: JSON.stringify({
|
||||
currentPassword: "EditorTest1",
|
||||
newPassword: "EditorTest1",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
base.afterAll(async () => {
|
||||
const listRes = await fetch(`${API}/api/auth/users`, {
|
||||
headers: authOnly(adminToken),
|
||||
});
|
||||
if (!listRes.ok) return;
|
||||
const { users } = await listRes.json();
|
||||
const editor = users.find((u: { username: string }) => u.username === "editortest");
|
||||
if (editor) {
|
||||
await fetch(`${API}/api/auth/users/${editor.id}`, {
|
||||
method: "DELETE",
|
||||
headers: authOnly(adminToken),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
base.test(
|
||||
"editor sees general, security, api-keys, tools, about but not admin tabs",
|
||||
async ({ page }) => {
|
||||
await login(page, "editortest", "EditorTest1");
|
||||
await page.locator("aside").getByText("Settings").click();
|
||||
|
||||
// Should see these
|
||||
await expect(page.getByRole("button", { name: /general/i })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /security/i })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /api keys/i })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /tools/i })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /about/i })).toBeVisible();
|
||||
|
||||
// Should NOT see admin tabs
|
||||
await expect(page.getByRole("button", { name: /system settings/i })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /people/i })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /teams/i })).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
base.test("editor gets 403 on admin API endpoints", async ({ page }) => {
|
||||
await login(page, "editortest", "EditorTest1");
|
||||
const token = await page.evaluate(() => localStorage.getItem("ashim-token"));
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
const usersRes = await fetch(`${API}/api/auth/users`, {
|
||||
headers: authOnly(token as string),
|
||||
});
|
||||
expect(usersRes.status).toBe(403);
|
||||
|
||||
const settingsRes = await fetch(`${API}/api/v1/settings`, {
|
||||
method: "PUT",
|
||||
headers: authJson(token as string),
|
||||
body: JSON.stringify({ appName: "hacked" }),
|
||||
});
|
||||
expect(settingsRes.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user