diff --git a/Overlord-Server/src/rbac.ts b/Overlord-Server/src/rbac.ts
index 5e5f5b4..2805ea4 100644
--- a/Overlord-Server/src/rbac.ts
+++ b/Overlord-Server/src/rbac.ts
@@ -84,7 +84,55 @@ const PERMISSIONS = {
roles: ["admin"],
},
"system:configure": {
- description: "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)",
+ description: "Legacy full server settings access (grants all system:* settings permissions)",
+ roles: ["admin"],
+ },
+ "system:security": {
+ description: "Change security policy settings",
+ roles: ["admin"],
+ },
+ "system:tls": {
+ description: "Change TLS and certificate settings",
+ roles: ["admin"],
+ },
+ "system:registration": {
+ description: "Change user registration policy settings",
+ roles: ["admin"],
+ },
+ "system:notifications": {
+ description: "Change global notification delivery settings",
+ roles: ["admin"],
+ },
+ "system:chat": {
+ description: "Change team chat settings",
+ roles: ["admin"],
+ },
+ "system:appearance": {
+ description: "Change custom CSS and appearance settings",
+ roles: ["admin"],
+ },
+ "system:thumbnails": {
+ description: "Change screenshot thumbnail settings",
+ roles: ["admin"],
+ },
+ "system:build-limits": {
+ description: "Change build rate limit settings",
+ roles: ["admin"],
+ },
+ "system:export-import": {
+ description: "Export and import server settings",
+ roles: ["admin"],
+ },
+ "system:health": {
+ description: "View server health diagnostics",
+ roles: ["admin"],
+ },
+ "system:health:manage": {
+ description: "Run server health maintenance actions",
+ roles: ["admin"],
+ },
+ "system:profiler": {
+ description: "Run server CPU and memory profiler captures",
roles: ["admin"],
},
"clients:elevate": {
@@ -128,8 +176,17 @@ export function hasPermission(
if (grantedByRole) return true;
// Permission groups + per-user extras are *additive only*: they can grant a
// permission the role doesn't have, but they cannot revoke one the role does.
- if (userId !== undefined && getUserGrantedPermissions(userId).has(permission)) {
- return true;
+ if (userId !== undefined) {
+ const granted = getUserGrantedPermissions(userId);
+ if (granted.has(permission)) return true;
+ if (
+ typeof permission === "string" &&
+ permission.startsWith("system:") &&
+ permission !== "system:configure" &&
+ granted.has("system:configure")
+ ) {
+ return true;
+ }
}
return false;
}
diff --git a/Overlord-Server/src/server/routes/auth-routes.permissions-array.test.ts b/Overlord-Server/src/server/routes/auth-routes.permissions-array.test.ts
index a979ea8..ffa8905 100644
--- a/Overlord-Server/src/server/routes/auth-routes.permissions-array.test.ts
+++ b/Overlord-Server/src/server/routes/auth-routes.permissions-array.test.ts
@@ -103,6 +103,19 @@ describe("/api/auth/me permissions array", () => {
expect(new Set(body.permissions).has("clients:metadata")).toBe(true);
});
+ test("legacy system:configure extra grants the split settings permissions", async () => {
+ const viewer = await tempUser("viewer");
+ setUserExtraPermissions(viewer.user.id, ["system:configure"]);
+ const body = await fetchMe(viewer.token);
+ const set = new Set(body.permissions);
+ expect(set.has("system:configure")).toBe(true);
+ expect(set.has("system:security")).toBe(true);
+ expect(set.has("system:tls")).toBe(true);
+ expect(set.has("system:registration")).toBe(true);
+ expect(set.has("system:health")).toBe(true);
+ expect(set.has("system:profiler")).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"]);
diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts
index c4965c1..d92d452 100644
--- a/Overlord-Server/src/server/routes/misc-routes.ts
+++ b/Overlord-Server/src/server/routes/misc-routes.ts
@@ -432,7 +432,7 @@ export async function handleMiscRoutes(
}
try {
- requirePermission(user, "system:configure");
+ requirePermission(user, "system:security");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -494,7 +494,7 @@ export async function handleMiscRoutes(
}
try {
- requirePermission(user, "system:configure");
+ requirePermission(user, "system:tls");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -549,7 +549,7 @@ export async function handleMiscRoutes(
}
try {
- requirePermission(user, "system:configure");
+ requirePermission(user, "system:tls");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -790,7 +790,7 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
try {
- requirePermission(user, "system:configure");
+ requirePermission(user, "system:export-import");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -823,7 +823,7 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
try {
- requirePermission(user, "system:configure");
+ requirePermission(user, "system:export-import");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -875,7 +875,7 @@ export async function handleMiscRoutes(
}
try {
- requirePermission(user, "system:configure");
+ requirePermission(user, "system:chat");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -916,7 +916,7 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 });
}
try {
- requirePermission(user, "system:configure");
+ requirePermission(user, "system:appearance");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -981,7 +981,7 @@ export async function handleMiscRoutes(
if (url.pathname === "/api/settings/registration") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
- try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
+ try { requirePermission(user, "system:registration"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (req.method === "GET") {
return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS });
@@ -1017,7 +1017,7 @@ export async function handleMiscRoutes(
if (url.pathname === "/api/settings/build-rate-limit") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
- try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
+ try { requirePermission(user, "system:build-limits"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (req.method === "GET") {
return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS });
@@ -1058,7 +1058,7 @@ export async function handleMiscRoutes(
}
if (req.method === "PUT") {
- try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
+ try { requirePermission(user, "system:thumbnails"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
let body: any = {};
try { body = await req.json(); } catch {
@@ -1087,7 +1087,7 @@ export async function handleMiscRoutes(
if (req.method === "GET" && url.pathname === "/api/settings/health") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
- if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
+ try { requirePermission(user, "system:health"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
const mem = process.memoryUsage();
return Response.json({
@@ -1116,7 +1116,7 @@ export async function handleMiscRoutes(
if (req.method === "POST" && url.pathname === "/api/settings/gc") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
- if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
+ try { requirePermission(user, "system:health:manage"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
const before = process.memoryUsage().heapUsed;
if (typeof Bun !== "undefined" && typeof (Bun as any).gc === "function") {
@@ -1130,7 +1130,7 @@ export async function handleMiscRoutes(
if (req.method === "POST" && url.pathname === "/api/settings/profile") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
- if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
+ try { requirePermission(user, "system:profiler"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (activeServerProfile) {
return Response.json({ error: "A server profile capture is already running." }, { status: 409 });
diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.ts b/Overlord-Server/src/server/routes/notifications-config-routes.ts
index d422d4a..f6e93c9 100644
--- a/Overlord-Server/src/server/routes/notifications-config-routes.ts
+++ b/Overlord-Server/src/server/routes/notifications-config-routes.ts
@@ -78,7 +78,7 @@ export async function handleNotificationsConfigRoutes(
});
}
try {
- requirePermission(user, "system:configure");
+ requirePermission(user, "system:notifications");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -95,7 +95,7 @@ export async function handleNotificationsConfigRoutes(
});
}
try {
- requirePermission(user, "system:configure");
+ requirePermission(user, "system:notifications");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });