audit log permissions

This commit is contained in:
KingKDot
2026-05-19 09:42:44 -05:00
parent 116beff515
commit c4f6172143
4 changed files with 361 additions and 21 deletions
+10
View File
@@ -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 {
@@ -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<string, string>,
pendingScripts: new Map<string, any>(),
pendingCommandReplies: new Map<string, any>(),
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<string, unknown> = {}) {
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);
});
});
@@ -52,6 +52,15 @@ function sanitizePermissions(raw: unknown): string[] {
);
}
function diffArrays<T>(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<typeof updatePermissionGroup>[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<string, unknown> = {
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 });
@@ -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<string, { from: boolean; to: boolean }> = {};
for (const f of Object.keys(after) as Array<keyof typeof after>) {
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<string, { from: boolean; to: boolean }> = {};
for (const f of Object.keys(after) as Array<keyof typeof after>) {
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,
});