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() { ` : ''} - + ` : ''} + + + + `, + ) + .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 @@ -
+ -