RBAC 3.0
This commit is contained in:
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 & 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 & 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user