This commit is contained in:
KingKDot
2026-05-19 00:46:30 -05:00
parent bbd61af672
commit 116beff515
31 changed files with 2170 additions and 521 deletions
+67 -36
View File
@@ -116,6 +116,39 @@ function isAdmin(role) {
return role === "admin"; 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() { function getPasswordRequirementsText() {
const minLength = Number(securityConfig?.passwordMinLength) || 6; const minLength = Number(securityConfig?.passwordMinLength) || 6;
const requirements = [`min ${minLength} chars`]; const requirements = [`min ${minLength} chars`];
@@ -820,19 +853,6 @@ function initSettingsSidebar() {
if (!sidebar) return; if (!sidebar) return;
const links = Array.from(sidebar.querySelectorAll(".settings-nav-link")); 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. // Build the section→link map up front so click + observer share it.
const sectionMap = new Map(); const sectionMap = new Map();
// sectionsInOrder preserves document order for "last visible at page bottom" // sectionsInOrder preserves document order for "last visible at page bottom"
@@ -1340,9 +1360,12 @@ async function handleRemoveInactiveSessions() {
async function loadRegistrationSettings() { async function loadRegistrationSettings() {
try { try {
const res = await fetch("/api/settings/registration", { credentials: "include" }); const [regRes, groupsRes] = await Promise.all([
if (!res.ok) return; fetch("/api/settings/registration", { credentials: "include" }),
const data = await res.json(); fetch("/api/permission-groups", { credentials: "include" }),
]);
if (!regRes.ok) return;
const data = await regRes.json();
const reg = data.registration || {}; const reg = data.registration || {};
const modeEl = document.getElementById("reg-mode"); const modeEl = document.getElementById("reg-mode");
const roleEl = document.getElementById("reg-default-role"); const roleEl = document.getElementById("reg-default-role");
@@ -1350,6 +1373,27 @@ async function loadRegistrationSettings() {
if (modeEl) modeEl.value = reg.mode || "off"; if (modeEl) modeEl.value = reg.mode || "off";
if (roleEl) roleEl.value = reg.defaultRole || "operator"; if (roleEl) roleEl.value = reg.defaultRole || "operator";
if (maxEl) maxEl.value = reg.maxUsersTotal ?? 0; 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
? `<p class="text-slate-500 text-sm">No groups defined yet. Create one on the <a href="/users" class="text-sky-400 hover:underline">Users page</a>.</p>`
: groups
.map(
(g) => `
<label class="flex items-start gap-2 py-1 cursor-pointer hover:bg-slate-800/40 rounded px-2">
<input type="checkbox" class="mt-1 h-4 w-4 accent-emerald-500" data-default-group="${g.id}" ${selected.has(g.id) ? "checked" : ""} />
<div>
<div class="text-sm font-medium text-slate-200">${g.name.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]))}</div>
<div class="text-xs text-slate-500">${g.permissions.length} permission${g.permissions.length === 1 ? "" : "s"}</div>
</div>
</label>`,
)
.join("");
}
updateRegSubsections(reg.mode || "off"); updateRegSubsections(reg.mode || "off");
if (reg.mode === "key") loadRegistrationKeys(); if (reg.mode === "key") loadRegistrationKeys();
if (reg.mode === "approval") loadPendingRegistrations(); if (reg.mode === "approval") loadPendingRegistrations();
@@ -1379,13 +1423,16 @@ async function saveRegistrationSettings(e) {
const mode = document.getElementById("reg-mode")?.value; const mode = document.getElementById("reg-mode")?.value;
const defaultRole = document.getElementById("reg-default-role")?.value; const defaultRole = document.getElementById("reg-default-role")?.value;
const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0; 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 { try {
const res = await fetch("/api/settings/registration", { const res = await fetch("/api/settings/registration", {
method: "PUT", method: "PUT",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode, defaultRole, maxUsersTotal }), body: JSON.stringify({ mode, defaultRole, maxUsersTotal, defaultGroupIds }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -1662,19 +1709,10 @@ function initThumbnailHandlers() {
async function init() { async function init() {
try { try {
await loadCurrentUser(); await loadCurrentUser();
applyPermissionVisibility();
loadPrefs(); loadPrefs();
if (isAdmin(currentUser?.role) && exportImportSection) { if (userHas("clients:build")) {
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 (buildsShowAllWrap && !isAdmin(currentUser?.role)) { if (buildsShowAllWrap && !isAdmin(currentUser?.role)) {
buildsShowAllWrap.classList.add("hidden"); buildsShowAllWrap.classList.add("hidden");
} }
@@ -1687,14 +1725,7 @@ async function init() {
await loadChatSettings(); await loadChatSettings();
await loadBannedIps(); await loadBannedIps();
// Registration + Build Rate Limit + Thumbnails (admin only) if (userHas("system:configure")) {
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");
await loadRegistrationSettings(); await loadRegistrationSettings();
await loadBuildRateLimitSettings(); await loadBuildRateLimitSettings();
await loadThumbnailSettings(); await loadThumbnailSettings();
+290 -1
View File
@@ -231,7 +231,18 @@ function renderUsers() {
<i class="fa-solid fa-puzzle-piece"></i> <i class="fa-solid fa-puzzle-piece"></i>
</button> </button>
` : ''} ` : ''}
<button ${user.role !== "admin" ? `
<button
class="user-action-btn px-3 py-1.5 text-sm bg-violet-900/30 hover:bg-violet-900/50 text-violet-300 rounded border border-violet-800 transition-colors"
data-action="permissions"
data-user-id="${user.id}"
data-username="${escapeHtml(user.username)}"
title="Permission Groups & Extras"
>
<i class="fa-solid fa-layer-group"></i>
</button>
` : ''}
<button
class="user-action-btn px-3 py-1.5 text-sm bg-cyan-900/30 hover:bg-cyan-900/50 text-cyan-300 rounded border border-cyan-800 transition-colors" class="user-action-btn px-3 py-1.5 text-sm bg-cyan-900/30 hover:bg-cyan-900/50 text-cyan-300 rounded border border-cyan-800 transition-colors"
data-action="view-sessions" data-action="view-sessions"
data-user-id="${user.id}" data-user-id="${user.id}"
@@ -329,6 +340,9 @@ function attachActionListeners() {
case "view-sessions": case "view-sessions":
viewUserSessions(userId, username); viewUserSessions(userId, username);
break; break;
case "permissions":
openUserPermissionsModal(userId, username);
break;
} }
}; };
@@ -512,6 +526,10 @@ const FEATURE_LABELS = {
processes: { label: "Processes", icon: "fa-microchip" }, processes: { label: "Processes", icon: "fa-microchip" },
keylogger: { label: "Keylogger", icon: "fa-keyboard" }, keylogger: { label: "Keylogger", icon: "fa-keyboard" },
voice: { label: "Voice", icon: "fa-microphone" }, voice: { label: "Voice", icon: "fa-microphone" },
disconnect: { label: "Disconnect Client", icon: "fa-plug-circle-xmark" },
reconnect: { label: "Reconnect Client", icon: "fa-arrows-rotate" },
uninstall: { label: "Uninstall Agent", icon: "fa-trash-can" },
client_metadata: { label: "Edit Client Metadata", icon: "fa-pen-to-square" },
}; };
window.configureFeaturePermissions = async function (userId, username) { window.configureFeaturePermissions = async function (userId, username) {
@@ -899,5 +917,276 @@ function formatRelTime(epochSeconds) {
return `${Math.floor(diff / 86400)}d ago`; return `${Math.floor(diff / 86400)}d ago`;
} }
let allPermissions = [];
let allGroups = [];
let editingGroupId = null;
let editingUserId = null;
async function loadPermissionsAndGroups() {
try {
const [permsRes, groupsRes] = await Promise.all([
fetch("/api/permissions"),
fetch("/api/permission-groups"),
]);
if (permsRes.ok) {
const data = await permsRes.json();
allPermissions = Array.isArray(data.permissions) ? data.permissions : [];
}
if (groupsRes.ok) {
const data = await groupsRes.json();
allGroups = Array.isArray(data.groups) ? data.groups : [];
}
renderGroupsList();
} catch (err) {
console.error("Failed to load permissions/groups:", err);
}
}
function renderGroupsList() {
const container = document.getElementById("groups-list");
if (!container) return;
if (allGroups.length === 0) {
container.innerHTML = `<p class="text-slate-500 text-sm text-center py-4">No groups yet. Click "New Group" to create one.</p>`;
return;
}
container.innerHTML = allGroups
.map(
(g) => `
<div class="flex items-start justify-between gap-3 p-3 bg-slate-800/40 border border-slate-700 rounded-lg">
<div class="flex-1 min-w-0">
<div class="font-medium text-slate-100">${escapeHtml(g.name)}</div>
${g.description ? `<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(g.description)}</div>` : ""}
<div class="text-xs text-slate-500 mt-1">
${g.permissions.length} permission${g.permissions.length === 1 ? "" : "s"}:
<span class="text-slate-400">${g.permissions.map((p) => escapeHtml(p)).join(", ") || "<em>none</em>"}</span>
</div>
</div>
<div class="flex gap-1 flex-shrink-0">
<button class="group-action px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded" data-action="edit" data-group-id="${g.id}"><i class="fa-solid fa-pen"></i></button>
<button class="group-action px-2 py-1 text-xs bg-red-900/40 hover:bg-red-900/60 text-red-300 rounded" data-action="delete" data-group-id="${g.id}" data-group-name="${escapeHtml(g.name)}"><i class="fa-solid fa-trash"></i></button>
</div>
</div>`,
)
.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) => `
<label class="flex items-start gap-2 py-1 cursor-pointer hover:bg-slate-800/40 rounded px-2">
<input type="checkbox" class="mt-1 h-4 w-4 accent-violet-500" data-perm="${escapeHtml(p.id)}" ${selected.has(p.id) ? "checked" : ""} />
<div>
<div class="text-sm font-mono text-slate-200">${escapeHtml(p.id)}</div>
<div class="text-xs text-slate-500">${escapeHtml(p.description)}</div>
</div>
</label>`,
)
.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 = `<p class="text-slate-500 text-sm">Loading...</p>`;
extrasBox.innerHTML = `<p class="text-slate-500 text-sm">Loading...</p>`;
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
? `<p class="text-slate-500 text-sm">No groups defined yet.</p>`
: allGroups
.map(
(g) => `
<label class="flex items-start gap-2 py-1 cursor-pointer hover:bg-slate-800/40 rounded px-2">
<input type="checkbox" class="mt-1 h-4 w-4 accent-violet-500" data-group="${g.id}" ${assignedGroups.has(g.id) ? "checked" : ""} />
<div>
<div class="text-sm font-medium text-slate-200">${escapeHtml(g.name)}</div>
<div class="text-xs text-slate-500">${g.permissions.length} permission${g.permissions.length === 1 ? "" : "s"}</div>
</div>
</label>`,
)
.join("");
extrasBox.innerHTML = allPermissions
.map(
(p) => `
<label class="flex items-start gap-2 py-1 cursor-pointer hover:bg-slate-800/40 rounded px-2">
<input type="checkbox" class="mt-1 h-4 w-4 accent-violet-500" data-perm="${escapeHtml(p.id)}" ${assignedExtras.has(p.id) ? "checked" : ""} />
<div>
<div class="text-sm font-mono text-slate-200">${escapeHtml(p.id)}</div>
<div class="text-xs text-slate-500">${escapeHtml(p.description)}</div>
</div>
</label>`,
)
.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(); getCurrentUser();
loadUsers(); loadUsers();
loadPermissionsAndGroups();
+31 -24
View File
@@ -84,20 +84,20 @@
<a data-target="telegram-settings" class="settings-nav-link" style="--accent: 52 211 153"><i class="fa-solid fa-bell w-4 text-emerald-400"></i><span>Notification Delivery</span></a> <a data-target="telegram-settings" class="settings-nav-link" style="--accent: 52 211 153"><i class="fa-solid fa-bell w-4 text-emerald-400"></i><span>Notification Delivery</span></a>
<a data-target="section-news" class="settings-nav-link" style="--accent: 56 189 248"><i class="fa-brands fa-telegram w-4 text-sky-400"></i><span>News &amp; Updates</span></a> <a data-target="section-news" class="settings-nav-link" style="--accent: 56 189 248"><i class="fa-brands fa-telegram w-4 text-sky-400"></i><span>News &amp; Updates</span></a>
<span class="settings-nav-group-label" data-admin-or-operator>Operations</span> <span class="settings-nav-group-label">Operations</span>
<a data-target="section-bans" class="settings-nav-link" style="--accent: 248 113 113"><i class="fa-solid fa-ban w-4 text-red-400"></i><span>Banned IPs</span></a> <a data-target="section-bans" class="settings-nav-link" data-permission="network:manage-bans" style="--accent: 248 113 113"><i class="fa-solid fa-ban w-4 text-red-400"></i><span>Banned IPs</span></a>
<a data-target="section-builds" class="settings-nav-link" data-admin-or-operator style="--accent: 251 146 60"><i class="fa-solid fa-hammer w-4 text-orange-400"></i><span>Build IDs</span></a> <a data-target="section-builds" class="settings-nav-link" data-permission="clients:build" style="--accent: 251 146 60"><i class="fa-solid fa-hammer w-4 text-orange-400"></i><span>Build IDs</span></a>
<a data-target="wipe-offline-section" class="settings-nav-link" data-admin-or-operator style="--accent: 248 113 113"><i class="fa-solid fa-circle-minus w-4 text-red-400"></i><span>Wipe Offline Clients</span></a> <a data-target="wipe-offline-section" class="settings-nav-link" data-permission="clients:control" style="--accent: 248 113 113"><i class="fa-solid fa-circle-minus w-4 text-red-400"></i><span>Wipe Offline Clients</span></a>
<span class="settings-nav-group-label" data-admin-only>Admin</span> <span class="settings-nav-group-label">Admin</span>
<a data-target="section-security" class="settings-nav-link" data-admin-only style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a> <a data-target="section-security" class="settings-nav-link" data-permission="system:configure" style="--accent: 129 140 248"><i class="fa-solid fa-shield-halved w-4 text-indigo-400"></i><span>Security Policy</span></a>
<a data-target="thumbnails-section" class="settings-nav-link" data-admin-only style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a> <a data-target="thumbnails-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-images w-4 text-sky-400"></i><span>Screenshot Thumbnails</span></a>
<a data-target="section-tls" class="settings-nav-link" data-admin-only style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a> <a data-target="section-tls" class="settings-nav-link" data-permission="system:configure" style="--accent: 34 211 238"><i class="fa-solid fa-certificate w-4 text-cyan-400"></i><span>TLS Certificates</span></a>
<a data-target="chat-settings-section" class="settings-nav-link" data-admin-only style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a> <a data-target="chat-settings-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 56 189 248"><i class="fa-solid fa-comments w-4 text-sky-400"></i><span>Team Chat</span></a>
<a data-target="registration-section" class="settings-nav-link" data-admin-only style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a> <a data-target="registration-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 52 211 153"><i class="fa-solid fa-user-plus w-4 text-emerald-400"></i><span>Registration</span></a>
<a data-target="build-rate-limit-section" class="settings-nav-link" data-admin-only style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a> <a data-target="build-rate-limit-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 146 60"><i class="fa-solid fa-gauge-high w-4 text-orange-400"></i><span>Build Rate Limit</span></a>
<a data-target="section-appearance" class="settings-nav-link" data-admin-only style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a> <a data-target="section-appearance" class="settings-nav-link" data-permission="system:configure" style="--accent: 232 121 249"><i class="fa-solid fa-paintbrush w-4 text-fuchsia-400"></i><span>Custom CSS</span></a>
<a data-target="export-import-section" class="settings-nav-link" data-admin-only style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a> <a data-target="export-import-section" class="settings-nav-link" data-permission="system:configure" style="--accent: 251 191 36"><i class="fa-solid fa-file-export w-4 text-amber-400"></i><span>Export / Import</span></a>
</nav> </nav>
</aside> </aside>
@@ -353,7 +353,7 @@
<!-- ========== OPERATIONS ========== --> <!-- ========== OPERATIONS ========== -->
<!-- Banned IPs --> <!-- Banned IPs -->
<section id="section-bans" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="section-bans" data-permission="network:manage-bans" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30">
@@ -397,7 +397,7 @@
</section> </section>
<!-- Build IDs (operator/admin) --> <!-- Build IDs (operator/admin) -->
<section id="section-builds" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="section-builds" data-permission="clients:build" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
@@ -451,7 +451,7 @@
</section> </section>
<!-- Wipe Offline Clients --> <!-- Wipe Offline Clients -->
<section id="wipe-offline-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="wipe-offline-section" data-permission="clients:control" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-red-500/10 border border-red-500/30">
<i class="fa-solid fa-circle-minus text-red-400"></i> <i class="fa-solid fa-circle-minus text-red-400"></i>
@@ -482,7 +482,7 @@
<!-- ========== ADMIN ========== --> <!-- ========== ADMIN ========== -->
<!-- Security Policy --> <!-- Security Policy -->
<section id="section-security" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="section-security" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/30">
<i class="fa-solid fa-shield-halved text-indigo-400"></i> <i class="fa-solid fa-shield-halved text-indigo-400"></i>
@@ -584,7 +584,7 @@
</section> </section>
<!-- TLS Certificates --> <!-- TLS Certificates -->
<section id="section-tls" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="section-tls" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-cyan-500/10 border border-cyan-500/30">
<i class="fa-solid fa-certificate text-cyan-400"></i> <i class="fa-solid fa-certificate text-cyan-400"></i>
@@ -686,7 +686,7 @@
</section> </section>
<!-- Team Chat --> <!-- Team Chat -->
<section id="chat-settings-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="chat-settings-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
<i class="fa-solid fa-comments text-sky-400"></i> <i class="fa-solid fa-comments text-sky-400"></i>
@@ -722,7 +722,7 @@
</section> </section>
<!-- Registration Settings (admin only) --> <!-- Registration Settings (admin only) -->
<section id="registration-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="registration-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<i class="fa-solid fa-user-plus text-emerald-400"></i> <i class="fa-solid fa-user-plus text-emerald-400"></i>
@@ -756,6 +756,13 @@
<input id="reg-max-users" type="number" min="0" max="10000" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" /> <input id="reg-max-users" type="number" min="0" max="10000" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
</label> </label>
</div> </div>
<div>
<span class="text-sm text-slate-300">Default permission groups for new users</span>
<p class="text-xs text-slate-500 mb-2">Newly registered users automatically join these groups. Manage groups on the <a href="/users" class="text-sky-400 hover:underline">Users page</a>.</p>
<div id="reg-default-groups-list" class="space-y-1 max-h-40 overflow-y-auto border border-slate-700 rounded-lg p-2 bg-slate-900/50">
<p class="text-slate-500 text-sm">Loading...</p>
</div>
</div>
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium"> <button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium">
<i class="fa-solid fa-floppy-disk"></i>Save Registration Settings <i class="fa-solid fa-floppy-disk"></i>Save Registration Settings
</button> </button>
@@ -823,7 +830,7 @@
</section> </section>
<!-- Screenshot Thumbnails (admin only) --> <!-- Screenshot Thumbnails (admin only) -->
<section id="thumbnails-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="thumbnails-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/30">
<i class="fa-solid fa-images text-sky-400"></i> <i class="fa-solid fa-images text-sky-400"></i>
@@ -863,7 +870,7 @@
</section> </section>
<!-- Build Rate Limit (admin only) --> <!-- Build Rate Limit (admin only) -->
<section id="build-rate-limit-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="build-rate-limit-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
<i class="fa-solid fa-gauge-high text-orange-400"></i> <i class="fa-solid fa-gauge-high text-orange-400"></i>
@@ -897,7 +904,7 @@
</section> </section>
<!-- Custom CSS --> <!-- Custom CSS -->
<section id="section-appearance" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="section-appearance" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-fuchsia-500/10 border border-fuchsia-500/30">
<i class="fa-solid fa-paintbrush text-fuchsia-400"></i> <i class="fa-solid fa-paintbrush text-fuchsia-400"></i>
@@ -935,7 +942,7 @@
</section> </section>
<!-- Export & Import --> <!-- Export & Import -->
<section id="export-import-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"> <section id="export-import-section" data-permission="system:configure" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
<i class="fa-solid fa-file-export text-amber-400"></i> <i class="fa-solid fa-file-export text-amber-400"></i>
+79
View File
@@ -23,6 +23,24 @@
<div class="min-h-full flex flex-col"> <div class="min-h-full flex flex-col">
<!-- Main Content --> <!-- Main Content -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8"> <main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8">
<!-- Permission Groups Manager -->
<details class="mb-6 bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
<summary class="cursor-pointer px-6 py-4 flex items-center justify-between hover:bg-slate-800/30 transition-colors">
<div>
<h2 class="text-xl font-bold text-slate-100"><i class="fa-solid fa-layer-group mr-2 text-violet-400"></i>Permission Groups</h2>
<p class="text-slate-400 text-sm">Bundles of permissions that can be assigned to users. Groups can only grant — they never revoke.</p>
</div>
<button id="new-group-btn" type="button" class="px-3 py-1.5 bg-violet-700 hover:bg-violet-600 text-white rounded-lg text-sm font-medium" onclick="event.preventDefault(); event.stopPropagation();">
<i class="fa-solid fa-plus mr-1"></i>New Group
</button>
</summary>
<div class="border-t border-slate-800 p-4">
<div id="groups-list" class="space-y-2">
<p class="text-slate-500 text-sm text-center py-4">Loading groups...</p>
</div>
</div>
</details>
<!-- Actions Bar --> <!-- Actions Bar -->
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div> <div>
@@ -195,6 +213,67 @@
</div> </div>
</div> </div>
<!-- Group Editor Modal -->
<div id="group-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-2xl w-full p-6 shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 id="group-modal-title" class="text-xl font-bold">Edit Group</h3>
<button id="close-group-modal" class="text-slate-400 hover:text-slate-200"><i class="fa-solid fa-times text-xl"></i></button>
</div>
<form id="group-form" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">Name</label>
<input id="group-name" type="text" required maxlength="64" class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">Description (optional)</label>
<input id="group-description" type="text" maxlength="256" class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">Permissions</label>
<div id="group-permissions-list" class="space-y-1 max-h-72 overflow-y-auto border border-slate-700 rounded-lg p-3 bg-slate-950/50">
<p class="text-slate-500 text-sm">Loading...</p>
</div>
</div>
<div id="group-error" class="hidden p-3 bg-red-900/20 border border-red-800 rounded-lg text-red-400 text-sm"></div>
<div class="flex gap-3 pt-2">
<button type="button" id="group-cancel-btn" class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700">Cancel</button>
<button type="submit" class="flex-1 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg font-medium">Save</button>
</div>
</form>
</div>
</div>
<!-- User Permissions Modal -->
<div id="user-perms-modal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-2xl w-full p-6 shadow-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 id="user-perms-title" class="text-xl font-bold">Permissions</h3>
<button id="close-user-perms-modal" class="text-slate-400 hover:text-slate-200"><i class="fa-solid fa-times text-xl"></i></button>
</div>
<p class="text-sm text-slate-400 mb-4">Groups bundle permissions; extras grant specific permissions on top. Both are additive — they only add to what the user's role already grants.</p>
<div class="space-y-4">
<div>
<h4 class="text-sm font-semibold text-slate-300 mb-2">Groups</h4>
<div id="user-groups-list" class="space-y-1 max-h-48 overflow-y-auto border border-slate-700 rounded-lg p-3 bg-slate-950/50">
<p class="text-slate-500 text-sm">Loading...</p>
</div>
</div>
<div>
<h4 class="text-sm font-semibold text-slate-300 mb-2">Extra Permissions</h4>
<div id="user-extras-list" class="space-y-1 max-h-72 overflow-y-auto border border-slate-700 rounded-lg p-3 bg-slate-950/50">
<p class="text-slate-500 text-sm">Loading...</p>
</div>
</div>
</div>
<div id="user-perms-error" class="hidden mt-3 p-3 bg-red-900/20 border border-red-800 rounded-lg text-red-400 text-sm"></div>
<div class="flex gap-3 pt-4">
<button type="button" id="user-perms-cancel-btn" class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700">Cancel</button>
<button type="button" id="user-perms-save-btn" class="flex-1 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg font-medium">Save</button>
</div>
</div>
</div>
<script type="module" src="/assets/nav.js"></script> <script type="module" src="/assets/nav.js"></script>
<script type="module" src="/assets/users.js"></script> <script type="module" src="/assets/users.js"></script>
</body> </body>
+10
View File
@@ -72,6 +72,7 @@ export interface Config {
mode: "off" | "open" | "key" | "approval"; mode: "off" | "open" | "key" | "approval";
defaultRole: "operator" | "viewer"; defaultRole: "operator" | "viewer";
maxUsersTotal: number; maxUsersTotal: number;
defaultGroupIds: number[];
}; };
buildRateLimit: { buildRateLimit: {
maxBuildsPerHour: number; maxBuildsPerHour: number;
@@ -155,6 +156,7 @@ const DEFAULT_CONFIG: Config = {
mode: "off" as const, mode: "off" as const,
defaultRole: "operator" as const, defaultRole: "operator" as const,
maxUsersTotal: 0, maxUsersTotal: 0,
defaultGroupIds: [] as number[],
}, },
buildRateLimit: { buildRateLimit: {
maxBuildsPerHour: 5, maxBuildsPerHour: 5,
@@ -546,6 +548,11 @@ export function loadConfig(): Config {
Number(process.env.OVERLORD_REGISTRATION_MAX_USERS) || Number(process.env.OVERLORD_REGISTRATION_MAX_USERS) ||
fileConfig.registration?.maxUsersTotal || fileConfig.registration?.maxUsersTotal ||
DEFAULT_CONFIG.registration.maxUsersTotal, DEFAULT_CONFIG.registration.maxUsersTotal,
defaultGroupIds: Array.isArray(fileConfig.registration?.defaultGroupIds)
? fileConfig.registration.defaultGroupIds
.map((g: unknown) => Number(g))
.filter((g: number) => Number.isFinite(g) && g > 0)
: [...DEFAULT_CONFIG.registration.defaultGroupIds],
}, },
buildRateLimit: { buildRateLimit: {
maxBuildsPerHour: maxBuildsPerHour:
@@ -834,6 +841,9 @@ export async function updateRegistrationConfig(
next.defaultRole = current.registration.defaultRole; next.defaultRole = current.registration.defaultRole;
} }
next.maxUsersTotal = Math.max(0, Math.min(100000, Number(next.maxUsersTotal) || 0)); next.maxUsersTotal = Math.max(0, Math.min(100000, Number(next.maxUsersTotal) || 0));
next.defaultGroupIds = Array.isArray(next.defaultGroupIds)
? Array.from(new Set(next.defaultGroupIds.map((g) => Number(g)).filter((g) => Number.isFinite(g) && g > 0)))
: [...current.registration.defaultGroupIds];
configCache = { configCache = {
...current, ...current,
+4 -1
View File
@@ -13,7 +13,8 @@ import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "./auth"; import { authenticateRequest } from "./auth";
import { loadConfig, getConfig } from "./config"; import { loadConfig, getConfig } from "./config";
import { flushAuditLogsSync } from "./auditLog"; import { flushAuditLogsSync } from "./auditLog";
import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClientOwnership, isClientOwnedByUser, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope, hasPermission } from "./users"; import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClientOwnership, isClientOwnedByUser, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope } from "./users";
import { hasPermission } from "./rbac";
import { requireAuth, requirePermission } from "./rbac"; import { requireAuth, requirePermission } from "./rbac";
import { metrics } from "./metrics"; import { metrics } from "./metrics";
import { ensureDataDir } from "./paths"; import { ensureDataDir } from "./paths";
@@ -35,6 +36,7 @@ import { handlePageRoutes } from "./server/routes/page-routes";
import { handlePluginRoutes } from "./server/routes/plugin-routes"; import { handlePluginRoutes } from "./server/routes/plugin-routes";
import { handleFileShareRoutes } from "./server/routes/file-share-routes"; import { handleFileShareRoutes } from "./server/routes/file-share-routes";
import { handleUsersRoutes } from "./server/routes/users-routes"; import { handleUsersRoutes } from "./server/routes/users-routes";
import { handlePermissionGroupsRoutes } from "./server/routes/permission-groups-routes";
import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes"; import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes"; import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
import { handleWebrtcRoutes } from "./server/routes/webrtc-routes"; import { handleWebrtcRoutes } from "./server/routes/webrtc-routes";
@@ -724,6 +726,7 @@ async function startServer() {
(req, url) => handleChatRoutes(req, url), (req, url) => handleChatRoutes(req, url),
(req, url) => handleSolRoutes(req, url), (req, url) => handleSolRoutes(req, url),
(req, url, srv) => handleUsersRoutes(req, url, srv as any), (req, url, srv) => handleUsersRoutes(req, url, srv as any),
(req, url, srv) => handlePermissionGroupsRoutes(req, url, srv as any),
(req, url, srv) => handleBuildRoutes(req, url, srv as any, routeDeps.build), (req, url, srv) => handleBuildRoutes(req, url, srv as any, routeDeps.build),
(req, url, srv) => handleDeployRoutes(req, url, srv as any, routeDeps.deploy), (req, url, srv) => handleDeployRoutes(req, url, srv as any, routeDeps.deploy),
(req, url, srv) => handleWinRERoutes(req, url, srv as any, routeDeps.winre), (req, url, srv) => handleWinRERoutes(req, url, srv as any, routeDeps.winre),
@@ -0,0 +1,171 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
createPermissionGroup,
createUser,
deletePermissionGroup,
deleteUser,
getPermissionGroup,
getUserByUsername,
getUserGrantedPermissions,
getUserGroupIds,
listPermissionGroups,
setUserExtraPermissions,
setUserGroups,
updatePermissionGroup,
} from "./users";
import { hasPermission, getUserPermissions } from "./rbac";
const createdUserIds: number[] = [];
const createdGroupIds: number[] = [];
async function tempUser(role: "viewer" | "operator" | "admin") {
const username = `pg_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test");
expect(result.success).toBe(true);
const user = getUserByUsername(username);
expect(user).not.toBeNull();
createdUserIds.push(user!.id);
return user!;
}
function tempGroup(name: string, permissions: string[]) {
const result = createPermissionGroup(name + "_" + Date.now() + "_" + Math.floor(Math.random() * 1000), null, permissions, null);
expect(result.success).toBe(true);
createdGroupIds.push(result.group!.id);
return result.group!;
}
afterEach(() => {
while (createdUserIds.length > 0) {
const id = createdUserIds.pop();
if (typeof id === "number") deleteUser(id);
}
while (createdGroupIds.length > 0) {
const id = createdGroupIds.pop();
if (typeof id === "number") deletePermissionGroup(id);
}
});
describe("permission groups CRUD", () => {
test("createPermissionGroup persists the group with permissions", () => {
const group = tempGroup("crud_one", ["clients:metadata", "clients:disconnect"]);
expect(group.name).toContain("crud_one");
expect(group.permissions.sort()).toEqual(["clients:disconnect", "clients:metadata"]);
const loaded = getPermissionGroup(group.id);
expect(loaded?.permissions.sort()).toEqual(["clients:disconnect", "clients:metadata"]);
});
test("createPermissionGroup rejects duplicate names", () => {
const group = tempGroup("dup", []);
const dup = createPermissionGroup(group.name, null, [], null);
expect(dup.success).toBe(false);
expect(dup.error).toMatch(/already exists/i);
});
test("updatePermissionGroup replaces the permission set", () => {
const group = tempGroup("upd", ["clients:metadata"]);
const result = updatePermissionGroup(group.id, { permissions: ["clients:uninstall"] });
expect(result.success).toBe(true);
expect(result.group!.permissions).toEqual(["clients:uninstall"]);
});
test("listPermissionGroups returns all created groups", () => {
const a = tempGroup("list_a", ["chat:write"]);
const b = tempGroup("list_b", ["clients:metadata"]);
const ids = new Set(listPermissionGroups().map((g) => g.id));
expect(ids.has(a.id)).toBe(true);
expect(ids.has(b.id)).toBe(true);
});
});
describe("user permission groups + extras", () => {
test("setUserGroups + getUserGrantedPermissions unions group perms", async () => {
const viewer = await tempUser("viewer");
const g1 = tempGroup("gp_a", ["clients:metadata"]);
const g2 = tempGroup("gp_b", ["clients:disconnect"]);
setUserGroups(viewer.id, [g1.id, g2.id]);
const granted = getUserGrantedPermissions(viewer.id);
expect(granted.has("clients:metadata")).toBe(true);
expect(granted.has("clients:disconnect")).toBe(true);
expect(granted.has("clients:uninstall")).toBe(false);
});
test("setUserExtraPermissions adds a permission outside any group", async () => {
const viewer = await tempUser("viewer");
setUserExtraPermissions(viewer.id, ["clients:uninstall"]);
expect(getUserGrantedPermissions(viewer.id).has("clients:uninstall")).toBe(true);
});
test("hasPermission union: viewer with a group gains the perm", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("hp_a", ["clients:metadata"]);
expect(hasPermission(viewer.role, "clients:metadata", viewer.id)).toBe(false);
setUserGroups(viewer.id, [g.id]);
expect(hasPermission(viewer.role, "clients:metadata", viewer.id)).toBe(true);
});
test("groups are additive only — they cannot revoke an admin perm", async () => {
const admin = await tempUser("admin");
setUserGroups(admin.id, []);
expect(hasPermission(admin.role, "clients:metadata", admin.id)).toBe(true);
});
test("userOverride still revokes — an operator with can_build=0 keeps no clients:build via empty group", async () => {
const op = await tempUser("operator");
// Operator has clients:build by default. Removing all groups + extras does
// NOT take it away because role grants it.
setUserGroups(op.id, []);
setUserExtraPermissions(op.id, []);
expect(hasPermission(op.role, "clients:build", op.id)).toBe(true);
// Only the legacy can_build override (userOverride returning false) revokes.
const { setUserCanBuild } = await import("./users");
setUserCanBuild(op.id, false);
expect(hasPermission(op.role, "clients:build", op.id)).toBe(false);
// Now add an extra → it gets granted back.
setUserExtraPermissions(op.id, ["clients:build"]);
expect(hasPermission(op.role, "clients:build", op.id)).toBe(true);
});
test("getUserPermissions includes role + group + extra permissions for a viewer", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("up_a", ["clients:metadata"]);
setUserGroups(viewer.id, [g.id]);
setUserExtraPermissions(viewer.id, ["clients:uninstall"]);
const perms = new Set(getUserPermissions(viewer.id, viewer.role));
expect(perms.has("clients:metadata")).toBe(true);
expect(perms.has("clients:uninstall")).toBe(true);
// Viewer has no role-level perms
expect(perms.has("clients:control")).toBe(false);
expect(perms.has("users:manage")).toBe(false);
});
test("getUserGroupIds returns assigned ids", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("gid_a", []);
setUserGroups(viewer.id, [g.id]);
expect(getUserGroupIds(viewer.id)).toContain(g.id);
});
test("deleting a group removes it from users", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("del_a", ["clients:metadata"]);
setUserGroups(viewer.id, [g.id]);
expect(getUserGroupIds(viewer.id)).toContain(g.id);
deletePermissionGroup(g.id);
expect(getUserGroupIds(viewer.id)).not.toContain(g.id);
// Stop the afterEach from double-deleting
createdGroupIds.splice(createdGroupIds.indexOf(g.id), 1);
});
test("deleting a user clears their group assignments + extras from cache", async () => {
const viewer = await tempUser("viewer");
const g = tempGroup("dl_user", ["clients:metadata"]);
setUserGroups(viewer.id, [g.id]);
setUserExtraPermissions(viewer.id, ["clients:disconnect"]);
expect(getUserGrantedPermissions(viewer.id).size).toBeGreaterThan(0);
deleteUser(viewer.id);
createdUserIds.splice(createdUserIds.indexOf(viewer.id), 1);
expect(getUserGrantedPermissions(viewer.id).size).toBe(0);
});
});
+104
View File
@@ -8,6 +8,7 @@ import {
requireAnyPermission, requireAnyPermission,
getPermissionDescription, getPermissionDescription,
getRoleDescription, getRoleDescription,
getRolePermissions,
type Permission, type Permission,
} from "./rbac"; } from "./rbac";
import type { AuthenticatedUser } from "./auth"; import type { AuthenticatedUser } from "./auth";
@@ -65,6 +66,58 @@ describe("checkPermission", () => {
test("operator has audit:view", () => { test("operator has audit:view", () => {
expect(checkPermission(makeUser({ role: "operator" }), "audit:view")).toBe(true); expect(checkPermission(makeUser({ role: "operator" }), "audit:view")).toBe(true);
}); });
test("admin has scripts:manage, operator does not", () => {
expect(checkPermission(makeUser({ role: "admin" }), "scripts:manage")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "scripts:manage")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "scripts:manage")).toBe(false);
});
test("admin has deploys:manage, operator does not", () => {
expect(checkPermission(makeUser({ role: "admin" }), "deploys:manage")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "deploys:manage")).toBe(false);
});
test("admin and operator have plugins:manage", () => {
expect(checkPermission(makeUser({ role: "admin" }), "plugins:manage")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "plugins:manage")).toBe(true);
expect(checkPermission(makeUser({ role: "viewer" }), "plugins:manage")).toBe(false);
});
test("only admin has plugins:configure", () => {
expect(checkPermission(makeUser({ role: "admin" }), "plugins:configure")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "plugins:configure")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "plugins:configure")).toBe(false);
});
test("only admin has network:manage-bans", () => {
expect(checkPermission(makeUser({ role: "admin" }), "network:manage-bans")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "network:manage-bans")).toBe(false);
});
test("only admin has clients:silent-exec", () => {
expect(checkPermission(makeUser({ role: "admin" }), "clients:silent-exec")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "clients:silent-exec")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "clients:silent-exec")).toBe(false);
});
test("only admin has system:configure", () => {
expect(checkPermission(makeUser({ role: "admin" }), "system:configure")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "system:configure")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "system:configure")).toBe(false);
});
test("only admin has clients:winre", () => {
expect(checkPermission(makeUser({ role: "admin" }), "clients:winre")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "clients:winre")).toBe(false);
expect(checkPermission(makeUser({ role: "viewer" }), "clients:winre")).toBe(false);
});
test("admin and operator have clients:reconnect", () => {
expect(checkPermission(makeUser({ role: "admin" }), "clients:reconnect")).toBe(true);
expect(checkPermission(makeUser({ role: "operator" }), "clients:reconnect")).toBe(true);
expect(checkPermission(makeUser({ role: "viewer" }), "clients:reconnect")).toBe(false);
});
}); });
describe("checkAnyPermission", () => { describe("checkAnyPermission", () => {
@@ -180,7 +233,16 @@ describe("getPermissionDescription", () => {
["clients:control", "Control clients (execute commands, desktop, console, files)"], ["clients:control", "Control clients (execute commands, desktop, console, files)"],
["clients:build", "Build client binaries"], ["clients:build", "Build client binaries"],
["clients:enroll", "Manage client enrollment approvals"], ["clients:enroll", "Manage client enrollment approvals"],
["clients:silent-exec", "Silently execute arbitrary commands on clients"],
["audit:view", "View audit logs"], ["audit:view", "View audit logs"],
["chat:write", "Send messages in team chat"],
["scripts:manage", "Manage auto-run scripts"],
["deploys:manage", "Manage deploys and auto-deploys"],
["plugins:manage", "Upload, enable, and delete plugins"],
["plugins:configure", "Configure plugin trust, auto-load, and direct execution"],
["network:manage-bans", "Manage IP bans"],
["system:configure", "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)"],
["clients:winre", "Install or uninstall WinRE persistence on clients"],
]; ];
for (const [perm, desc] of cases) { for (const [perm, desc] of cases) {
@@ -194,6 +256,48 @@ describe("getPermissionDescription", () => {
}); });
}); });
describe("getRolePermissions", () => {
test("admin gets every declared permission", () => {
const perms = getRolePermissions("admin");
expect(perms).toContain("users:manage");
expect(perms).toContain("clients:control");
expect(perms).toContain("clients:build");
expect(perms).toContain("clients:enroll");
expect(perms).toContain("clients:silent-exec");
expect(perms).toContain("audit:view");
expect(perms).toContain("chat:write");
expect(perms).toContain("scripts:manage");
expect(perms).toContain("deploys:manage");
expect(perms).toContain("plugins:manage");
expect(perms).toContain("plugins:configure");
expect(perms).toContain("network:manage-bans");
expect(perms).toContain("system:configure");
expect(perms).toContain("clients:winre");
});
test("operator gets the role-eligible subset", () => {
const perms = getRolePermissions("operator");
expect(perms).toContain("clients:control");
expect(perms).toContain("clients:build");
expect(perms).toContain("clients:enroll");
expect(perms).toContain("audit:view");
expect(perms).toContain("chat:write");
expect(perms).toContain("plugins:manage");
expect(perms).not.toContain("users:manage");
expect(perms).not.toContain("scripts:manage");
expect(perms).not.toContain("deploys:manage");
expect(perms).not.toContain("plugins:configure");
expect(perms).not.toContain("network:manage-bans");
expect(perms).not.toContain("clients:silent-exec");
expect(perms).not.toContain("system:configure");
expect(perms).not.toContain("clients:winre");
});
test("viewer gets nothing", () => {
expect(getRolePermissions("viewer")).toHaveLength(0);
});
});
describe("getRoleDescription", () => { describe("getRoleDescription", () => {
test("admin description", () => { test("admin description", () => {
expect(getRoleDescription("admin")).toBe( expect(getRoleDescription("admin")).toBe(
+173 -34
View File
@@ -1,13 +1,130 @@
import { type AuthenticatedUser } from "./auth"; import { type AuthenticatedUser } from "./auth";
import { hasPermission, type UserRole } from "./users"; import {
canBuildClients,
canChatWrite,
canUserAccessClient,
canUserAccessFeature,
canUserAccessPlugin,
getUserGrantedPermissions,
type FeatureName,
type UserRole,
} from "./users";
export type Permission = type PermissionDef = {
| "users:manage" description: string;
| "clients:control" roles: readonly UserRole[];
| "clients:build" userOverride?: (userId: number, role: UserRole) => boolean;
| "clients:enroll" };
| "audit:view"
| "chat:write"; const PERMISSIONS = {
"users:manage": {
description: "Manage users and roles",
roles: ["admin"],
},
"clients:control": {
description: "Control clients (execute commands, desktop, console, files)",
roles: ["admin", "operator"],
},
"clients:build": {
description: "Build client binaries",
roles: ["admin", "operator"],
userOverride: canBuildClients,
},
"clients:enroll": {
description: "Manage client enrollment approvals",
roles: ["admin", "operator"],
},
"clients:silent-exec": {
description: "Silently execute arbitrary commands on clients",
roles: ["admin"],
},
"clients:disconnect": {
description: "Force a client to disconnect",
roles: ["admin", "operator"],
},
"clients:reconnect": {
description: "Force a client to drop its session and reconnect",
roles: ["admin", "operator"],
},
"clients:metadata": {
description: "Edit client metadata (nickname, tag, group, bookmark, mute)",
roles: ["admin", "operator"],
},
"clients:uninstall": {
description: "Uninstall the agent on a client (removes persistence and deletes from dashboard)",
roles: ["admin", "operator"],
},
"audit:view": {
description: "View audit logs",
roles: ["admin", "operator"],
},
"chat:write": {
description: "Send messages in team chat",
roles: ["admin", "operator"],
userOverride: canChatWrite,
},
"scripts:manage": {
description: "Manage auto-run scripts",
roles: ["admin"],
},
"deploys:manage": {
description: "Manage deploys and auto-deploys",
roles: ["admin"],
},
"plugins:manage": {
description: "Upload, enable, and delete plugins",
roles: ["admin", "operator"],
},
"plugins:configure": {
description: "Configure plugin trust, auto-load, and direct execution",
roles: ["admin"],
},
"network:manage-bans": {
description: "Manage IP bans",
roles: ["admin"],
},
"system:configure": {
description: "Change server-level settings (TLS, security, registration, notifications, appearance, etc.)",
roles: ["admin"],
},
"clients:winre": {
description: "Install or uninstall WinRE persistence on clients",
roles: ["admin"],
},
} as const satisfies Record<string, PermissionDef>;
export type Permission = keyof typeof PERMISSIONS;
function lookupPermission(permission: string): PermissionDef | undefined {
return (PERMISSIONS as Record<string, PermissionDef>)[permission];
}
export function hasPermission(
role: UserRole,
permission: Permission | string,
userId?: number,
): boolean {
const def = lookupPermission(permission);
if (!def) return false;
// The legacy role-level gate. Per-permission userOverride (canBuild,
// canChatWrite) is authoritative when present + userId is known — it can
// both grant AND deny relative to the role default (an operator with
// can_build=0 loses clients:build at the role layer). Without a userOverride,
// membership in def.roles decides.
let grantedByRole: boolean;
if (def.userOverride && userId !== undefined) {
grantedByRole = def.userOverride(userId, role);
} else {
grantedByRole = def.roles.includes(role);
}
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;
}
return false;
}
export function checkPermission( export function checkPermission(
user: AuthenticatedUser | null, user: AuthenticatedUser | null,
@@ -66,23 +183,8 @@ export function requireAnyPermission(
return authedUser; return authedUser;
} }
export function getPermissionDescription(permission: Permission): string { export function getPermissionDescription(permission: Permission | string): string {
switch (permission) { return lookupPermission(permission)?.description ?? "Unknown permission";
case "users:manage":
return "Manage users and roles";
case "clients:control":
return "Control clients (execute commands, desktop, console, files)";
case "clients:build":
return "Build client binaries";
case "clients:enroll":
return "Manage client enrollment approvals";
case "audit:view":
return "View audit logs";
case "chat:write":
return "Send messages in team chat";
default:
return "Unknown permission";
}
} }
export function getRoleDescription(role: UserRole): string { export function getRoleDescription(role: UserRole): string {
@@ -98,15 +200,52 @@ export function getRoleDescription(role: UserRole): string {
} }
} }
export function requireClientAccess(
user: AuthenticatedUser | null,
clientId: string,
): AuthenticatedUser {
const authed = requireAuth(user);
if (!canUserAccessClient(authed.userId, authed.role, clientId)) {
throw new Response("Forbidden: You do not have access to this client", { status: 403 });
}
return authed;
}
export function requirePluginAccess(
user: AuthenticatedUser | null,
pluginId: string,
): AuthenticatedUser {
const authed = requireAuth(user);
if (!canUserAccessPlugin(authed.userId, authed.role, pluginId)) {
throw new Response("Forbidden: You do not have access to this plugin", { status: 403 });
}
return authed;
}
export function requireFeatureAccess(
user: AuthenticatedUser | null,
feature: FeatureName,
): AuthenticatedUser {
const authed = requireAuth(user);
if (!canUserAccessFeature(authed.userId, authed.role, feature)) {
throw new Response("Forbidden: feature access denied", { status: 403 });
}
return authed;
}
export function getRolePermissions(role: UserRole): Permission[] { export function getRolePermissions(role: UserRole): Permission[] {
const permissions: Permission[] = []; const result: Permission[] = [];
for (const perm of Object.keys(PERMISSIONS) as Permission[]) {
if (hasPermission(role, "users:manage")) permissions.push("users:manage"); const def = lookupPermission(perm);
if (hasPermission(role, "clients:control")) if (def && def.roles.includes(role)) result.push(perm);
permissions.push("clients:control"); }
if (hasPermission(role, "clients:build")) permissions.push("clients:build"); return result;
if (hasPermission(role, "clients:enroll")) permissions.push("clients:enroll"); }
if (hasPermission(role, "audit:view")) permissions.push("audit:view");
return permissions; export function getUserPermissions(userId: number, role: UserRole): Permission[] {
const result: Permission[] = [];
for (const perm of Object.keys(PERMISSIONS) as Permission[]) {
if (hasPermission(role, perm, userId)) result.push(perm);
}
return result;
} }
@@ -24,6 +24,7 @@ import {
recordSuccessfulAttempt, recordSuccessfulAttempt,
} from "../../rateLimit"; } from "../../rateLimit";
import { getUserById, canUserAccessClient, canUserAccessFeature, getUserFeaturePermissions, type FeatureName, ALL_FEATURES } from "../../users"; import { getUserById, canUserAccessClient, canUserAccessFeature, getUserFeaturePermissions, type FeatureName, ALL_FEATURES } from "../../users";
import { getUserPermissions, requirePermission } from "../../rbac";
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie"; import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
type RequestIpProvider = { type RequestIpProvider = {
@@ -214,8 +215,11 @@ export async function handleAuthRoutes(
if (!user) { if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 }); return Response.json({ error: "Not authenticated" }, { status: 401 });
} }
if (user.role !== "admin") { try {
return Response.json({ error: "Admin only" }, { status: 403 }); requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return Response.json({ error: "Forbidden" }, { status: 403 });
} }
const targetUserId = parseInt(url.pathname.split("/")[3]); const targetUserId = parseInt(url.pathname.split("/")[3]);
@@ -274,8 +278,11 @@ export async function handleAuthRoutes(
if (!user) { if (!user) {
return Response.json({ error: "Not authenticated" }, { status: 401 }); return Response.json({ error: "Not authenticated" }, { status: 401 });
} }
if (user.role !== "admin") { try {
return Response.json({ error: "Admin only" }, { status: 403 }); requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return Response.json({ error: "Forbidden" }, { status: 403 });
} }
const targetUserId = parseInt(url.pathname.split("/")[3]); const targetUserId = parseInt(url.pathname.split("/")[3]);
@@ -341,6 +348,7 @@ export async function handleAuthRoutes(
const dbUser = getUserById(user.userId); const dbUser = getUserById(user.userId);
const featurePermissions = getUserFeaturePermissions(user.userId); const featurePermissions = getUserFeaturePermissions(user.userId);
const permissions = getUserPermissions(user.userId, user.role);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -351,6 +359,7 @@ export async function handleAuthRoutes(
canBuild: dbUser ? Boolean(dbUser.can_build) : false, canBuild: dbUser ? Boolean(dbUser.can_build) : false,
telegramChatId: dbUser?.telegram_chat_id || "", telegramChatId: dbUser?.telegram_chat_id || "",
featurePermissions, featurePermissions,
permissions,
}), }),
{ {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -50,8 +50,11 @@ export async function handleAutoDeployRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let form: FormData; let form: FormData;
@@ -123,8 +126,11 @@ export async function handleAutoDeployRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -168,8 +174,11 @@ export async function handleAutoDeployRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const deploy = getAutoDeploy(autoDeployMatch[1]); const deploy = getAutoDeploy(autoDeployMatch[1]);
@@ -40,8 +40,11 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "scripts:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -95,8 +98,11 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "scripts:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -147,8 +153,11 @@ export async function handleAutoScriptsRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "scripts:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
deleteAutoScript(autoScriptMatch[1]); deleteAutoScript(autoScriptMatch[1]);
@@ -852,10 +852,7 @@ export async function handleBuildRoutes(
} }
if (req.method === "POST" && url.pathname === "/api/build/update-all") { if (req.method === "POST" && url.pathname === "/api/build/update-all") {
requirePermission(user, "clients:build"); requirePermission(user, "deploys:manage");
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
let body: any = {}; let body: any = {};
try { try {
@@ -1,7 +1,7 @@
import { authenticateRequest } from "../../auth"; import { authenticateRequest } from "../../auth";
import { getConfig } from "../../config"; import { getConfig } from "../../config";
import { getChatHistory, getOnlineClientCountForUser } from "../../db"; import { getChatHistory, getOnlineClientCountForUser } from "../../db";
import { hasPermission } from "../../users"; import { hasPermission } from "../../rbac";
export async function handleChatRoutes( export async function handleChatRoutes(
req: Request, req: Request,
@@ -29,7 +29,7 @@ import {
} from "../../db"; } from "../../db";
import { metrics } from "../../metrics"; import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol"; import { encodeMessage } from "../../protocol";
import { requirePermission } from "../../rbac"; import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac";
import { import {
canUserAccessClient, canUserAccessClient,
getUserClientAccessScope, getUserClientAccessScope,
@@ -158,8 +158,11 @@ export async function handleClientRoutes(
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "network:manage-bans");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (req.method === "GET") { if (req.method === "GET") {
@@ -208,8 +211,11 @@ export async function handleClientRoutes(
} }
const targetId = banMatch[1]; const targetId = banMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const target = clientManager.getClient(targetId); const target = clientManager.getClient(targetId);
const targetIp = target?.ip || getClientIp(targetId); const targetIp = target?.ip || getClientIp(targetId);
@@ -247,8 +253,11 @@ export async function handleClientRoutes(
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
const clientId = thumbnailMatch[1]; const clientId = thumbnailMatch[1];
if (!canUserAccessClient(user.userId, user.role, clientId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const { requestThumbnailRegen, markThumbnailRequested, getThumbnailVersion, waitForThumbnail, clearThumbnailRequest } = await import("../../thumbnails"); const { requestThumbnailRegen, markThumbnailRequested, getThumbnailVersion, waitForThumbnail, clearThumbnailRequest } = await import("../../thumbnails");
markThumbnailRequested(clientId); markThumbnailRequested(clientId);
@@ -286,8 +295,11 @@ export async function handleClientRoutes(
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
const clientId = thumbnailMatch[1]; const clientId = thumbnailMatch[1];
if (!canUserAccessClient(user.userId, user.role, clientId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const { getThumbnailRecord } = await import("../../thumbnails"); const { getThumbnailRecord } = await import("../../thumbnails");
const record = getThumbnailRecord(clientId); const record = getThumbnailRecord(clientId);
@@ -349,8 +361,11 @@ export async function handleClientRoutes(
} }
const targetId = clientDeleteMatch[1]; const targetId = clientDeleteMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const target = clientManager.getClient(targetId); const target = clientManager.getClient(targetId);
const isOnlineInDb = getClientOnlineState(targetId); const isOnlineInDb = getClientOnlineState(targetId);
@@ -399,15 +414,19 @@ export async function handleClientRoutes(
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
try { try {
requirePermission(user, "clients:control"); requirePermission(user, "clients:metadata");
requireFeatureAccess(user, "client_metadata");
} catch (error) { } catch (error) {
if (error instanceof Response) return error; if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
const targetId = clientNicknameMatch[1]; const targetId = clientNicknameMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (!clientExists(targetId)) { if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 }); return Response.json({ error: "Client not found" }, { status: 404 });
@@ -456,15 +475,19 @@ export async function handleClientRoutes(
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
try { try {
requirePermission(user, "clients:control"); requirePermission(user, "clients:metadata");
requireFeatureAccess(user, "client_metadata");
} catch (error) { } catch (error) {
if (error instanceof Response) return error; if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
const targetId = clientTagMatch[1]; const targetId = clientTagMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (!clientExists(targetId)) { if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 }); return Response.json({ error: "Client not found" }, { status: 404 });
@@ -517,7 +540,7 @@ export async function handleClientRoutes(
if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-notifications-muted") { if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-notifications-muted") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) { try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) {
if (error instanceof Response) return error; if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
@@ -555,14 +578,17 @@ export async function handleClientRoutes(
if (req.method === "PATCH" && muteMatch) { if (req.method === "PATCH" && muteMatch) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) { try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) {
if (error instanceof Response) return error; if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
const targetId = muteMatch[1]; const targetId = muteMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (!clientExists(targetId)) { if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 }); return Response.json({ error: "Client not found" }, { status: 404 });
@@ -602,8 +628,11 @@ export async function handleClientRoutes(
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
const targetId = bookmarkMatch[1]; const targetId = bookmarkMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (!clientExists(targetId)) { if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 }); return Response.json({ error: "Client not found" }, { status: 404 });
@@ -639,8 +668,11 @@ export async function handleClientRoutes(
} }
const targetId = cmdMatch[1]; const targetId = cmdMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const target = clientManager.getClient(targetId); const target = clientManager.getClient(targetId);
const ip = server.requestIP(req)?.address || "unknown"; const ip = server.requestIP(req)?.address || "unknown";
@@ -661,6 +693,13 @@ export async function handleClientRoutes(
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
} }
} else if (action === "disconnect") { } else if (action === "disconnect") {
try {
requirePermission(user, "clients:disconnect");
requireFeatureAccess(user, "disconnect");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
target.ws.send(encodeMessage({ type: "command", commandType: "disconnect", id: uuidv4() })); target.ws.send(encodeMessage({ type: "command", commandType: "disconnect", id: uuidv4() }));
metrics.recordCommand("disconnect"); metrics.recordCommand("disconnect");
logAudit({ logAudit({
@@ -672,6 +711,13 @@ export async function handleClientRoutes(
success: true, success: true,
}); });
} else if (action === "reconnect") { } else if (action === "reconnect") {
try {
requirePermission(user, "clients:reconnect");
requireFeatureAccess(user, "reconnect");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
target.ws.send(encodeMessage({ type: "command", commandType: "reconnect", id: uuidv4() })); target.ws.send(encodeMessage({ type: "command", commandType: "reconnect", id: uuidv4() }));
metrics.recordCommand("reconnect"); metrics.recordCommand("reconnect");
logAudit({ logAudit({
@@ -774,8 +820,11 @@ export async function handleClientRoutes(
return Response.json({ ok: false, error: error.message || "Voice capability probe failed" }, { status: 504 }); return Response.json({ ok: false, error: error.message || "Voice capability probe failed" }, { status: 504 });
} }
} else if (action === "silent_exec") { } else if (action === "silent_exec") {
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "clients:silent-exec");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const command = typeof body?.command === "string" ? body.command.trim() : ""; const command = typeof body?.command === "string" ? body.command.trim() : "";
@@ -806,6 +855,13 @@ export async function handleClientRoutes(
details: JSON.stringify({ command, args, cwd }), details: JSON.stringify({ command, args, cwd }),
}); });
} else if (action === "uninstall") { } else if (action === "uninstall") {
try {
requirePermission(user, "clients:uninstall");
requireFeatureAccess(user, "uninstall");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
target.ws.send(encodeMessage({ type: "command", commandType: "uninstall", id: uuidv4() })); target.ws.send(encodeMessage({ type: "command", commandType: "uninstall", id: uuidv4() }));
metrics.recordCommand("uninstall"); metrics.recordCommand("uninstall");
clientManager.deleteClient(targetId); clientManager.deleteClient(targetId);
@@ -821,8 +877,11 @@ export async function handleClientRoutes(
success: true, success: true,
}); });
} else if (action === "elevate") { } else if (action === "elevate") {
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "clients:silent-exec");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const password = typeof body?.password === "string" ? body.password : ""; const password = typeof body?.password === "string" ? body.password : "";
@@ -980,7 +1039,7 @@ export async function handleClientRoutes(
if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-group") { if (req.method === "PATCH" && url.pathname === "/api/clients/bulk-group") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) { try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) {
if (error instanceof Response) return error; if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
@@ -1019,14 +1078,17 @@ export async function handleClientRoutes(
if (req.method === "PATCH" && clientGroupMatch) { if (req.method === "PATCH" && clientGroupMatch) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
try { requirePermission(user, "clients:control"); } catch (error) { try { requirePermission(user, "clients:metadata"); requireFeatureAccess(user, "client_metadata"); } catch (error) {
if (error instanceof Response) return error; if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
const targetId = clientGroupMatch[1]; const targetId = clientGroupMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: Client access denied", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (!clientExists(targetId)) { if (!clientExists(targetId)) {
return Response.json({ error: "Client not found" }, { status: 404 }); return Response.json({ error: "Client not found" }, { status: 404 });
@@ -7,6 +7,7 @@ import { AuditAction, logAudit } from "../../auditLog";
import * as clientManager from "../../clientManager"; import * as clientManager from "../../clientManager";
import { metrics } from "../../metrics"; import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol"; import { encodeMessage } from "../../protocol";
import { requirePermission } from "../../rbac";
import { createUploadPull } from "./file-download-routes"; import { createUploadPull } from "./file-download-routes";
type RequestIpProvider = { type RequestIpProvider = {
@@ -44,8 +45,11 @@ export async function handleDeployRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let form: FormData; let form: FormData;
@@ -87,8 +91,11 @@ export async function handleDeployRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -166,8 +173,11 @@ export async function handleDeployRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -289,8 +299,11 @@ export async function handleDeployRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "deploys:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -346,8 +346,11 @@ export async function handleEnrollmentRoutes(
if (req.method === "GET" && url.pathname === "/api/enrollment/banned-ips") { if (req.method === "GET" && url.pathname === "/api/enrollment/banned-ips") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "network:manage-bans");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
return Response.json({ items: listBannedIps() }); return Response.json({ items: listBannedIps() });
@@ -357,8 +360,11 @@ export async function handleEnrollmentRoutes(
if (req.method === "DELETE" && url.pathname === "/api/enrollment/banned-ips") { if (req.method === "DELETE" && url.pathname === "/api/enrollment/banned-ips") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "network:manage-bans");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const ipToUnban = (url.searchParams.get("ip") || "").trim(); const ipToUnban = (url.searchParams.get("ip") || "").trim();
@@ -384,8 +390,11 @@ export async function handleEnrollmentRoutes(
if (req.method === "POST" && url.pathname === "/api/enrollment/ban-ip") { if (req.method === "POST" && url.pathname === "/api/enrollment/ban-ip") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "network:manage-bans");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any; let body: any;
@@ -395,7 +395,7 @@ describe("file route feature_browser enforcement", () => {
expect(res).not.toBeNull(); expect(res).not.toBeNull();
expect(res!.status).toBe(403); expect(res!.status).toBe(403);
expect(await res!.text()).toContain("client access denied"); expect(await res!.text()).toContain("do not have access to this client");
} finally { } finally {
clientManager.deleteClient(clientId); clientManager.deleteClient(clientId);
deleteUser(auth.userId); deleteUser(auth.userId);
@@ -8,7 +8,7 @@ import { logger } from "../../logger";
import { encodeMessage } from "../../protocol"; import { encodeMessage } from "../../protocol";
import { getConfig } from "../../config"; import { getConfig } from "../../config";
import { isAuthorizedAgentRequest } from "../agent-auth"; import { isAuthorizedAgentRequest } from "../agent-auth";
import { canUserAccessClient, canUserAccessFeature } from "../../users"; import { requireClientAccess, requireFeatureAccess, requirePermission } from "../../rbac";
type RequestIpProvider = { type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined; requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -156,12 +156,18 @@ async function serveDownloadById(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin" && user.role !== "operator") { try {
return new Response("Forbidden: Admin or operator access required", { status: 403 }); requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) { try {
return new Response("Forbidden: feature access denied", { status: 403 }); requireFeatureAccess(user, "file_browser");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
logger.debug("[filebrowser] http download request", { logger.debug("[filebrowser] http download request", {
@@ -345,8 +351,11 @@ export async function handleFileDownloadRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin" && user.role !== "operator") { try {
return new Response("Forbidden: Admin or operator access required", { status: 403 }); requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -363,12 +372,12 @@ export async function handleFileDownloadRoutes(
return new Response("Bad request", { status: 400 }); return new Response("Bad request", { status: 400 });
} }
if (!canUserAccessClient(user.userId, user.role as any, clientId)) { try {
return new Response("Forbidden: client access denied", { status: 403 }); requireClientAccess(user, clientId);
} requireFeatureAccess(user, "file_browser");
} catch (error) {
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) { if (error instanceof Response) return error;
return new Response("Forbidden: feature access denied", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
logger.debug("[filebrowser] http upload request", { logger.debug("[filebrowser] http upload request", {
@@ -412,12 +421,18 @@ export async function handleFileDownloadRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin" && user.role !== "operator") { try {
return new Response("Forbidden: Admin or operator access required", { status: 403 }); requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) { try {
return new Response("Forbidden: feature access denied", { status: 403 }); requireFeatureAccess(user, "file_browser");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let uploadId = ""; let uploadId = "";
@@ -642,8 +657,11 @@ export async function handleFileDownloadRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin" && user.role !== "operator") { try {
return new Response("Forbidden: Admin or operator access required", { status: 403 }); requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -659,12 +677,12 @@ export async function handleFileDownloadRoutes(
return new Response("Bad request", { status: 400 }); return new Response("Bad request", { status: 400 });
} }
if (!canUserAccessClient(user.userId, user.role as any, clientId)) { try {
return new Response("Forbidden: client access denied", { status: 403 }); requireClientAccess(user, clientId);
} requireFeatureAccess(user, "file_browser");
} catch (error) {
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) { if (error instanceof Response) return error;
return new Response("Forbidden: feature access denied", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
const target = clientManager.getClient(clientId); const target = clientManager.getClient(clientId);
@@ -110,8 +110,11 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (req.method === "GET") { if (req.method === "GET") {
@@ -157,8 +160,11 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (req.method === "GET") { if (req.method === "GET") {
@@ -209,8 +215,11 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -447,8 +456,11 @@ export async function handleMiscRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const exportData = getExportableConfig(deps.SERVER_VERSION); const exportData = getExportableConfig(deps.SERVER_VERSION);
@@ -477,8 +489,11 @@ export async function handleMiscRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -526,8 +541,11 @@ export async function handleMiscRoutes(
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (req.method === "GET") { if (req.method === "GET") {
@@ -564,8 +582,11 @@ export async function handleMiscRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (req.method === "GET") { if (req.method === "GET") {
@@ -627,7 +648,7 @@ export async function handleMiscRoutes(
if (url.pathname === "/api/settings/registration") { if (url.pathname === "/api/settings/registration") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 }); try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (req.method === "GET") { if (req.method === "GET") {
return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS }); return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS });
@@ -643,6 +664,7 @@ export async function handleMiscRoutes(
mode: body?.mode, mode: body?.mode,
defaultRole: body?.defaultRole, defaultRole: body?.defaultRole,
maxUsersTotal: body?.maxUsersTotal !== undefined ? Number(body.maxUsersTotal) : undefined, maxUsersTotal: body?.maxUsersTotal !== undefined ? Number(body.maxUsersTotal) : undefined,
defaultGroupIds: Array.isArray(body?.defaultGroupIds) ? body.defaultGroupIds : undefined,
}); });
logAudit({ logAudit({
@@ -662,7 +684,7 @@ export async function handleMiscRoutes(
if (url.pathname === "/api/settings/build-rate-limit") { if (url.pathname === "/api/settings/build-rate-limit") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 }); try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
if (req.method === "GET") { if (req.method === "GET") {
return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS }); return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS });
@@ -703,7 +725,7 @@ export async function handleMiscRoutes(
} }
if (req.method === "PUT") { if (req.method === "PUT") {
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 }); try { requirePermission(user, "system:configure"); } catch (error) { if (error instanceof Response) return error; return new Response("Forbidden", { status: 403 }); }
let body: any = {}; let body: any = {};
try { body = await req.json(); } catch { try { body = await req.json(); } catch {
@@ -9,6 +9,7 @@ import {
getUserNotificationSettings, getUserNotificationSettings,
updateUserNotificationSettings, updateUserNotificationSettings,
} from "../../users"; } from "../../users";
import { requirePermission } from "../../rbac";
import { import {
DEFAULT_WEBHOOK_TEMPLATE, DEFAULT_WEBHOOK_TEMPLATE,
DEFAULT_TELEGRAM_TEMPLATE, DEFAULT_TELEGRAM_TEMPLATE,
@@ -76,8 +77,11 @@ export async function handleNotificationsConfigRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
return Response.json({ notifications: getConfig().notifications }); return Response.json({ notifications: getConfig().notifications });
} }
@@ -90,10 +94,11 @@ export async function handleNotificationsConfigRoutes(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { requirePermission(user, "system:configure");
status: 403, } catch (error) {
}); if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -0,0 +1,258 @@
import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import {
createPermissionGroup,
deletePermissionGroup,
getPermissionGroup,
getUserById,
getUserExtraPermissions,
getUserGroupIds,
listPermissionGroups,
setUserExtraPermissions,
setUserGroups,
updatePermissionGroup,
} from "../../users";
import {
getPermissionDescription,
type Permission,
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);
return Array.from(
new Set(
raw
.filter((p): p is string => typeof p === "string")
.map((p) => p.trim())
.filter((p) => known.has(p)),
),
);
}
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
};
export async function handlePermissionGroupsRoutes(
req: Request,
url: URL,
server: RequestIpProvider,
): Promise<Response | null> {
if (req.method === "GET" && url.pathname === "/api/permissions") {
const user = await authenticateRequest(req);
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
return Response.json({
permissions: KNOWN_PERMISSIONS.map((p) => ({
id: p,
description: getPermissionDescription(p),
})),
});
}
if (req.method === "GET" && url.pathname === "/api/permission-groups") {
const user = await authenticateRequest(req);
try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
return Response.json({ groups: listPermissionGroups() });
}
if (req.method === "POST" && url.pathname === "/api/permission-groups") {
const user = await authenticateRequest(req);
let authed;
try {
authed = requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
const name = typeof body?.name === "string" ? body.name : "";
const description = typeof body?.description === "string" ? body.description : null;
const permissions = sanitizePermissions(body?.permissions);
const result = createPermissionGroup(name, description, permissions, authed.userId);
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Created permission group "${result.group!.name}" with ${result.group!.permissions.length} permissions`,
success: true,
});
return Response.json({ group: result.group });
}
const singleGroupMatch = url.pathname.match(/^\/api\/permission-groups\/(\d+)$/);
if (singleGroupMatch) {
const groupId = Number(singleGroupMatch[1]);
const user = await authenticateRequest(req);
let authed;
try {
authed = requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
const group = getPermissionGroup(groupId);
if (!group) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json({ group });
}
if (req.method === "PATCH" || req.method === "PUT") {
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
const updates: Parameters<typeof updatePermissionGroup>[1] = {};
if (body?.name !== undefined) updates.name = String(body.name);
if (body?.description !== undefined) updates.description = body.description === null ? null : String(body.description);
if (body?.permissions !== undefined) updates.permissions = sanitizePermissions(body.permissions);
const result = updatePermissionGroup(groupId, updates);
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Updated permission group "${result.group!.name}"`,
success: true,
});
return Response.json({ group: result.group });
}
if (req.method === "DELETE") {
const existing = getPermissionGroup(groupId);
const result = deletePermissionGroup(groupId);
if (!result.success) return Response.json({ error: result.error }, { status: 404 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Deleted permission group "${existing?.name ?? groupId}"`,
success: true,
});
return Response.json({ ok: true });
}
}
const userGroupsMatch = url.pathname.match(/^\/api\/users\/(\d+)\/permission-groups$/);
if (userGroupsMatch) {
const targetUserId = Number(userGroupsMatch[1]);
const user = await authenticateRequest(req);
let authed;
try {
authed = requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!getUserById(targetUserId)) {
return Response.json({ error: "User not found" }, { status: 404 });
}
if (req.method === "GET") {
return Response.json({ groupIds: getUserGroupIds(targetUserId) });
}
if (req.method === "PUT") {
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
const ids = Array.isArray(body?.groupIds)
? body.groupIds.map((g: any) => Number(g)).filter((g: number) => Number.isFinite(g))
: [];
const result = setUserGroups(targetUserId, ids);
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Assigned ${ids.length} permission group(s) to user #${targetUserId}`,
success: true,
});
return Response.json({ ok: true, groupIds: ids });
}
}
const userExtrasMatch = url.pathname.match(/^\/api\/users\/(\d+)\/extra-permissions$/);
if (userExtrasMatch) {
const targetUserId = Number(userExtrasMatch[1]);
const user = await authenticateRequest(req);
let authed;
try {
authed = requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (!getUserById(targetUserId)) {
return Response.json({ error: "User not found" }, { status: 404 });
}
if (req.method === "GET") {
return Response.json({ permissions: Array.from(getUserExtraPermissions(targetUserId)).sort() });
}
if (req.method === "PUT") {
let body: any;
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
const perms = sanitizePermissions(body?.permissions);
const result = setUserExtraPermissions(targetUserId, perms);
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
logAudit({
timestamp: Date.now(),
username: authed.username,
ip: server.requestIP(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Set ${perms.length} extra permission(s) on user #${targetUserId}`,
success: true,
});
return Response.json({ ok: true, permissions: perms });
}
}
return null;
}
@@ -4,8 +4,8 @@ import path from "path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { authenticateRequest } from "../../auth"; import { authenticateRequest } from "../../auth";
import { requirePermission } from "../../rbac"; import { requireClientAccess, requirePermission, requirePluginAccess } from "../../rbac";
import { canUserAccessClient, canUserAccessPlugin } from "../../users"; import { canUserAccessPlugin } from "../../users";
import * as clientManager from "../../clientManager"; import * as clientManager from "../../clientManager";
import { metrics } from "../../metrics"; import { metrics } from "../../metrics";
import { encodeMessage, type PluginSignatureInfo } from "../../protocol"; import { encodeMessage, type PluginSignatureInfo } from "../../protocol";
@@ -146,8 +146,11 @@ export async function handlePluginRoutes(
if (req.method === "GET" && url.pathname === "/api/plugins/trusted-keys") { if (req.method === "GET" && url.pathname === "/api/plugins/trusted-keys") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const config = getConfig(); const config = getConfig();
const allKeys = Array.from(new Set([...BUILTIN_TRUSTED_KEYS, ...config.plugins.trustedKeys])); const allKeys = Array.from(new Set([...BUILTIN_TRUSTED_KEYS, ...config.plugins.trustedKeys]));
@@ -157,8 +160,11 @@ export async function handlePluginRoutes(
if (req.method === "POST" && url.pathname === "/api/plugins/trusted-keys") { if (req.method === "POST" && url.pathname === "/api/plugins/trusted-keys") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
try { body = await req.json(); } catch {} try { body = await req.json(); } catch {}
@@ -179,8 +185,11 @@ export async function handlePluginRoutes(
if (req.method === "DELETE" && trustedKeyDeleteMatch) { if (req.method === "DELETE" && trustedKeyDeleteMatch) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const fingerprint = trustedKeyDeleteMatch[1].toLowerCase(); const fingerprint = trustedKeyDeleteMatch[1].toLowerCase();
const config = getConfig(); const config = getConfig();
@@ -203,8 +212,11 @@ export async function handlePluginRoutes(
} }
const clientId = clientPluginsMatch[1]; const clientId = clientPluginsMatch[1];
if (!canUserAccessClient(user.userId, user.role, clientId)) { try {
return new Response("Forbidden: You do not have access to this client", { status: 403 }); requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const loaded = deps.pluginLoadedByClient.get(clientId) || new Set<string>(); const loaded = deps.pluginLoadedByClient.get(clientId) || new Set<string>();
const manifests = await deps.listPluginManifests(); const manifests = await deps.listPluginManifests();
@@ -226,8 +238,11 @@ export async function handlePluginRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin" && user.role !== "operator") { try {
return new Response("Forbidden: Admin or operator access required", { status: 403 }); requirePermission(user, "plugins:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let form: FormData; let form: FormData;
@@ -321,8 +336,11 @@ export async function handlePluginRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin" && user.role !== "operator") { try {
return new Response("Forbidden: Admin or operator access required", { status: 403 }); requirePermission(user, "plugins:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let pluginId = ""; let pluginId = "";
try { try {
@@ -368,8 +386,11 @@ export async function handlePluginRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let pluginId = ""; let pluginId = "";
try { try {
@@ -460,8 +481,11 @@ export async function handlePluginRoutes(
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); } try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
let pluginId = ""; let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginDataListMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); } try { pluginId = deps.sanitizePluginId(pluginDataListMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data"); const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
await fs.mkdir(dataDir, { recursive: true }); await fs.mkdir(dataDir, { recursive: true });
@@ -492,8 +516,11 @@ export async function handlePluginRoutes(
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); } try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
let pluginId = ""; let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginDataReadMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); } try { pluginId = deps.sanitizePluginId(pluginDataReadMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let relPath = pluginDataReadMatch[2]; let relPath = pluginDataReadMatch[2];
try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); } try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); }
@@ -517,8 +544,11 @@ export async function handlePluginRoutes(
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); } try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
let pluginId = ""; let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginDataWriteMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); } try { pluginId = deps.sanitizePluginId(pluginDataWriteMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let relPath = pluginDataWriteMatch[2]; let relPath = pluginDataWriteMatch[2];
try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); } try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); }
@@ -558,8 +588,11 @@ export async function handlePluginRoutes(
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); } try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
let pluginId = ""; let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginDataDeleteMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); } try { pluginId = deps.sanitizePluginId(pluginDataDeleteMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let relPath = pluginDataDeleteMatch[2]; let relPath = pluginDataDeleteMatch[2];
try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); } try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); }
@@ -589,13 +622,19 @@ export async function handlePluginRoutes(
if (req.method === "POST" && pluginExecMatch) { if (req.method === "POST" && pluginExecMatch) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "plugins:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let pluginId = ""; let pluginId = "";
try { pluginId = deps.sanitizePluginId(pluginExecMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); } try { pluginId = deps.sanitizePluginId(pluginExecMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
try { body = await req.json(); } catch { return new Response("Bad request", { status: 400 }); } try { body = await req.json(); } catch { return new Response("Bad request", { status: 400 }); }
@@ -657,8 +696,11 @@ export async function handlePluginRoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin" && user.role !== "operator") { try {
return new Response("Forbidden: Admin or operator access required", { status: 403 }); requirePermission(user, "plugins:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let pluginId = ""; let pluginId = "";
@@ -733,12 +775,18 @@ export async function handlePluginRoutes(
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
const targetId = pluginLoadMatch[1]; const targetId = pluginLoadMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: You do not have access to this client", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const pluginId = pluginLoadMatch[2]; const pluginId = pluginLoadMatch[2];
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const target = clientManager.getClient(targetId); const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 }); if (!target) return new Response("Not found", { status: 404 });
@@ -793,12 +841,18 @@ export async function handlePluginRoutes(
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
const targetId = pluginEventsPollMatch[1]; const targetId = pluginEventsPollMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: You do not have access to this client", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const pluginId = pluginEventsPollMatch[2]; const pluginId = pluginEventsPollMatch[2];
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const events = deps.drainPluginUIEvents(targetId, pluginId); const events = deps.drainPluginUIEvents(targetId, pluginId);
return Response.json({ events }); return Response.json({ events });
@@ -816,12 +870,18 @@ export async function handlePluginRoutes(
} }
const targetId = pluginEventMatch[1]; const targetId = pluginEventMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: You do not have access to this client", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const pluginId = pluginEventMatch[2]; const pluginId = pluginEventMatch[2];
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const target = clientManager.getClient(targetId); const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 }); if (!target) return new Response("Not found", { status: 404 });
@@ -881,12 +941,18 @@ export async function handlePluginRoutes(
} }
const targetId = pluginUnloadMatch[1]; const targetId = pluginUnloadMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) { try {
return new Response("Forbidden: You do not have access to this client", { status: 403 }); requireClientAccess(user, targetId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const pluginId = pluginUnloadMatch[2]; const pluginId = pluginUnloadMatch[2];
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const target = clientManager.getClient(targetId); const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 }); if (!target) return new Response("Not found", { status: 404 });
@@ -923,8 +989,11 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin id", { status: 400 }); return new Response("Invalid plugin id", { status: 400 });
} }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (deps.pluginState.enabled[pluginId] === false) { if (deps.pluginState.enabled[pluginId] === false) {
@@ -976,8 +1045,11 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin id", { status: 400 }); return new Response("Invalid plugin id", { status: 400 });
} }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (deps.pluginState.enabled[pluginId] === false) { if (deps.pluginState.enabled[pluginId] === false) {
@@ -1041,8 +1113,11 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin id", { status: 400 }); return new Response("Invalid plugin id", { status: 400 });
} }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`); const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`);
@@ -1085,13 +1160,21 @@ export async function handlePluginRoutes(
return new Response("Invalid plugin id", { status: 400 }); return new Response("Invalid plugin id", { status: 400 });
} }
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
const clientId = url.searchParams.get("clientId") || ""; const clientId = url.searchParams.get("clientId") || "";
if (clientId && !canUserAccessClient(user.userId, user.role, clientId)) { if (clientId) {
return new Response("Forbidden: Client access denied", { status: 403 }); try {
requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
} }
const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`); const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`);
@@ -1180,8 +1263,11 @@ export async function handlePluginRoutes(
} }
const [, pluginId, assetPath] = pluginAssetMatch; const [, pluginId, assetPath] = pluginAssetMatch;
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) { try {
return new Response("Forbidden: You do not have access to this plugin", { status: 403 }); requirePluginAccess(user, pluginId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let decodedPath = assetPath; let decodedPath = assetPath;
try { try {
@@ -2,6 +2,7 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog"; import { AuditAction, logAudit } from "../../auditLog";
import { getConfig } from "../../config"; import { getConfig } from "../../config";
import { logger } from "../../logger"; import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
import { import {
registerUser, registerUser,
deleteUser, deleteUser,
@@ -225,7 +226,12 @@ export async function handleRegistrationRoutes(
if (req.method === "GET" && url.pathname === "/api/registration/keys") { if (req.method === "GET" && url.pathname === "/api/registration/keys") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const keys = listRegistrationKeys(); const keys = listRegistrationKeys();
return Response.json({ keys }); return Response.json({ keys });
@@ -234,7 +240,12 @@ export async function handleRegistrationRoutes(
if (req.method === "POST" && url.pathname === "/api/registration/keys") { if (req.method === "POST" && url.pathname === "/api/registration/keys") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
let body: any; let body: any;
try { try {
@@ -267,7 +278,12 @@ export async function handleRegistrationRoutes(
if (req.method === "DELETE" && url.pathname.match(/^\/api\/registration\/keys\/\d+$/)) { if (req.method === "DELETE" && url.pathname.match(/^\/api\/registration\/keys\/\d+$/)) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const keyId = Number(url.pathname.split("/").pop()); const keyId = Number(url.pathname.split("/").pop());
if (!keyId || !Number.isFinite(keyId)) { if (!keyId || !Number.isFinite(keyId)) {
@@ -296,7 +312,12 @@ export async function handleRegistrationRoutes(
if (req.method === "GET" && url.pathname === "/api/registration/keys/export") { if (req.method === "GET" && url.pathname === "/api/registration/keys/export") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const keys = listRegistrationKeys(); const keys = listRegistrationKeys();
const exportData = keys.map((k) => ({ const exportData = keys.map((k) => ({
@@ -319,7 +340,12 @@ export async function handleRegistrationRoutes(
if (req.method === "GET" && url.pathname === "/api/registration/pending") { if (req.method === "GET" && url.pathname === "/api/registration/pending") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pending = listPendingRegistrations(); const pending = listPendingRegistrations();
return Response.json({ return Response.json({
@@ -336,7 +362,12 @@ export async function handleRegistrationRoutes(
if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/approve$/)) { if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/approve$/)) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pendingId = Number(url.pathname.split("/")[4]); const pendingId = Number(url.pathname.split("/")[4]);
if (!pendingId || !Number.isFinite(pendingId)) { if (!pendingId || !Number.isFinite(pendingId)) {
@@ -371,7 +402,12 @@ export async function handleRegistrationRoutes(
if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/deny$/)) { if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/deny$/)) {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 }); try {
requirePermission(user, "users:manage");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const pendingId = Number(url.pathname.split("/")[4]); const pendingId = Number(url.pathname.split("/")[4]);
if (!pendingId || !Number.isFinite(pendingId)) { if (!pendingId || !Number.isFinite(pendingId)) {
@@ -2,6 +2,7 @@ import { createHash, randomBytes, createCipheriv } from "crypto";
import { authenticateRequest } from "../../auth"; import { authenticateRequest } from "../../auth";
import { getConfig } from "../../config"; import { getConfig } from "../../config";
import { logger } from "../../logger"; import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
let _solana: typeof import("@solana/web3.js") | null = null; let _solana: typeof import("@solana/web3.js") | null = null;
async function getSolana() { async function getSolana() {
@@ -71,8 +72,11 @@ export async function handleSolRoutes(
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "system:configure");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
if (req.method === "POST" && url.pathname === "/api/sol/preview") { if (req.method === "POST" && url.pathname === "/api/sol/preview") {
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { authenticateRequest } from "../../auth"; import { authenticateRequest } from "../../auth";
import { canUserAccessClient } from "../../users"; import { requireClientAccess } from "../../rbac";
import { logger } from "../../logger"; import { logger } from "../../logger";
const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, ""); const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, "");
@@ -57,7 +57,10 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Respon
} else { } else {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 }); if (!user) return new Response("Unauthorized", { status: 401 });
if (!canUserAccessClient(user.userId, user.role as any, clientId)) { try {
requireClientAccess(user, clientId);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
} }
@@ -6,6 +6,7 @@ import { AuditAction, logAudit } from "../../auditLog";
import * as clientManager from "../../clientManager"; import * as clientManager from "../../clientManager";
import { metrics } from "../../metrics"; import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol"; import { encodeMessage } from "../../protocol";
import { requirePermission } from "../../rbac";
import { createUploadPull } from "./file-download-routes"; import { createUploadPull } from "./file-download-routes";
type RequestIpProvider = { type RequestIpProvider = {
@@ -39,8 +40,11 @@ export async function handleWinRERoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "clients:winre");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let form: FormData; let form: FormData;
@@ -80,8 +84,11 @@ export async function handleWinRERoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "clients:winre");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -169,8 +176,11 @@ export async function handleWinRERoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "clients:winre");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -232,8 +242,11 @@ export async function handleWinRERoutes(
if (!user) { if (!user) {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
if (user.role !== "admin") { try {
return new Response("Forbidden: Admin access required", { status: 403 }); requirePermission(user, "clients:winre");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
let body: any = {}; let body: any = {};
@@ -1,8 +1,8 @@
import { authenticateRequest } from "../../auth"; import { authenticateRequest } from "../../auth";
import { logger } from "../../logger"; import { logger } from "../../logger";
import { isIpBanned } from "../../db"; import { isIpBanned } from "../../db";
import { canUserAccessClient, canUserAccessFeature, hasPermission } from "../../users";
import type { FeatureName } from "../../users"; import type { FeatureName } from "../../users";
import { hasPermission, requireClientAccess, requireFeatureAccess } from "../../rbac";
type RequestServer = { type RequestServer = {
requestIP: (req: Request) => { address?: string } | null | undefined; requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -47,11 +47,12 @@ function checkOperatorAccess(
clientId: string, clientId: string,
feature: FeatureName, feature: FeatureName,
): Response | null { ): Response | null {
if (!canUserAccessClient(user.userId, user.role as any, clientId)) { try {
return new Response("Forbidden: client access denied", { status: 403 }); requireClientAccess(user as any, clientId);
} requireFeatureAccess(user as any, feature);
if (!canUserAccessFeature(user.userId, user.role as any, feature)) { } catch (error) {
return new Response("Forbidden: feature access denied", { status: 403 }); if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
} }
return null; return null;
} }
@@ -4,9 +4,9 @@ import {
createUser, createUser,
deleteUser, deleteUser,
getUserByUsername, getUserByUsername,
hasPermission,
setUserCanBuild, setUserCanBuild,
} from "./users"; } from "./users";
import { hasPermission } from "./rbac";
const createdUserIds: number[] = []; const createdUserIds: number[] = [];
@@ -44,6 +44,10 @@ describe("feature permissions", () => {
"processes", "processes",
"keylogger", "keylogger",
"voice", "voice",
"disconnect",
"reconnect",
"uninstall",
"client_metadata",
]); ]);
}); });
+482 -224
View File
@@ -63,6 +63,15 @@ type UserAccessCacheEntry = {
const userAccessCache = new Map<number, UserAccessCacheEntry>(); const userAccessCache = new Map<number, UserAccessCacheEntry>();
let notificationDeliveryCache: UserDeliveryRow[] | null = null; let notificationDeliveryCache: UserDeliveryRow[] | null = null;
type AccessCheck<E extends any[]> = (userId: number, role: UserRole, ...extra: E) => boolean;
function withAdminBypass<E extends any[]>(check: AccessCheck<E>): AccessCheck<E> {
return (userId, role, ...extra) => {
if (role === "admin") return true;
return check(userId, role, ...extra);
};
}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -115,147 +124,170 @@ db.exec(
`CREATE INDEX IF NOT EXISTS idx_user_plugin_access_rules_user ON user_plugin_access_rules(user_id)`, `CREATE INDEX IF NOT EXISTS idx_user_plugin_access_rules_user ON user_plugin_access_rules(user_id)`,
); );
try { function addColumnIfMissing(table: string, definition: string): void {
db.exec(
`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`,
);
logger.info("[users] Added must_change_password column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))`,
);
logger.info("[users] Added client_scope column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(`UPDATE users SET client_scope='all' WHERE role='admin'`);
} catch (err: any) {
logger.error("[users] Failed to normalize admin client_scope:", err);
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN can_build INTEGER NOT NULL DEFAULT 0`,
);
logger.info("[users] Added can_build column to existing database");
try { try {
db.exec(`UPDATE users SET can_build=1 WHERE role='admin' OR role='operator'`); db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
} catch (err: any) { } catch (err: any) {
logger.error("[users] Failed to backfill admin/operator can_build:", err); if (!err?.message?.includes("duplicate column name")) throw err;
}
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
} }
} }
try { type Migration = { id: string; run: () => void };
db.exec(
`ALTER TABLE users ADD COLUMN telegram_chat_id TEXT DEFAULT NULL`,
);
logger.info("[users] Added telegram_chat_id column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
const notificationColumns: Array<{ sql: string; label: string }> = [ const userMigrations: Migration[] = [
{ sql: `ALTER TABLE users ADD COLUMN webhook_enabled INTEGER DEFAULT 0`, label: "webhook_enabled" }, {
{ sql: `ALTER TABLE users ADD COLUMN webhook_url TEXT DEFAULT NULL`, label: "webhook_url" }, id: "001_users_must_change_password",
{ sql: `ALTER TABLE users ADD COLUMN webhook_template TEXT DEFAULT NULL`, label: "webhook_template" }, run: () => addColumnIfMissing("users", `must_change_password INTEGER DEFAULT 0`),
{ sql: `ALTER TABLE users ADD COLUMN telegram_enabled INTEGER DEFAULT 0`, label: "telegram_enabled" }, },
{ sql: `ALTER TABLE users ADD COLUMN telegram_bot_token TEXT DEFAULT NULL`, label: "telegram_bot_token" }, {
{ sql: `ALTER TABLE users ADD COLUMN telegram_template TEXT DEFAULT NULL`, label: "telegram_template" }, id: "002_users_client_scope",
run: () => {
addColumnIfMissing(
"users",
`client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))`,
);
db.exec(`UPDATE users SET client_scope='all' WHERE role='admin'`);
},
},
{
id: "003_users_can_build",
run: () => {
addColumnIfMissing("users", `can_build INTEGER NOT NULL DEFAULT 0`);
db.exec(`UPDATE users SET can_build=1 WHERE role='admin' OR role='operator'`);
},
},
{
id: "004_users_telegram_chat_id",
run: () => addColumnIfMissing("users", `telegram_chat_id TEXT DEFAULT NULL`),
},
{
id: "005_users_notification_delivery",
run: () => {
addColumnIfMissing("users", `webhook_enabled INTEGER DEFAULT 0`);
addColumnIfMissing("users", `webhook_url TEXT DEFAULT NULL`);
addColumnIfMissing("users", `webhook_template TEXT DEFAULT NULL`);
addColumnIfMissing("users", `telegram_enabled INTEGER DEFAULT 0`);
addColumnIfMissing("users", `telegram_bot_token TEXT DEFAULT NULL`);
addColumnIfMissing("users", `telegram_template TEXT DEFAULT NULL`);
},
},
{
id: "006_users_can_upload_files",
run: () => {
addColumnIfMissing("users", `can_upload_files INTEGER NOT NULL DEFAULT 0`);
db.exec(`UPDATE users SET can_upload_files=1 WHERE role='admin'`);
},
},
{
id: "007_users_client_event_filters",
run: () => {
addColumnIfMissing("users", `client_event_webhook INTEGER DEFAULT 1`);
addColumnIfMissing("users", `client_event_telegram INTEGER DEFAULT 1`);
addColumnIfMissing("users", `client_event_push INTEGER DEFAULT 1`);
},
},
{
id: "008_users_chat_write",
run: () => addColumnIfMissing("users", `chat_write INTEGER DEFAULT NULL`),
},
{
id: "009_users_registered_via",
run: () => addColumnIfMissing("users", `registered_via TEXT DEFAULT NULL`),
},
{
id: "010_users_plugin_scope",
run: () => {
addColumnIfMissing(
"users",
`plugin_scope TEXT NOT NULL DEFAULT 'none' CHECK(plugin_scope IN ('none', 'allowlist', 'all'))`,
);
db.exec(`UPDATE users SET plugin_scope='all' WHERE role='admin'`);
},
},
{
id: "011_permission_groups",
run: () => {
db.exec(`
CREATE TABLE IF NOT EXISTS permission_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at INTEGER NOT NULL,
created_by INTEGER,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS permission_group_permissions (
group_id INTEGER NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (group_id, permission),
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_permission_groups (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE CASCADE
)
`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_user_permission_groups_user ON user_permission_groups(user_id)`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_permission_group_permissions_group ON permission_group_permissions(group_id)`,
);
},
},
{
id: "012_user_extra_permissions",
run: () => {
db.exec(`
CREATE TABLE IF NOT EXISTS user_extra_permissions (
user_id INTEGER NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (user_id, permission),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_user_extra_permissions_user ON user_extra_permissions(user_id)`,
);
},
},
]; ];
for (const col of notificationColumns) {
try { function runMigrations(migrations: ReadonlyArray<Migration>): void {
db.exec(col.sql); db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
logger.info(`[users] Added ${col.label} column to existing database`); id TEXT PRIMARY KEY,
} catch (err: any) { applied_at INTEGER NOT NULL
if (!err.message?.includes("duplicate column name")) { )`);
logger.error("[users] Migration error:", err);
const appliedRows = db
.prepare("SELECT id FROM schema_migrations")
.all() as Array<{ id: string }>;
const applied = new Set(appliedRows.map((r) => r.id));
const markApplied = db.prepare(
"INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)",
);
for (const migration of migrations) {
if (applied.has(migration.id)) continue;
try {
migration.run();
markApplied.run(migration.id, Date.now());
logger.info(`[migration] Applied ${migration.id}`);
} catch (err) {
logger.error(`[migration] Failed: ${migration.id}`, err);
throw err;
} }
} }
} }
try { runMigrations(userMigrations);
db.exec(
`ALTER TABLE users ADD COLUMN can_upload_files INTEGER NOT NULL DEFAULT 0`,
);
logger.info("[users] Added can_upload_files column to existing database");
try {
db.exec(`UPDATE users SET can_upload_files=1 WHERE role='admin'`);
} catch (err: any) {
logger.error("[users] Failed to backfill admin can_upload_files:", err);
}
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
const clientEventColumns: Array<{ sql: string; label: string }> = [
{ sql: `ALTER TABLE users ADD COLUMN client_event_webhook INTEGER DEFAULT 1`, label: "client_event_webhook" },
{ sql: `ALTER TABLE users ADD COLUMN client_event_telegram INTEGER DEFAULT 1`, label: "client_event_telegram" },
{ sql: `ALTER TABLE users ADD COLUMN client_event_push INTEGER DEFAULT 1`, label: "client_event_push" },
];
for (const col of clientEventColumns) {
try {
db.exec(col.sql);
logger.info(`[users] Added ${col.label} column to existing database`);
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
}
try {
db.exec(`ALTER TABLE users ADD COLUMN chat_write INTEGER DEFAULT NULL`);
logger.info("[users] Added chat_write column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(`ALTER TABLE users ADD COLUMN registered_via TEXT DEFAULT NULL`);
logger.info("[users] Added registered_via column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN plugin_scope TEXT NOT NULL DEFAULT 'none' CHECK(plugin_scope IN ('none', 'allowlist', 'all'))`,
);
logger.info("[users] Added plugin_scope column to existing database");
try {
db.exec(`UPDATE users SET plugin_scope='all' WHERE role='admin'`);
} catch (err: any) {
logger.error("[users] Failed to backfill admin plugin_scope:", err);
}
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS registration_keys ( CREATE TABLE IF NOT EXISTS registration_keys (
@@ -477,26 +509,17 @@ export function removeUserClientAccessRule(
} }
} }
export function canUserAccessClient( export const canUserAccessClient = withAdminBypass(
userId: number, (userId, _role, clientId: string): boolean => {
role: UserRole, const access = getUserAccessCacheEntry(userId);
clientId: string, const scope = access.scope;
): boolean { if (scope === "none") return false;
if (role === "admin") return true; if (scope === "all") return true;
if (scope === "allowlist") return access.allow.has(clientId);
const access = getUserAccessCacheEntry(userId); if (scope === "denylist") return !access.deny.has(clientId);
const scope = access.scope; return false;
if (scope === "none") return false; },
if (scope === "all") return true; );
if (scope === "allowlist") {
return access.allow.has(clientId);
}
if (scope === "denylist") {
return !access.deny.has(clientId);
}
return false;
}
type PluginAccessCacheEntry = { type PluginAccessCacheEntry = {
scope: PluginAccessScope; scope: PluginAccessScope;
@@ -549,19 +572,15 @@ export function listUserPluginAccessRules(userId: number): UserPluginAccessRule[
.all(userId) as UserPluginAccessRule[]; .all(userId) as UserPluginAccessRule[];
} }
export function canUserAccessPlugin( export const canUserAccessPlugin = withAdminBypass(
userId: number, (userId, _role, pluginId: string): boolean => {
role: UserRole, const access = getPluginAccessCacheEntry(userId);
pluginId: string, if (access.scope === "none") return false;
): boolean { if (access.scope === "all") return true;
if (role === "admin") return true; if (access.scope === "allowlist") return access.allowed.has(pluginId);
return false;
const access = getPluginAccessCacheEntry(userId); },
if (access.scope === "none") return false; );
if (access.scope === "all") return true;
if (access.scope === "allowlist") return access.allowed.has(pluginId);
return false;
}
export function setUserPluginAccessScope( export function setUserPluginAccessScope(
userId: number, userId: number,
@@ -652,7 +671,11 @@ export type FeatureName =
| "file_browser" | "file_browser"
| "processes" | "processes"
| "keylogger" | "keylogger"
| "voice"; | "voice"
| "disconnect"
| "reconnect"
| "uninstall"
| "client_metadata";
export const ALL_FEATURES: FeatureName[] = [ export const ALL_FEATURES: FeatureName[] = [
"console", "console",
@@ -663,6 +686,10 @@ export const ALL_FEATURES: FeatureName[] = [
"processes", "processes",
"keylogger", "keylogger",
"voice", "voice",
"disconnect",
"reconnect",
"uninstall",
"client_metadata",
]; ];
const featurePermCache = new Map<number, Map<string, boolean>>(); const featurePermCache = new Map<number, Map<string, boolean>>();
@@ -687,19 +714,14 @@ function invalidateFeaturePermCache(userId: number): void {
featurePermCache.delete(userId); featurePermCache.delete(userId);
} }
export function canUserAccessFeature( export const canUserAccessFeature = withAdminBypass(
userId: number, (userId, role, feature: FeatureName): boolean => {
role: UserRole, if (role === "viewer") return false;
feature: FeatureName, const perms = getFeaturePermCacheEntry(userId);
): boolean { const entry = perms.get(feature);
if (role === "admin") return true; return entry === undefined ? true : entry;
if (role === "viewer") return false; },
);
const perms = getFeaturePermCacheEntry(userId);
const entry = perms.get(feature);
if (entry === undefined) return true;
return entry;
}
export function getUserFeaturePermissions( export function getUserFeaturePermissions(
userId: number, userId: number,
@@ -915,10 +937,21 @@ export function deleteUser(userId: number): {
} }
try { try {
db.prepare("DELETE FROM users WHERE id = ?").run(userId); // SQLite foreign keys aren't enforced by default, so manually purge the
// user's dependent rows so the caches and the DB agree.
db.transaction(() => {
db.prepare("DELETE FROM user_permission_groups WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM user_extra_permissions WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM user_client_access_rules WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM user_plugin_access_rules WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM user_feature_permissions WHERE user_id = ?").run(userId);
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
})();
invalidateUserAccessCache(userId); invalidateUserAccessCache(userId);
invalidatePluginAccessCache(userId); invalidatePluginAccessCache(userId);
invalidateNotificationDeliveryCache(); invalidateNotificationDeliveryCache();
userGroupIdsCache.delete(userId);
userExtraPermsCache.delete(userId);
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
console.error("[users] Delete user error:", err); console.error("[users] Delete user error:", err);
@@ -965,48 +998,10 @@ export async function verifyPassword(
return user; return user;
} }
export function canManageUsers(role: UserRole): boolean { export const canBuildClients = withAdminBypass((userId): boolean => {
return role === "admin";
}
export function canControlClients(role: UserRole): boolean {
return role === "admin" || role === "operator";
}
export function canBuildClients(userId: number, role: UserRole): boolean {
if (role === "admin") return true;
const user = getUserById(userId); const user = getUserById(userId);
return user ? user.can_build === 1 : false; return user ? user.can_build === 1 : false;
} });
export function canViewAuditLogs(role: UserRole): boolean {
return role === "admin" || role === "operator";
}
export function canManageEnrollment(role: UserRole): boolean {
return role === "admin" || role === "operator";
}
export function hasPermission(role: UserRole, permission: string, userId?: number): boolean {
switch (permission) {
case "users:manage":
return canManageUsers(role);
case "clients:control":
return canControlClients(role);
case "clients:build":
if (userId !== undefined) return canBuildClients(userId, role);
return role === "admin" || role === "operator";
case "clients:enroll":
return canManageEnrollment(role);
case "audit:view":
return canViewAuditLogs(role);
case "chat:write":
if (userId !== undefined) return canChatWrite(userId, role);
return role === "admin" || role === "operator";
default:
return false;
}
}
export function setUserCanBuild( export function setUserCanBuild(
userId: number, userId: number,
@@ -1021,11 +1016,10 @@ export function setUserCanBuild(
} }
} }
export function canUploadFiles(userId: number, role: UserRole): boolean { export const canUploadFiles = withAdminBypass((userId): boolean => {
if (role === "admin") return true;
const user = getUserById(userId); const user = getUserById(userId);
return user ? user.can_upload_files === 1 : false; return user ? user.can_upload_files === 1 : false;
} });
export function setUserCanUploadFiles( export function setUserCanUploadFiles(
userId: number, userId: number,
@@ -1040,8 +1034,7 @@ export function setUserCanUploadFiles(
} }
} }
export function canChatWrite(userId: number, role: UserRole): boolean { export const canChatWrite = withAdminBypass((userId, role): boolean => {
if (role === "admin") return true;
const user = getUserById(userId); const user = getUserById(userId);
if (!user) return false; if (!user) return false;
const chatWrite = (user as any).chat_write; const chatWrite = (user as any).chat_write;
@@ -1049,7 +1042,7 @@ export function canChatWrite(userId: number, role: UserRole): boolean {
return role === "operator"; return role === "operator";
} }
return chatWrite === 1; return chatWrite === 1;
} });
export function setUserChatWrite( export function setUserChatWrite(
userId: number, userId: number,
@@ -1064,6 +1057,252 @@ export function setUserChatWrite(
} }
} }
export interface PermissionGroup {
id: number;
name: string;
description: string | null;
created_at: number;
created_by: number | null;
permissions: string[];
}
const groupPermsCache = new Map<number, Set<string>>();
const userGroupIdsCache = new Map<number, number[]>();
const userExtraPermsCache = new Map<number, Set<string>>();
function invalidateUserPermissionCaches(userId?: number): void {
if (userId === undefined) {
userGroupIdsCache.clear();
userExtraPermsCache.clear();
return;
}
userGroupIdsCache.delete(userId);
userExtraPermsCache.delete(userId);
}
function invalidateGroupPermsCache(groupId?: number): void {
if (groupId === undefined) {
groupPermsCache.clear();
return;
}
groupPermsCache.delete(groupId);
}
export function listPermissionGroups(): PermissionGroup[] {
const rows = db
.prepare(
`SELECT id, name, description, created_at, created_by FROM permission_groups ORDER BY name ASC`,
)
.all() as Array<Omit<PermissionGroup, "permissions">>;
return rows.map((row) => ({
...row,
permissions: Array.from(getPermissionsForGroup(row.id)).sort(),
}));
}
export function getPermissionGroup(groupId: number): PermissionGroup | null {
const row = db
.prepare(
`SELECT id, name, description, created_at, created_by FROM permission_groups WHERE id = ?`,
)
.get(groupId) as Omit<PermissionGroup, "permissions"> | undefined;
if (!row) return null;
return {
...row,
permissions: Array.from(getPermissionsForGroup(row.id)).sort(),
};
}
function getPermissionsForGroup(groupId: number): Set<string> {
const cached = groupPermsCache.get(groupId);
if (cached) return cached;
const rows = db
.prepare("SELECT permission FROM permission_group_permissions WHERE group_id = ?")
.all(groupId) as Array<{ permission: string }>;
const set = new Set(rows.map((r) => r.permission));
groupPermsCache.set(groupId, set);
return set;
}
export function createPermissionGroup(
name: string,
description: string | null,
permissions: string[],
createdBy: number | null,
): { success: boolean; error?: string; group?: PermissionGroup } {
const trimmedName = (name || "").trim();
if (!trimmedName || trimmedName.length > 64) {
return { success: false, error: "Name must be 1-64 characters" };
}
const trimmedDesc = description ? String(description).slice(0, 256).trim() || null : null;
const perms = Array.from(new Set(permissions.filter((p) => typeof p === "string" && p.length > 0)));
try {
const tx = db.transaction(() => {
const result = db
.prepare(
"INSERT INTO permission_groups (name, description, created_at, created_by) VALUES (?, ?, ?, ?)",
)
.run(trimmedName, trimmedDesc, Date.now(), createdBy);
const groupId = result.lastInsertRowid as number;
const insertPerm = db.prepare(
"INSERT INTO permission_group_permissions (group_id, permission) VALUES (?, ?)",
);
for (const perm of perms) insertPerm.run(groupId, perm);
return groupId;
});
const groupId = tx();
invalidateGroupPermsCache(groupId);
invalidateUserPermissionCaches();
const group = getPermissionGroup(groupId);
return group ? { success: true, group } : { success: false, error: "Failed to load created group" };
} catch (err: any) {
if (err.message?.includes("UNIQUE constraint")) {
return { success: false, error: "A group with that name already exists" };
}
logger.error("[users] createPermissionGroup error:", err);
return { success: false, error: err.message || "Failed to create permission group" };
}
}
export function updatePermissionGroup(
groupId: number,
updates: { name?: string; description?: string | null; permissions?: string[] },
): { success: boolean; error?: string; group?: PermissionGroup } {
const existing = getPermissionGroup(groupId);
if (!existing) return { success: false, error: "Group not found" };
try {
const tx = db.transaction(() => {
if (updates.name !== undefined) {
const trimmed = String(updates.name).trim();
if (!trimmed || trimmed.length > 64) throw new Error("Name must be 1-64 characters");
db.prepare("UPDATE permission_groups SET name = ? WHERE id = ?").run(trimmed, groupId);
}
if (updates.description !== undefined) {
const trimmed = updates.description ? String(updates.description).slice(0, 256).trim() || null : null;
db.prepare("UPDATE permission_groups SET description = ? WHERE id = ?").run(trimmed, groupId);
}
if (updates.permissions !== undefined) {
db.prepare("DELETE FROM permission_group_permissions WHERE group_id = ?").run(groupId);
const insertPerm = db.prepare(
"INSERT INTO permission_group_permissions (group_id, permission) VALUES (?, ?)",
);
const perms = Array.from(new Set(updates.permissions.filter((p) => typeof p === "string" && p.length > 0)));
for (const perm of perms) insertPerm.run(groupId, perm);
}
});
tx();
invalidateGroupPermsCache(groupId);
invalidateUserPermissionCaches();
const group = getPermissionGroup(groupId);
return group ? { success: true, group } : { success: false, error: "Failed to load updated group" };
} catch (err: any) {
if (err.message?.includes("UNIQUE constraint")) {
return { success: false, error: "A group with that name already exists" };
}
logger.error("[users] updatePermissionGroup error:", err);
return { success: false, error: err.message || "Failed to update permission group" };
}
}
export function deletePermissionGroup(groupId: number): { success: boolean; error?: string } {
try {
// SQLite foreign keys aren't enforced unless PRAGMA foreign_keys=ON, so
// clean up dependent rows explicitly to avoid orphans.
const result = db.transaction(() => {
db.prepare("DELETE FROM permission_group_permissions WHERE group_id = ?").run(groupId);
db.prepare("DELETE FROM user_permission_groups WHERE group_id = ?").run(groupId);
return db.prepare("DELETE FROM permission_groups WHERE id = ?").run(groupId);
})();
invalidateGroupPermsCache(groupId);
invalidateUserPermissionCaches();
if (result.changes === 0) return { success: false, error: "Group not found" };
return { success: true };
} catch (err: any) {
logger.error("[users] deletePermissionGroup error:", err);
return { success: false, error: err.message || "Failed to delete permission group" };
}
}
export function getUserGroupIds(userId: number): number[] {
const cached = userGroupIdsCache.get(userId);
if (cached) return cached;
const rows = db
.prepare("SELECT group_id FROM user_permission_groups WHERE user_id = ?")
.all(userId) as Array<{ group_id: number }>;
const ids = rows.map((r) => r.group_id);
userGroupIdsCache.set(userId, ids);
return ids;
}
export function setUserGroups(
userId: number,
groupIds: number[],
): { success: boolean; error?: string } {
try {
const tx = db.transaction(() => {
db.prepare("DELETE FROM user_permission_groups WHERE user_id = ?").run(userId);
const insert = db.prepare(
"INSERT OR IGNORE INTO user_permission_groups (user_id, group_id) VALUES (?, ?)",
);
const unique = Array.from(new Set(groupIds.filter((id) => Number.isFinite(id))));
for (const gid of unique) insert.run(userId, gid);
});
tx();
invalidateUserPermissionCaches(userId);
return { success: true };
} catch (err: any) {
logger.error("[users] setUserGroups error:", err);
return { success: false, error: err.message || "Failed to update user groups" };
}
}
export function getUserExtraPermissions(userId: number): Set<string> {
const cached = userExtraPermsCache.get(userId);
if (cached) return cached;
const rows = db
.prepare("SELECT permission FROM user_extra_permissions WHERE user_id = ?")
.all(userId) as Array<{ permission: string }>;
const set = new Set(rows.map((r) => r.permission));
userExtraPermsCache.set(userId, set);
return set;
}
export function setUserExtraPermissions(
userId: number,
permissions: string[],
): { success: boolean; error?: string } {
try {
const tx = db.transaction(() => {
db.prepare("DELETE FROM user_extra_permissions WHERE user_id = ?").run(userId);
const insert = db.prepare(
"INSERT OR IGNORE INTO user_extra_permissions (user_id, permission) VALUES (?, ?)",
);
const unique = Array.from(new Set(permissions.filter((p) => typeof p === "string" && p.length > 0)));
for (const perm of unique) insert.run(userId, perm);
});
tx();
invalidateUserPermissionCaches(userId);
return { success: true };
} catch (err: any) {
logger.error("[users] setUserExtraPermissions error:", err);
return { success: false, error: err.message || "Failed to update extra permissions" };
}
}
// Collects every permission string a user has via assigned groups + direct
// extras. Returns a Set for cheap membership tests; getUserPermissions in
// rbac.ts unions this with role-level permissions.
export function getUserGrantedPermissions(userId: number): Set<string> {
const result = new Set<string>();
for (const groupId of getUserGroupIds(userId)) {
for (const perm of getPermissionsForGroup(groupId)) result.add(perm);
}
for (const perm of getUserExtraPermissions(userId)) result.add(perm);
return result;
}
export function setUserTelegramChatId( export function setUserTelegramChatId(
userId: number, userId: number,
chatId: string | null, chatId: string | null,
@@ -1432,6 +1671,7 @@ export async function approvePendingRegistration(
`UPDATE pending_registrations SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?`, `UPDATE pending_registrations SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?`,
).run(reviewedBy, Date.now(), pendingId); ).run(reviewedBy, Date.now(), pendingId);
applyRegistrationDefaultGroups(result.lastInsertRowid as number);
invalidateNotificationDeliveryCache(); invalidateNotificationDeliveryCache();
return { success: true, userId: result.lastInsertRowid as number }; return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) { } catch (err: any) {
@@ -1489,6 +1729,7 @@ export async function registerUser(
"allowlist", role === "operator" ? 1 : 0, 0, registeredVia, "allowlist", role === "operator" ? 1 : 0, 0, registeredVia,
); );
applyRegistrationDefaultGroups(result.lastInsertRowid as number);
invalidateNotificationDeliveryCache(); invalidateNotificationDeliveryCache();
return { success: true, userId: result.lastInsertRowid as number }; return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) { } catch (err: any) {
@@ -1500,6 +1741,23 @@ export async function registerUser(
} }
} }
function applyRegistrationDefaultGroups(userId: number): void {
let defaults: number[] = [];
try {
defaults = getConfig().registration.defaultGroupIds || [];
} catch {
return;
}
if (defaults.length === 0) return;
const validIds: number[] = [];
const check = db.prepare("SELECT 1 AS ok FROM permission_groups WHERE id = ?");
for (const id of defaults) {
if ((check.get(id) as { ok?: number } | undefined)?.ok) validIds.push(id);
}
if (validIds.length === 0) return;
setUserGroups(userId, validIds);
}
export function getTotalUserCount(): number { export function getTotalUserCount(): number {
const row = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number }; const row = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number };
return row.count; return row.count;