rbac standardization
This commit is contained in:
@@ -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),
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user