From c4f6172143b024d2416ea8d9757785fb2f50a153 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Tue, 19 May 2026 09:42:44 -0500 Subject: [PATCH] audit log permissions --- Overlord-Server/src/auditLog.ts | 10 + .../server/routes/client-routes-gates.test.ts | 233 ++++++++++++++++++ .../server/routes/permission-groups-routes.ts | 74 +++++- .../src/server/routes/users-routes.ts | 65 ++++- 4 files changed, 361 insertions(+), 21 deletions(-) create mode 100644 Overlord-Server/src/server/routes/client-routes-gates.test.ts diff --git a/Overlord-Server/src/auditLog.ts b/Overlord-Server/src/auditLog.ts index 60bf629..3d43d33 100644 --- a/Overlord-Server/src/auditLog.ts +++ b/Overlord-Server/src/auditLog.ts @@ -78,6 +78,16 @@ export enum AuditAction { ENROLLMENT_BULK = "enrollment_bulk", WINRE_INSTALL = "winre_install", WINRE_UNINSTALL = "winre_uninstall", + PERMISSION_GROUP_CREATE = "permission_group_create", + PERMISSION_GROUP_UPDATE = "permission_group_update", + PERMISSION_GROUP_DELETE = "permission_group_delete", + USER_GROUPS_CHANGE = "user_groups_change", + USER_EXTRA_PERMISSIONS_CHANGE = "user_extra_permissions_change", + USER_FEATURE_PERMISSIONS_CHANGE = "user_feature_permissions_change", + USER_ROLE_CHANGE = "user_role_change", + USER_BUILD_TOGGLE = "user_build_toggle", + USER_UPLOAD_TOGGLE = "user_upload_toggle", + USER_CHAT_WRITE_TOGGLE = "user_chat_write_toggle", } export interface AuditLogEntry { diff --git a/Overlord-Server/src/server/routes/client-routes-gates.test.ts b/Overlord-Server/src/server/routes/client-routes-gates.test.ts new file mode 100644 index 0000000..488c3d4 --- /dev/null +++ b/Overlord-Server/src/server/routes/client-routes-gates.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { generateToken } from "../../auth"; +import * as clientManager from "../../clientManager"; +import { + createUser, + deleteUser, + getUserById, + setUserClientAccessScope, + setUserFeaturePermission, +} from "../../users"; +import { upsertClientRow, deleteClientRow } from "../../db"; +import { handleClientRoutes } from "./client-routes"; + +const PASSWORD = "Aa1!RouteGateTestPass123"; + +const mockServer = { + requestIP: () => ({ address: "127.0.0.1" }), +}; + +const deps = { + CORS_HEADERS: {} as Record, + pendingScripts: new Map(), + pendingCommandReplies: new Map(), + broadcastNotificationsCleared: () => {}, +}; + +const createdUserIds: number[] = []; +const createdClientIds: string[] = []; + +async function makeUserWithToken(role: "admin" | "operator" | "viewer", scopeAll = true) { + const username = `gate_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; + const result = await createUser(username, PASSWORD, role, "test"); + expect(result.success).toBe(true); + const user = getUserById(result.userId!); + expect(user).not.toBeNull(); + createdUserIds.push(user!.id); + + if (scopeAll && role !== "admin") { + setUserClientAccessScope(user!.id, "all"); + } + + const token = await generateToken(user!); + return { user: user!, token }; +} + +function registerTestClient(): string { + const clientId = `gate-client-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; + // Insert a row so clientExists() / DB-backed checks pass; also keep an + // in-memory entry with a no-op ws.send() so the route's target.ws.send() is + // exercised without actually trying to write to a socket. + upsertClientRow({ + id: clientId, + os: "windows", + arch: "x64", + online: 1, + lastSeen: Date.now(), + }); + clientManager.addClient(clientId, { + id: clientId, + lastSeen: Date.now(), + role: "client", + ws: { send: () => {} }, + } as any); + createdClientIds.push(clientId); + return clientId; +} + +async function command(token: string, clientId: string, action: string, extra: Record = {}) { + const url = new URL(`https://localhost/api/clients/${encodeURIComponent(clientId)}/command`); + return handleClientRoutes( + new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ action, ...extra }), + }), + url, + mockServer, + deps, + ); +} + +async function patchNickname(token: string, clientId: string, nickname: string) { + const url = new URL(`https://localhost/api/clients/${encodeURIComponent(clientId)}/nickname`); + return handleClientRoutes( + new Request(url, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ nickname }), + }), + url, + mockServer, + deps, + ); +} + +afterEach(() => { + while (createdClientIds.length > 0) { + const id = createdClientIds.pop(); + if (typeof id === "string") { + clientManager.deleteClient(id); + try { deleteClientRow(id); } catch {} + } + } + while (createdUserIds.length > 0) { + const id = createdUserIds.pop(); + if (typeof id === "number") deleteUser(id); + } +}); + +describe("clients:disconnect gates", () => { + test("admin can disconnect", async () => { + const auth = await makeUserWithToken("admin"); + const clientId = registerTestClient(); + const res = await command(auth.token, clientId, "disconnect"); + expect(res!.status).toBe(200); + }); + + test("operator (default) can disconnect", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + const res = await command(auth.token, clientId, "disconnect"); + expect(res!.status).toBe(200); + }); + + test("operator with disconnect feature OFF gets 403", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + setUserFeaturePermission(auth.user.id, "disconnect", false); + const res = await command(auth.token, clientId, "disconnect"); + expect(res!.status).toBe(403); + }); + + test("viewer cannot reach the dispatch (blocked by outer clients:control gate)", async () => { + const auth = await makeUserWithToken("viewer"); + const clientId = registerTestClient(); + const res = await command(auth.token, clientId, "disconnect"); + expect(res!.status).toBe(403); + }); +}); + +describe("clients:reconnect gates", () => { + test("operator (default) can reconnect", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + const res = await command(auth.token, clientId, "reconnect"); + expect(res!.status).toBe(200); + }); + + test("operator with reconnect feature OFF gets 403", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + setUserFeaturePermission(auth.user.id, "reconnect", false); + const res = await command(auth.token, clientId, "reconnect"); + expect(res!.status).toBe(403); + }); + + test("revoking disconnect does not revoke reconnect (feature toggles are independent)", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + setUserFeaturePermission(auth.user.id, "disconnect", false); + const res = await command(auth.token, clientId, "reconnect"); + expect(res!.status).toBe(200); + }); +}); + +describe("clients:uninstall gates", () => { + test("admin can uninstall", async () => { + const auth = await makeUserWithToken("admin"); + const clientId = registerTestClient(); + const res = await command(auth.token, clientId, "uninstall"); + expect(res!.status).toBe(200); + }); + + test("operator with uninstall feature OFF gets 403", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + setUserFeaturePermission(auth.user.id, "uninstall", false); + const res = await command(auth.token, clientId, "uninstall"); + expect(res!.status).toBe(403); + }); +}); + +describe("clients:metadata gates", () => { + test("operator (default) can set nickname", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + const res = await patchNickname(auth.token, clientId, "test-nick"); + expect(res!.status).toBe(200); + }); + + test("operator with client_metadata feature OFF gets 403", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + setUserFeaturePermission(auth.user.id, "client_metadata", false); + const res = await patchNickname(auth.token, clientId, "test-nick"); + expect(res!.status).toBe(403); + }); + + test("viewer cannot set nickname", async () => { + const auth = await makeUserWithToken("viewer"); + const clientId = registerTestClient(); + const res = await patchNickname(auth.token, clientId, "test-nick"); + expect(res!.status).toBe(403); + }); + + test("revoking disconnect does not block metadata edits", async () => { + const auth = await makeUserWithToken("operator"); + const clientId = registerTestClient(); + setUserFeaturePermission(auth.user.id, "disconnect", false); + const res = await patchNickname(auth.token, clientId, "test-nick"); + expect(res!.status).toBe(200); + }); +}); + +describe("layered semantics: groups + feature toggles", () => { + test("admin always bypasses both gates regardless of feature toggles", async () => { + const auth = await makeUserWithToken("admin"); + const clientId = registerTestClient(); + // Admin's feature toggle is irrelevant (canUserAccessFeature returns true for admin). + setUserFeaturePermission(auth.user.id, "disconnect", false); + const res = await command(auth.token, clientId, "disconnect"); + expect(res!.status).toBe(200); + }); + + test("viewer is denied even though feature toggle defaults look on (canUserAccessFeature returns false for viewer)", async () => { + const auth = await makeUserWithToken("viewer"); + const clientId = registerTestClient(); + // Even if we tried to flip the toggle on, the feature gate denies viewers. + setUserFeaturePermission(auth.user.id, "disconnect", true); + const res = await command(auth.token, clientId, "disconnect"); + expect(res!.status).toBe(403); + }); +}); diff --git a/Overlord-Server/src/server/routes/permission-groups-routes.ts b/Overlord-Server/src/server/routes/permission-groups-routes.ts index fd9544e..2c63272 100644 --- a/Overlord-Server/src/server/routes/permission-groups-routes.ts +++ b/Overlord-Server/src/server/routes/permission-groups-routes.ts @@ -52,6 +52,15 @@ function sanitizePermissions(raw: unknown): string[] { ); } +function diffArrays(before: readonly T[], after: readonly T[]): { added: T[]; removed: T[] } { + const b = new Set(before); + const a = new Set(after); + return { + added: after.filter((x) => !b.has(x)), + removed: before.filter((x) => !a.has(x)), + }; +} + type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; }; @@ -110,8 +119,13 @@ export async function handlePermissionGroupsRoutes( timestamp: Date.now(), username: authed.username, ip: server.requestIP(req)?.address || "unknown", - action: AuditAction.COMMAND, - details: `Created permission group "${result.group!.name}" with ${result.group!.permissions.length} permissions`, + action: AuditAction.PERMISSION_GROUP_CREATE, + details: JSON.stringify({ + groupId: result.group!.id, + name: result.group!.name, + description: result.group!.description, + permissions: result.group!.permissions, + }), success: true, }); @@ -139,6 +153,9 @@ export async function handlePermissionGroupsRoutes( if (req.method === "PATCH" || req.method === "PUT") { let body: any; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); } + const before = getPermissionGroup(groupId); + if (!before) return Response.json({ error: "Not found" }, { status: 404 }); + const updates: Parameters[1] = {}; if (body?.name !== undefined) updates.name = String(body.name); if (body?.description !== undefined) updates.description = body.description === null ? null : String(body.description); @@ -146,12 +163,27 @@ export async function handlePermissionGroupsRoutes( const result = updatePermissionGroup(groupId, updates); if (!result.success) return Response.json({ error: result.error }, { status: 400 }); + const after = result.group!; + const permsDiff = diffArrays(before.permissions, after.permissions); + const auditDetails: Record = { + groupId, + name: after.name, + }; + if (before.name !== after.name) auditDetails.nameChanged = { from: before.name, to: after.name }; + if (before.description !== after.description) { + auditDetails.descriptionChanged = { from: before.description, to: after.description }; + } + if (permsDiff.added.length || permsDiff.removed.length) { + auditDetails.permissionsAdded = permsDiff.added; + auditDetails.permissionsRemoved = permsDiff.removed; + } + logAudit({ timestamp: Date.now(), username: authed.username, ip: server.requestIP(req)?.address || "unknown", - action: AuditAction.COMMAND, - details: `Updated permission group "${result.group!.name}"`, + action: AuditAction.PERMISSION_GROUP_UPDATE, + details: JSON.stringify(auditDetails), success: true, }); return Response.json({ group: result.group }); @@ -166,8 +198,12 @@ export async function handlePermissionGroupsRoutes( timestamp: Date.now(), username: authed.username, ip: server.requestIP(req)?.address || "unknown", - action: AuditAction.COMMAND, - details: `Deleted permission group "${existing?.name ?? groupId}"`, + action: AuditAction.PERMISSION_GROUP_DELETE, + details: JSON.stringify({ + groupId, + name: existing?.name ?? null, + permissions: existing?.permissions ?? [], + }), success: true, }); return Response.json({ ok: true }); @@ -200,15 +236,24 @@ export async function handlePermissionGroupsRoutes( const ids = Array.isArray(body?.groupIds) ? body.groupIds.map((g: any) => Number(g)).filter((g: number) => Number.isFinite(g)) : []; + const targetUser = getUserById(targetUserId); + const before = getUserGroupIds(targetUserId); const result = setUserGroups(targetUserId, ids); if (!result.success) return Response.json({ error: result.error }, { status: 400 }); + const groupDiff = diffArrays(before, ids); logAudit({ timestamp: Date.now(), username: authed.username, ip: server.requestIP(req)?.address || "unknown", - action: AuditAction.COMMAND, - details: `Assigned ${ids.length} permission group(s) to user #${targetUserId}`, + action: AuditAction.USER_GROUPS_CHANGE, + details: JSON.stringify({ + targetUserId, + targetUsername: targetUser?.username, + groupsAdded: groupDiff.added, + groupsRemoved: groupDiff.removed, + groupsAfter: ids, + }), success: true, }); return Response.json({ ok: true, groupIds: ids }); @@ -239,15 +284,24 @@ export async function handlePermissionGroupsRoutes( let body: any; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); } const perms = sanitizePermissions(body?.permissions); + const targetUser = getUserById(targetUserId); + const before = Array.from(getUserExtraPermissions(targetUserId)); const result = setUserExtraPermissions(targetUserId, perms); if (!result.success) return Response.json({ error: result.error }, { status: 400 }); + const permsDiff = diffArrays(before, perms); logAudit({ timestamp: Date.now(), username: authed.username, ip: server.requestIP(req)?.address || "unknown", - action: AuditAction.COMMAND, - details: `Set ${perms.length} extra permission(s) on user #${targetUserId}`, + action: AuditAction.USER_EXTRA_PERMISSIONS_CHANGE, + details: JSON.stringify({ + targetUserId, + targetUsername: targetUser?.username, + permissionsAdded: permsDiff.added, + permissionsRemoved: permsDiff.removed, + permissionsAfter: perms, + }), success: true, }); return Response.json({ ok: true, permissions: perms }); diff --git a/Overlord-Server/src/server/routes/users-routes.ts b/Overlord-Server/src/server/routes/users-routes.ts index 0f88c9a..e216bfd 100644 --- a/Overlord-Server/src/server/routes/users-routes.ts +++ b/Overlord-Server/src/server/routes/users-routes.ts @@ -191,6 +191,8 @@ export async function handleUsersRoutes( return Response.json({ error: "Cannot change your own role" }, { status: 400 }); } + const beforeUser = getUserById(userId); + const previousRole = beforeUser?.role ?? null; const result = updateUserRole(userId, role); if (result.success) { @@ -207,8 +209,14 @@ export async function handleUsersRoutes( timestamp: Date.now(), username: authedUser.username, ip, - action: AuditAction.COMMAND, - details: `Updated role for user: ${targetUser?.username} to ${role}`, + action: AuditAction.USER_ROLE_CHANGE, + details: JSON.stringify({ + targetUserId: userId, + targetUsername: targetUser?.username, + from: previousRole, + to: role, + revokedSessions: existingSessions.length, + }), success: true, }); @@ -376,6 +384,7 @@ export async function handleUsersRoutes( const body = await req.json(); const canBuild = !!body?.canBuild; + const previous = Boolean(targetUser.can_build); const result = setUserCanBuild(userId, canBuild); if (!result.success) { return Response.json({ error: result.error }, { status: 400 }); @@ -386,8 +395,13 @@ export async function handleUsersRoutes( timestamp: Date.now(), username: authedUser.username, ip, - action: AuditAction.COMMAND, - details: `${canBuild ? "Granted" : "Revoked"} build permission for ${targetUser.username}`, + action: AuditAction.USER_BUILD_TOGGLE, + details: JSON.stringify({ + targetUserId: userId, + targetUsername: targetUser.username, + from: previous, + to: canBuild, + }), success: true, }); @@ -404,6 +418,7 @@ export async function handleUsersRoutes( const body = await req.json(); const canUploadFiles = !!body?.canUploadFiles; + const previous = Boolean(targetUser.can_upload_files); const result = setUserCanUploadFiles(userId, canUploadFiles); if (!result.success) { return Response.json({ error: result.error }, { status: 400 }); @@ -414,8 +429,13 @@ export async function handleUsersRoutes( timestamp: Date.now(), username: authedUser.username, ip, - action: AuditAction.COMMAND, - details: `${canUploadFiles ? "Granted" : "Revoked"} file upload permission for ${targetUser.username}`, + action: AuditAction.USER_UPLOAD_TOGGLE, + details: JSON.stringify({ + targetUserId: userId, + targetUsername: targetUser.username, + from: previous, + to: canUploadFiles, + }), success: true, }); @@ -455,22 +475,33 @@ export async function handleUsersRoutes( } } + const before = getUserFeaturePermissions(userId); const result = setUserFeaturePermissions(userId, validated); if (!result.success) { return Response.json({ error: result.error }, { status: 400 }); } + const after = getUserFeaturePermissions(userId); + + const changes: Record = {}; + for (const f of Object.keys(after) as Array) { + if (before[f] !== after[f]) changes[f as string] = { from: before[f], to: after[f] }; + } const ip = server.requestIP(req)?.address || "unknown"; logAudit({ timestamp: Date.now(), username: authedUser.username, ip, - action: AuditAction.COMMAND, - details: `Updated feature permissions for ${targetUser.username}: ${JSON.stringify(validated)}`, + action: AuditAction.USER_FEATURE_PERMISSIONS_CHANGE, + details: JSON.stringify({ + targetUserId: userId, + targetUsername: targetUser.username, + changes, + }), success: true, }); - return Response.json({ success: true, permissions: getUserFeaturePermissions(userId) }); + return Response.json({ success: true, permissions: after }); } if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+\/feature-permissions$/)) { @@ -481,18 +512,30 @@ export async function handleUsersRoutes( return Response.json({ error: "User not found" }, { status: 404 }); } + const before = getUserFeaturePermissions(userId); const result = resetUserFeaturePermissions(userId); if (!result.success) { return Response.json({ error: result.error }, { status: 400 }); } + const after = getUserFeaturePermissions(userId); + + const changes: Record = {}; + for (const f of Object.keys(after) as Array) { + if (before[f] !== after[f]) changes[f as string] = { from: before[f], to: after[f] }; + } const ip = server.requestIP(req)?.address || "unknown"; logAudit({ timestamp: Date.now(), username: authedUser.username, ip, - action: AuditAction.COMMAND, - details: `Reset feature permissions for ${targetUser.username} to defaults`, + action: AuditAction.USER_FEATURE_PERMISSIONS_CHANGE, + details: JSON.stringify({ + targetUserId: userId, + targetUsername: targetUser.username, + reset: true, + changes, + }), success: true, });