From 116beff51506948e830ebc407f9130bca4d9771d Mon Sep 17 00:00:00 2001
From: KingKDot
Date: Tue, 19 May 2026 00:46:30 -0500
Subject: [PATCH] RBAC 3.0
---
Overlord-Server/public/assets/settings.js | 103 ++-
Overlord-Server/public/assets/users.js | 291 +++++++-
Overlord-Server/public/settings.html | 55 +-
Overlord-Server/public/users.html | 79 ++
Overlord-Server/src/config.ts | 10 +
Overlord-Server/src/main-server.ts | 5 +-
Overlord-Server/src/permission-groups.test.ts | 171 +++++
Overlord-Server/src/rbac.test.ts | 104 +++
Overlord-Server/src/rbac.ts | 207 ++++-
.../src/server/routes/auth-routes.ts | 17 +-
.../src/server/routes/auto-deploy-routes.ts | 21 +-
.../src/server/routes/auto-scripts-routes.ts | 21 +-
.../src/server/routes/build-routes.ts | 5 +-
.../src/server/routes/chat-routes.ts | 2 +-
.../src/server/routes/client-routes.ts | 128 +++-
.../src/server/routes/deploy-routes.ts | 29 +-
.../src/server/routes/enrollment-routes.ts | 21 +-
.../routes/file-download-routes.test.ts | 2 +-
.../src/server/routes/file-download-routes.ts | 68 +-
.../src/server/routes/misc-routes.ts | 56 +-
.../routes/notifications-config-routes.ts | 17 +-
.../server/routes/permission-groups-routes.ts | 258 +++++++
.../src/server/routes/plugin-routes.ts | 202 +++--
.../src/server/routes/registration-routes.ts | 50 +-
.../src/server/routes/sol-routes.ts | 8 +-
.../src/server/routes/webrtc-routes.ts | 7 +-
.../src/server/routes/winre-routes.ts | 29 +-
.../src/server/routes/ws-upgrade-routes.ts | 13 +-
.../src/users.build-permission.test.ts | 2 +-
.../src/users.feature-permissions.test.ts | 4 +
Overlord-Server/src/users.ts | 706 ++++++++++++------
31 files changed, 2170 insertions(+), 521 deletions(-)
create mode 100644 Overlord-Server/src/permission-groups.test.ts
create mode 100644 Overlord-Server/src/server/routes/permission-groups-routes.ts
diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js
index 032c1b9..784f807 100644
--- a/Overlord-Server/public/assets/settings.js
+++ b/Overlord-Server/public/assets/settings.js
@@ -116,6 +116,39 @@ function isAdmin(role) {
return role === "admin";
}
+function userHas(perm) {
+ return !!currentUser?.permissions?.includes(perm);
+}
+
+function applyPermissionVisibility() {
+ document.querySelectorAll("[data-permission]").forEach((el) => {
+ const perm = el.dataset.permission;
+ if (perm && userHas(perm)) {
+ el.classList.remove("hidden");
+ } else {
+ el.classList.add("hidden");
+ }
+ });
+
+ const sidebar = document.getElementById("settings-sidebar");
+ if (!sidebar) return;
+ const children = Array.from(sidebar.children);
+ for (let i = 0; i < children.length; i++) {
+ const label = children[i];
+ if (!label.classList.contains("settings-nav-group-label")) continue;
+ let hasVisibleLink = false;
+ for (let j = i + 1; j < children.length; j++) {
+ const next = children[j];
+ if (next.classList.contains("settings-nav-group-label")) break;
+ if (next.classList.contains("settings-nav-link") && !next.classList.contains("hidden")) {
+ hasVisibleLink = true;
+ break;
+ }
+ }
+ label.classList.toggle("hidden", !hasVisibleLink);
+ }
+}
+
function getPasswordRequirementsText() {
const minLength = Number(securityConfig?.passwordMinLength) || 6;
const requirements = [`min ${minLength} chars`];
@@ -820,19 +853,6 @@ function initSettingsSidebar() {
if (!sidebar) return;
const links = Array.from(sidebar.querySelectorAll(".settings-nav-link"));
- // Hide nav links and group labels whose target sections are admin-only /
- // admin-or-operator when the current user isn't authorized. We hide rather
- // than disable so the sidebar stays uncluttered for non-admins.
- const gated = sidebar.querySelectorAll("[data-admin-only], [data-admin-or-operator]");
- for (const el of gated) {
- if (el.dataset.adminOnly !== undefined && !isAdmin(currentUser?.role)) {
- el.classList.add("hidden");
- }
- if (el.dataset.adminOrOperator !== undefined && !canManageBuilds(currentUser?.role)) {
- el.classList.add("hidden");
- }
- }
-
// Build the section→link map up front so click + observer share it.
const sectionMap = new Map();
// sectionsInOrder preserves document order for "last visible at page bottom"
@@ -1340,9 +1360,12 @@ async function handleRemoveInactiveSessions() {
async function loadRegistrationSettings() {
try {
- const res = await fetch("/api/settings/registration", { credentials: "include" });
- if (!res.ok) return;
- const data = await res.json();
+ const [regRes, groupsRes] = await Promise.all([
+ fetch("/api/settings/registration", { credentials: "include" }),
+ fetch("/api/permission-groups", { credentials: "include" }),
+ ]);
+ if (!regRes.ok) return;
+ const data = await regRes.json();
const reg = data.registration || {};
const modeEl = document.getElementById("reg-mode");
const roleEl = document.getElementById("reg-default-role");
@@ -1350,6 +1373,27 @@ async function loadRegistrationSettings() {
if (modeEl) modeEl.value = reg.mode || "off";
if (roleEl) roleEl.value = reg.defaultRole || "operator";
if (maxEl) maxEl.value = reg.maxUsersTotal ?? 0;
+
+ const listEl = document.getElementById("reg-default-groups-list");
+ if (listEl) {
+ const groups = groupsRes.ok ? (await groupsRes.json()).groups || [] : [];
+ const selected = new Set(reg.defaultGroupIds || []);
+ listEl.innerHTML = groups.length === 0
+ ? `No groups defined yet. Create one on the Users page.
`
+ : groups
+ .map(
+ (g) => `
+ `,
+ )
+ .join("");
+ }
+
updateRegSubsections(reg.mode || "off");
if (reg.mode === "key") loadRegistrationKeys();
if (reg.mode === "approval") loadPendingRegistrations();
@@ -1379,13 +1423,16 @@ async function saveRegistrationSettings(e) {
const mode = document.getElementById("reg-mode")?.value;
const defaultRole = document.getElementById("reg-default-role")?.value;
const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0;
+ const defaultGroupIds = Array.from(
+ document.querySelectorAll("#reg-default-groups-list input[type=checkbox]:checked"),
+ ).map((el) => Number(el.dataset.defaultGroup)).filter((n) => Number.isFinite(n));
try {
const res = await fetch("/api/settings/registration", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ mode, defaultRole, maxUsersTotal }),
+ body: JSON.stringify({ mode, defaultRole, maxUsersTotal, defaultGroupIds }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -1662,19 +1709,10 @@ function initThumbnailHandlers() {
async function init() {
try {
await loadCurrentUser();
+ applyPermissionVisibility();
loadPrefs();
- if (isAdmin(currentUser?.role) && exportImportSection) {
- exportImportSection.classList.remove("hidden");
- }
-
- if (canManageClientBans(currentUser?.role) && wipeOfflineSection) {
- wipeOfflineSection.classList.remove("hidden");
- }
-
- if (canManageBuilds(currentUser?.role)) {
- if (buildsSection) buildsSection.classList.remove("hidden");
- // Hide the "show all" toggle for non-admins
+ if (userHas("clients:build")) {
if (buildsShowAllWrap && !isAdmin(currentUser?.role)) {
buildsShowAllWrap.classList.add("hidden");
}
@@ -1687,14 +1725,7 @@ async function init() {
await loadChatSettings();
await loadBannedIps();
- // Registration + Build Rate Limit + Thumbnails (admin only)
- if (isAdmin(currentUser?.role)) {
- const regSection = document.getElementById("registration-section");
- const brlSection = document.getElementById("build-rate-limit-section");
- const thumbSection = document.getElementById("thumbnails-section");
- if (regSection) regSection.classList.remove("hidden");
- if (brlSection) brlSection.classList.remove("hidden");
- if (thumbSection) thumbSection.classList.remove("hidden");
+ if (userHas("system:configure")) {
await loadRegistrationSettings();
await loadBuildRateLimitSettings();
await loadThumbnailSettings();
diff --git a/Overlord-Server/public/assets/users.js b/Overlord-Server/public/assets/users.js
index fe2cc9d..fce4a68 100644
--- a/Overlord-Server/public/assets/users.js
+++ b/Overlord-Server/public/assets/users.js
@@ -231,7 +231,18 @@ function renderUsers() {
` : ''}
-
+ ` : ''}
+
`;
+ return;
+ }
+ container.innerHTML = allGroups
+ .map(
+ (g) => `
+
+
+
${escapeHtml(g.name)}
+ ${g.description ? `
${escapeHtml(g.description)}
` : ""}
+
+ ${g.permissions.length} permission${g.permissions.length === 1 ? "" : "s"}:
+ ${g.permissions.map((p) => escapeHtml(p)).join(", ") || "none"}
+
+
+
+
+
+
+
`,
+ )
+ .join("");
+}
+
+document.getElementById("groups-list")?.addEventListener("click", (e) => {
+ const btn = e.target.closest(".group-action");
+ if (!btn) return;
+ const groupId = Number(btn.dataset.groupId);
+ if (btn.dataset.action === "edit") openGroupModal(groupId);
+ else if (btn.dataset.action === "delete") deleteGroup(groupId, btn.dataset.groupName);
+});
+
+document.getElementById("new-group-btn")?.addEventListener("click", (e) => {
+ e.preventDefault();
+ openGroupModal(null);
+});
+
+function openGroupModal(groupId) {
+ editingGroupId = groupId;
+ const modal = document.getElementById("group-modal");
+ const title = document.getElementById("group-modal-title");
+ const nameInput = document.getElementById("group-name");
+ const descInput = document.getElementById("group-description");
+ const permsList = document.getElementById("group-permissions-list");
+ const errorBox = document.getElementById("group-error");
+ errorBox.classList.add("hidden");
+
+ let selected = new Set();
+ if (groupId) {
+ const g = allGroups.find((x) => x.id === groupId);
+ if (g) {
+ title.textContent = `Edit Group: ${g.name}`;
+ nameInput.value = g.name;
+ descInput.value = g.description || "";
+ selected = new Set(g.permissions);
+ }
+ } else {
+ title.textContent = "New Group";
+ nameInput.value = "";
+ descInput.value = "";
+ }
+
+ permsList.innerHTML = allPermissions
+ .map(
+ (p) => `
+ `,
+ )
+ .join("");
+
+ modal.classList.remove("hidden");
+}
+
+function closeGroupModal() {
+ document.getElementById("group-modal")?.classList.add("hidden");
+ editingGroupId = null;
+}
+document.getElementById("close-group-modal")?.addEventListener("click", closeGroupModal);
+document.getElementById("group-cancel-btn")?.addEventListener("click", closeGroupModal);
+
+document.getElementById("group-form")?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const name = document.getElementById("group-name").value.trim();
+ const description = document.getElementById("group-description").value.trim();
+ const permissions = Array.from(
+ document.querySelectorAll("#group-permissions-list input[type=checkbox]:checked"),
+ ).map((el) => el.dataset.perm);
+ const errorBox = document.getElementById("group-error");
+ errorBox.classList.add("hidden");
+
+ try {
+ const url = editingGroupId
+ ? `/api/permission-groups/${editingGroupId}`
+ : "/api/permission-groups";
+ const method = editingGroupId ? "PATCH" : "POST";
+ const res = await fetch(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name, description, permissions }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ errorBox.textContent = data.error || "Failed to save group";
+ errorBox.classList.remove("hidden");
+ return;
+ }
+ closeGroupModal();
+ await loadPermissionsAndGroups();
+ } catch (err) {
+ console.error(err);
+ errorBox.textContent = "Network error";
+ errorBox.classList.remove("hidden");
+ }
+});
+
+async function deleteGroup(groupId, groupName) {
+ if (!confirm(`Delete group "${groupName}"? Users assigned to this group will lose its permissions.`)) return;
+ try {
+ const res = await fetch(`/api/permission-groups/${groupId}`, { method: "DELETE" });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ alert(data.error || "Failed to delete group");
+ return;
+ }
+ await loadPermissionsAndGroups();
+ } catch {
+ alert("Network error");
+ }
+}
+
+async function openUserPermissionsModal(userId, username) {
+ editingUserId = userId;
+ const modal = document.getElementById("user-perms-modal");
+ const title = document.getElementById("user-perms-title");
+ const groupsBox = document.getElementById("user-groups-list");
+ const extrasBox = document.getElementById("user-extras-list");
+ const errorBox = document.getElementById("user-perms-error");
+
+ title.textContent = `Permissions: ${username}`;
+ errorBox.classList.add("hidden");
+ groupsBox.innerHTML = `Loading...
`;
+ extrasBox.innerHTML = `Loading...
`;
+ modal.classList.remove("hidden");
+
+ if (allPermissions.length === 0 || allGroups.length === 0) {
+ await loadPermissionsAndGroups();
+ }
+
+ try {
+ const [groupsRes, extrasRes] = await Promise.all([
+ fetch(`/api/users/${userId}/permission-groups`),
+ fetch(`/api/users/${userId}/extra-permissions`),
+ ]);
+ const groupsData = await groupsRes.json().catch(() => ({}));
+ const extrasData = await extrasRes.json().catch(() => ({}));
+ const assignedGroups = new Set(groupsData.groupIds || []);
+ const assignedExtras = new Set(extrasData.permissions || []);
+
+ groupsBox.innerHTML = allGroups.length === 0
+ ? `No groups defined yet.
`
+ : allGroups
+ .map(
+ (g) => `
+ `,
+ )
+ .join("");
+
+ extrasBox.innerHTML = allPermissions
+ .map(
+ (p) => `
+ `,
+ )
+ .join("");
+ } catch (err) {
+ console.error(err);
+ errorBox.textContent = "Failed to load current assignments";
+ errorBox.classList.remove("hidden");
+ }
+}
+
+function closeUserPermsModal() {
+ document.getElementById("user-perms-modal")?.classList.add("hidden");
+ editingUserId = null;
+}
+document.getElementById("close-user-perms-modal")?.addEventListener("click", closeUserPermsModal);
+document.getElementById("user-perms-cancel-btn")?.addEventListener("click", closeUserPermsModal);
+
+document.getElementById("user-perms-save-btn")?.addEventListener("click", async () => {
+ if (!editingUserId) return;
+ const groupIds = Array.from(
+ document.querySelectorAll("#user-groups-list input[type=checkbox]:checked"),
+ ).map((el) => Number(el.dataset.group));
+ const permissions = Array.from(
+ document.querySelectorAll("#user-extras-list input[type=checkbox]:checked"),
+ ).map((el) => el.dataset.perm);
+ const errorBox = document.getElementById("user-perms-error");
+ errorBox.classList.add("hidden");
+
+ try {
+ const [gRes, eRes] = await Promise.all([
+ fetch(`/api/users/${editingUserId}/permission-groups`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ groupIds }),
+ }),
+ fetch(`/api/users/${editingUserId}/extra-permissions`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ permissions }),
+ }),
+ ]);
+ if (!gRes.ok || !eRes.ok) {
+ const data = await (gRes.ok ? eRes : gRes).json().catch(() => ({}));
+ errorBox.textContent = data.error || "Failed to save";
+ errorBox.classList.remove("hidden");
+ return;
+ }
+ closeUserPermsModal();
+ } catch {
+ errorBox.textContent = "Network error";
+ errorBox.classList.remove("hidden");
+ }
+});
+
getCurrentUser();
loadUsers();
+loadPermissionsAndGroups();
diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html
index 78d86d8..a671ed6 100644
--- a/Overlord-Server/public/settings.html
+++ b/Overlord-Server/public/settings.html
@@ -84,20 +84,20 @@
Notification Delivery
News & Updates
- Operations
- Banned IPs
- Build IDs
- Wipe Offline Clients
+ Operations
+ Banned IPs
+ Build IDs
+ Wipe Offline Clients
- Admin
- Security Policy
- Screenshot Thumbnails
- TLS Certificates
- Team Chat
- Registration
- Build Rate Limit
- Custom CSS
- Export / Import
+ Admin
+ Security Policy
+ Screenshot Thumbnails
+ TLS Certificates
+ Team Chat
+ Registration
+ Build Rate Limit
+ Custom CSS
+ Export / Import
@@ -353,7 +353,7 @@
-
+
@@ -397,7 +397,7 @@
-
+
@@ -451,7 +451,7 @@
-
+
@@ -482,7 +482,7 @@
-
+
@@ -584,7 +584,7 @@
-
+
@@ -686,7 +686,7 @@
-
+
@@ -722,7 +722,7 @@
-
+
@@ -756,6 +756,13 @@
+
+
Default permission groups for new users
+
Newly registered users automatically join these groups. Manage groups on the Users page.
+
+
@@ -823,7 +830,7 @@
-
+
@@ -863,7 +870,7 @@
-
+
@@ -897,7 +904,7 @@
-
+
@@ -935,7 +942,7 @@
-
+
diff --git a/Overlord-Server/public/users.html b/Overlord-Server/public/users.html
index 7f2b7ae..40570c4 100644
--- a/Overlord-Server/public/users.html
+++ b/Overlord-Server/public/users.html
@@ -23,6 +23,24 @@
+
+
+
+
+
Permission Groups
+
Bundles of permissions that can be assigned to users. Groups can only grant — they never revoke.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Permissions
+
+
+
Groups bundle permissions; extras grant specific permissions on top. Both are additive — they only add to what the user's role already grants.
+
+
+
+
Extra Permissions
+
+
+
+
+
+
+
+
+
+
+