rbac standardization

This commit is contained in:
KingKDot
2026-05-19 09:57:47 -05:00
parent c4f6172143
commit 16169d7465
6 changed files with 617 additions and 24 deletions
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, test } from "bun:test";
import { generateToken } from "../../auth";
import {
createPermissionGroup,
createUser,
deletePermissionGroup,
deleteUser,
getUserById,
setUserExtraPermissions,
setUserGroups,
} from "../../users";
import { handleAuthRoutes } from "./auth-routes";
const PASSWORD = "Aa1!AuthMePermissionsTest";
const mockServer = {
requestIP: () => ({ address: "127.0.0.1" }),
};
const createdUserIds: number[] = [];
const createdGroupIds: number[] = [];
async function tempUser(role: "admin" | "operator" | "viewer") {
const username = `mep_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
const created = await createUser(username, PASSWORD, role, "test");
expect(created.success).toBe(true);
const user = getUserById(created.userId!);
expect(user).not.toBeNull();
createdUserIds.push(user!.id);
const token = await generateToken(user!);
return { user: user!, token };
}
function tempGroup(name: string, permissions: string[]) {
const result = createPermissionGroup(
`${name}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`,
null,
permissions,
null,
);
expect(result.success).toBe(true);
createdGroupIds.push(result.group!.id);
return result.group!;
}
async function fetchMe(token: string): Promise<any> {
const url = new URL("https://localhost/api/auth/me");
const res = await handleAuthRoutes(
new Request(url, { method: "GET", headers: { Authorization: `Bearer ${token}` } }),
url,
mockServer,
);
expect(res).not.toBeNull();
expect(res!.status).toBe(200);
return res!.json();
}
afterEach(() => {
while (createdGroupIds.length > 0) {
const id = createdGroupIds.pop();
if (typeof id === "number") deletePermissionGroup(id);
}
while (createdUserIds.length > 0) {
const id = createdUserIds.pop();
if (typeof id === "number") deleteUser(id);
}
});
describe("/api/auth/me permissions array", () => {
test("admin gets every declared permission", async () => {
const admin = await tempUser("admin");
const body = await fetchMe(admin.token);
expect(Array.isArray(body.permissions)).toBe(true);
const set = new Set(body.permissions);
expect(set.has("users:manage")).toBe(true);
expect(set.has("clients:control")).toBe(true);
expect(set.has("system:configure")).toBe(true);
expect(set.has("clients:disconnect")).toBe(true);
});
test("operator gets the role-eligible subset and lacks admin-only perms", async () => {
const op = await tempUser("operator");
const body = await fetchMe(op.token);
const set = new Set(body.permissions);
expect(set.has("clients:control")).toBe(true);
expect(set.has("clients:disconnect")).toBe(true);
expect(set.has("clients:metadata")).toBe(true);
expect(set.has("users:manage")).toBe(false);
expect(set.has("system:configure")).toBe(false);
expect(set.has("network:manage-bans")).toBe(false);
});
test("viewer has no permissions by default", async () => {
const viewer = await tempUser("viewer");
const body = await fetchMe(viewer.token);
expect(body.permissions).toEqual([]);
});
test("viewer with extras gains those permissions in /api/auth/me", async () => {
const viewer = await tempUser("viewer");
setUserExtraPermissions(viewer.user.id, ["clients:metadata"]);
const body = await fetchMe(viewer.token);
expect(new Set(body.permissions).has("clients:metadata")).toBe(true);
});
test("viewer in a group gains the group's permissions in /api/auth/me", async () => {
const viewer = await tempUser("viewer");
const group = tempGroup("me_group", ["clients:disconnect", "audit:view"]);
setUserGroups(viewer.user.id, [group.id]);
const body = await fetchMe(viewer.token);
const set = new Set(body.permissions);
expect(set.has("clients:disconnect")).toBe(true);
expect(set.has("audit:view")).toBe(true);
});
test("operator with can_build=0 loses clients:build (legacy userOverride still revokes via /api/auth/me)", async () => {
const op = await tempUser("operator");
const { setUserCanBuild } = await import("../../users");
setUserCanBuild(op.user.id, false);
const body = await fetchMe(op.token);
expect(new Set(body.permissions).has("clients:build")).toBe(false);
});
});
@@ -0,0 +1,320 @@
import { afterEach, describe, expect, test } from "bun:test";
import { generateToken } from "../../auth";
import { AuditAction, flushAuditLogsSync, getAuditLogs } from "../../auditLog";
import {
createUser,
deleteUser,
deletePermissionGroup,
getUserById,
setUserGroups,
} from "../../users";
import { handlePermissionGroupsRoutes } from "./permission-groups-routes";
const PASSWORD = "Aa1!PgRoutesTestPassword";
const mockServer = {
requestIP: () => ({ address: "127.0.0.1" }),
};
const createdUserIds: number[] = [];
const createdGroupIds: number[] = [];
async function tempUser(role: "admin" | "operator" | "viewer") {
const username = `pgr_${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);
const token = await generateToken(user!);
return { user: user!, token };
}
function call(method: string, path: string, token: string | null, body?: unknown) {
const url = new URL(`https://localhost${path}`);
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
return handlePermissionGroupsRoutes(
new Request(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
}),
url,
mockServer,
);
}
afterEach(() => {
while (createdGroupIds.length > 0) {
const id = createdGroupIds.pop();
if (typeof id === "number") deletePermissionGroup(id);
}
while (createdUserIds.length > 0) {
const id = createdUserIds.pop();
if (typeof id === "number") deleteUser(id);
}
});
describe("permission-groups routes — auth gate", () => {
test("operator gets 403 from GET /api/permissions", async () => {
const op = await tempUser("operator");
const res = await call("GET", "/api/permissions", op.token);
expect(res!.status).toBe(403);
});
test("operator gets 403 from GET /api/permission-groups", async () => {
const op = await tempUser("operator");
const res = await call("GET", "/api/permission-groups", op.token);
expect(res!.status).toBe(403);
});
test("operator gets 403 from POST /api/permission-groups", async () => {
const op = await tempUser("operator");
const res = await call("POST", "/api/permission-groups", op.token, {
name: "shouldnt-work",
permissions: ["clients:metadata"],
});
expect(res!.status).toBe(403);
});
test("unauthenticated request returns 401", async () => {
const res = await call("GET", "/api/permission-groups", null);
expect(res!.status).toBe(401);
});
});
describe("permission-groups routes — list permissions catalog", () => {
test("admin can list known permissions with descriptions", async () => {
const admin = await tempUser("admin");
const res = await call("GET", "/api/permissions", admin.token);
expect(res!.status).toBe(200);
const body = await res!.json();
expect(Array.isArray(body.permissions)).toBe(true);
const ids = new Set(body.permissions.map((p: any) => p.id));
// Spot-check a few permissions we know exist.
expect(ids.has("clients:disconnect")).toBe(true);
expect(ids.has("clients:metadata")).toBe(true);
expect(ids.has("system:configure")).toBe(true);
// Each entry must have a non-empty description.
for (const p of body.permissions) {
expect(typeof p.description).toBe("string");
expect(p.description.length).toBeGreaterThan(0);
}
});
});
describe("permission-groups routes — group CRUD", () => {
test("create → list → fetch by id round trip", async () => {
const admin = await tempUser("admin");
const createRes = await call("POST", "/api/permission-groups", admin.token, {
name: `crud_${Date.now().toString(36)}`,
description: "test group",
permissions: ["clients:metadata", "clients:disconnect"],
});
expect(createRes!.status).toBe(200);
const created = await createRes!.json();
expect(created.group.id).toBeGreaterThan(0);
createdGroupIds.push(created.group.id);
expect(created.group.permissions.sort()).toEqual(["clients:disconnect", "clients:metadata"]);
const listRes = await call("GET", "/api/permission-groups", admin.token);
const list = await listRes!.json();
expect(list.groups.some((g: any) => g.id === created.group.id)).toBe(true);
const getRes = await call("GET", `/api/permission-groups/${created.group.id}`, admin.token);
expect(getRes!.status).toBe(200);
const fetched = await getRes!.json();
expect(fetched.group.permissions.sort()).toEqual(["clients:disconnect", "clients:metadata"]);
});
test("PATCH replaces permissions", async () => {
const admin = await tempUser("admin");
const createRes = await call("POST", "/api/permission-groups", admin.token, {
name: `patch_${Date.now().toString(36)}`,
permissions: ["clients:metadata"],
});
const created = await createRes!.json();
createdGroupIds.push(created.group.id);
const patchRes = await call("PATCH", `/api/permission-groups/${created.group.id}`, admin.token, {
permissions: ["clients:uninstall"],
});
expect(patchRes!.status).toBe(200);
const patched = await patchRes!.json();
expect(patched.group.permissions).toEqual(["clients:uninstall"]);
});
test("DELETE removes the group", async () => {
const admin = await tempUser("admin");
const createRes = await call("POST", "/api/permission-groups", admin.token, {
name: `del_${Date.now().toString(36)}`,
permissions: [],
});
const created = await createRes!.json();
const delRes = await call("DELETE", `/api/permission-groups/${created.group.id}`, admin.token);
expect(delRes!.status).toBe(200);
const getRes = await call("GET", `/api/permission-groups/${created.group.id}`, admin.token);
expect(getRes!.status).toBe(404);
});
test("rejects unknown permissions during creation (silent strip)", async () => {
const admin = await tempUser("admin");
const res = await call("POST", "/api/permission-groups", admin.token, {
name: `sanitize_${Date.now().toString(36)}`,
permissions: ["clients:metadata", "obviously:not:real", ""],
});
expect(res!.status).toBe(200);
const data = await res!.json();
createdGroupIds.push(data.group.id);
expect(data.group.permissions).toEqual(["clients:metadata"]);
});
test("duplicate group name returns 400", async () => {
const admin = await tempUser("admin");
const name = `dup_${Date.now().toString(36)}`;
const first = await call("POST", "/api/permission-groups", admin.token, { name, permissions: [] });
const firstData = await first!.json();
createdGroupIds.push(firstData.group.id);
const second = await call("POST", "/api/permission-groups", admin.token, { name, permissions: [] });
expect(second!.status).toBe(400);
});
});
describe("permission-groups routes — user assignments", () => {
test("PUT /api/users/:id/permission-groups assigns + GET returns the same set", async () => {
const admin = await tempUser("admin");
const target = await tempUser("viewer");
const createRes = await call("POST", "/api/permission-groups", admin.token, {
name: `assign_${Date.now().toString(36)}`,
permissions: ["clients:metadata"],
});
const group = (await createRes!.json()).group;
createdGroupIds.push(group.id);
const putRes = await call("PUT", `/api/users/${target.user.id}/permission-groups`, admin.token, {
groupIds: [group.id],
});
expect(putRes!.status).toBe(200);
const getRes = await call("GET", `/api/users/${target.user.id}/permission-groups`, admin.token);
const body = await getRes!.json();
expect(body.groupIds).toEqual([group.id]);
});
test("PUT /api/users/:id/extra-permissions sanitizes unknown perms", async () => {
const admin = await tempUser("admin");
const target = await tempUser("viewer");
const putRes = await call("PUT", `/api/users/${target.user.id}/extra-permissions`, admin.token, {
permissions: ["clients:metadata", "not:a:permission", 42],
});
expect(putRes!.status).toBe(200);
const body = await putRes!.json();
expect(body.permissions).toEqual(["clients:metadata"]);
});
test("returns 404 for unknown user", async () => {
const admin = await tempUser("admin");
const res = await call("PUT", `/api/users/9999999/permission-groups`, admin.token, { groupIds: [] });
expect(res!.status).toBe(404);
});
});
describe("permission-groups routes — audit log shape", () => {
test("group create logs PERMISSION_GROUP_CREATE with structured details", async () => {
const admin = await tempUser("admin");
const name = `audit_create_${Date.now().toString(36)}`;
const createRes = await call("POST", "/api/permission-groups", admin.token, {
name,
description: "describes the group",
permissions: ["clients:metadata"],
});
const created = (await createRes!.json()).group;
createdGroupIds.push(created.id);
flushAuditLogsSync();
const { logs } = getAuditLogs({
action: AuditAction.PERMISSION_GROUP_CREATE,
username: admin.user.username,
pageSize: 5,
});
const entry = logs.find((l) => l.details && l.details.includes(`"groupId":${created.id}`));
expect(entry).toBeDefined();
const parsed = JSON.parse(entry!.details!);
expect(parsed.groupId).toBe(created.id);
expect(parsed.name).toBe(name);
expect(parsed.description).toBe("describes the group");
expect(parsed.permissions).toEqual(["clients:metadata"]);
});
test("user-groups change logs added/removed deltas, not just the count", async () => {
const admin = await tempUser("admin");
const target = await tempUser("viewer");
const ga = (await (await call("POST", "/api/permission-groups", admin.token, {
name: `delta_a_${Date.now().toString(36)}`,
permissions: [],
}))!.json()).group;
createdGroupIds.push(ga.id);
const gb = (await (await call("POST", "/api/permission-groups", admin.token, {
name: `delta_b_${Date.now().toString(36)}`,
permissions: [],
}))!.json()).group;
createdGroupIds.push(gb.id);
// Seed: user has only group A
setUserGroups(target.user.id, [ga.id]);
// Change: replace with group B
await call("PUT", `/api/users/${target.user.id}/permission-groups`, admin.token, {
groupIds: [gb.id],
});
flushAuditLogsSync();
const { logs } = getAuditLogs({
action: AuditAction.USER_GROUPS_CHANGE,
username: admin.user.username,
pageSize: 5,
});
const entry = logs.find((l) => l.details && l.details.includes(`"targetUserId":${target.user.id}`));
expect(entry).toBeDefined();
const parsed = JSON.parse(entry!.details!);
expect(parsed.groupsAdded).toEqual([gb.id]);
expect(parsed.groupsRemoved).toEqual([ga.id]);
expect(parsed.targetUsername).toBe(target.user.username);
});
test("user-extras change logs added/removed deltas", async () => {
const admin = await tempUser("admin");
const target = await tempUser("viewer");
// First set: ["clients:metadata"]
await call("PUT", `/api/users/${target.user.id}/extra-permissions`, admin.token, {
permissions: ["clients:metadata"],
});
// Then replace with ["clients:disconnect"]
await call("PUT", `/api/users/${target.user.id}/extra-permissions`, admin.token, {
permissions: ["clients:disconnect"],
});
flushAuditLogsSync();
const { logs } = getAuditLogs({
action: AuditAction.USER_EXTRA_PERMISSIONS_CHANGE,
username: admin.user.username,
pageSize: 10,
});
// The most recent entry should reflect the second swap.
const entry = logs
.filter((l) => l.details && l.details.includes(`"targetUserId":${target.user.id}`))
.sort((a, b) => b.timestamp - a.timestamp)[0];
expect(entry).toBeDefined();
const parsed = JSON.parse(entry!.details!);
expect(parsed.permissionsAdded).toEqual(["clients:disconnect"]);
expect(parsed.permissionsRemoved).toEqual(["clients:metadata"]);
});
});
@@ -14,34 +14,13 @@ import {
} from "../../users";
import {
getPermissionDescription,
type Permission,
listAllPermissions,
requirePermission,
} from "../../rbac";
const KNOWN_PERMISSIONS: Permission[] = [
"users:manage",
"clients:control",
"clients:build",
"clients:enroll",
"clients:silent-exec",
"clients:disconnect",
"clients:reconnect",
"clients:metadata",
"clients:uninstall",
"clients:winre",
"audit:view",
"chat:write",
"scripts:manage",
"deploys:manage",
"plugins:manage",
"plugins:configure",
"network:manage-bans",
"system:configure",
];
function sanitizePermissions(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const known = new Set<string>(KNOWN_PERMISSIONS);
const known = new Set<string>(listAllPermissions());
return Array.from(
new Set(
raw
@@ -79,7 +58,7 @@ export async function handlePermissionGroupsRoutes(
return new Response("Forbidden", { status: 403 });
}
return Response.json({
permissions: KNOWN_PERMISSIONS.map((p) => ({
permissions: listAllPermissions().map((p) => ({
id: p,
description: getPermissionDescription(p),
})),