add option to revoke perms/set perms for plugins.
This commit is contained in:
@@ -210,7 +210,7 @@ function renderUsers() {
|
||||
<i class="fa-solid fa-user-shield"></i>
|
||||
</button>
|
||||
${user.role === "operator" ? `
|
||||
<button
|
||||
<button
|
||||
class="user-action-btn px-3 py-1.5 text-sm bg-amber-900/30 hover:bg-amber-900/50 text-amber-300 rounded border border-amber-800 transition-colors"
|
||||
data-action="feature-permissions"
|
||||
data-user-id="${user.id}"
|
||||
@@ -220,6 +220,17 @@ function renderUsers() {
|
||||
<i class="fa-solid fa-sliders"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
${user.role !== "admin" ? `
|
||||
<button
|
||||
class="user-action-btn px-3 py-1.5 text-sm bg-emerald-900/30 hover:bg-emerald-900/50 text-emerald-300 rounded border border-emerald-800 transition-colors"
|
||||
data-action="plugin-access"
|
||||
data-user-id="${user.id}"
|
||||
data-username="${escapeHtml(user.username)}"
|
||||
title="Plugin Access"
|
||||
>
|
||||
<i class="fa-solid fa-puzzle-piece"></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"
|
||||
data-action="view-sessions"
|
||||
@@ -312,6 +323,9 @@ function attachActionListeners() {
|
||||
case "feature-permissions":
|
||||
configureFeaturePermissions(userId, username);
|
||||
break;
|
||||
case "plugin-access":
|
||||
configurePluginAccess(userId, username);
|
||||
break;
|
||||
case "view-sessions":
|
||||
viewUserSessions(userId, username);
|
||||
break;
|
||||
@@ -591,6 +605,133 @@ window.configureFeaturePermissions = async function (userId, username) {
|
||||
}
|
||||
};
|
||||
|
||||
window.configurePluginAccess = async function (userId, username) {
|
||||
try {
|
||||
const [accessRes, pluginsRes] = await Promise.all([
|
||||
fetch(`/api/users/${userId}/plugin-access`),
|
||||
fetch("/api/plugins"),
|
||||
]);
|
||||
if (!accessRes.ok) throw new Error("Failed to load plugin access");
|
||||
|
||||
const accessData = await accessRes.json();
|
||||
const currentScope = accessData.scope || "none";
|
||||
const allowedIds = new Set((accessData.rules || []).map(r => r.pluginId));
|
||||
|
||||
let allPlugins = [];
|
||||
if (pluginsRes.ok) {
|
||||
const pluginsData = await pluginsRes.json();
|
||||
allPlugins = pluginsData.plugins || [];
|
||||
}
|
||||
|
||||
let modal = document.getElementById("plugin-access-modal");
|
||||
if (modal) modal.remove();
|
||||
|
||||
modal = document.createElement("div");
|
||||
modal.id = "plugin-access-modal";
|
||||
modal.className = "fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4";
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: "none", label: "No Access", desc: "User cannot access any plugins" },
|
||||
{ value: "allowlist", label: "Selected Plugins", desc: "User can only access checked plugins" },
|
||||
{ value: "all", label: "All Plugins", desc: "User can access all plugins" },
|
||||
];
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-md w-full p-6 shadow-2xl max-h-[85vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-bold text-slate-100">Plugin Access</h3>
|
||||
<button id="close-plugin-modal" class="text-slate-400 hover:text-slate-200 transition-colors">
|
||||
<i class="fa-solid fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400 mb-4">
|
||||
Manage plugin access for <span class="text-slate-200 font-medium">${escapeHtml(username)}</span>.
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">Access Mode</label>
|
||||
<select id="plugin-scope-select" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-slate-200 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
|
||||
${scopeOptions.map(o => `<option value="${o.value}" ${o.value === currentScope ? "selected" : ""}>${o.label}</option>`).join("")}
|
||||
</select>
|
||||
<p id="plugin-scope-desc" class="text-xs text-slate-500 mt-1">${scopeOptions.find(o => o.value === currentScope)?.desc || ""}</p>
|
||||
</div>
|
||||
<div id="plugin-list-section" class="${currentScope !== "allowlist" ? "hidden" : ""} flex-1 overflow-y-auto mb-4">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">Select Plugins</label>
|
||||
<div class="space-y-2" id="plugin-toggles">
|
||||
${allPlugins.length === 0
|
||||
? '<p class="text-sm text-slate-500 italic">No plugins installed</p>'
|
||||
: allPlugins.map(p => `
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 cursor-pointer transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fa-solid fa-puzzle-piece text-slate-400 w-5 text-center"></i>
|
||||
<span class="text-slate-200 font-medium">${escapeHtml(p.name || p.id)}</span>
|
||||
<span class="text-xs text-slate-500">${escapeHtml(p.id)}</span>
|
||||
</div>
|
||||
<input type="checkbox" data-plugin-id="${escapeHtml(p.id)}" ${allowedIds.has(p.id) ? "checked" : ""}
|
||||
class="w-5 h-5 rounded bg-slate-700 border-slate-600 text-emerald-500 focus:ring-emerald-500 focus:ring-offset-0 cursor-pointer" />
|
||||
</label>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="cancel-plugin-access" class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg border border-slate-700 transition-colors font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="save-plugin-access" class="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors font-medium">
|
||||
<i class="fa-solid fa-check mr-2"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const scopeSelect = document.getElementById("plugin-scope-select");
|
||||
const scopeDesc = document.getElementById("plugin-scope-desc");
|
||||
const listSection = document.getElementById("plugin-list-section");
|
||||
|
||||
scopeSelect.addEventListener("change", () => {
|
||||
const val = scopeSelect.value;
|
||||
const opt = scopeOptions.find(o => o.value === val);
|
||||
scopeDesc.textContent = opt?.desc || "";
|
||||
if (val === "allowlist") {
|
||||
listSection.classList.remove("hidden");
|
||||
} else {
|
||||
listSection.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("close-plugin-modal").addEventListener("click", () => modal.remove());
|
||||
document.getElementById("cancel-plugin-access").addEventListener("click", () => modal.remove());
|
||||
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
|
||||
|
||||
document.getElementById("save-plugin-access").addEventListener("click", async () => {
|
||||
const scope = scopeSelect.value;
|
||||
const pluginIds = [];
|
||||
if (scope === "allowlist") {
|
||||
modal.querySelectorAll("[data-plugin-id]").forEach(cb => {
|
||||
if (cb.checked) pluginIds.push(cb.dataset.pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const saveRes = await fetch(`/api/users/${userId}/plugin-access`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scope, pluginIds }),
|
||||
});
|
||||
if (!saveRes.ok) {
|
||||
const err = await saveRes.json().catch(() => ({}));
|
||||
throw new Error(err.error || "Failed to save");
|
||||
}
|
||||
modal.remove();
|
||||
if (window.showToast) window.showToast("Plugin access updated", "success");
|
||||
} catch (err) {
|
||||
alert("Failed to save plugin access: " + err.message);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Plugin access error:", err);
|
||||
alert("Failed to load plugin access settings");
|
||||
}
|
||||
};
|
||||
|
||||
window.toggleBuildPermission = async function (userId, username, currentCanBuild) {
|
||||
const newVal = !currentCanBuild;
|
||||
if (!confirm(`${newVal ? 'Grant' : 'Revoke'} build permission for ${username}?`)) return;
|
||||
|
||||
@@ -5,7 +5,7 @@ import AdmZip from "adm-zip";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { canUserAccessClient } from "../../users";
|
||||
import { canUserAccessClient, canUserAccessPlugin } from "../../users";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { encodeMessage, type PluginSignatureInfo } from "../../protocol";
|
||||
@@ -123,20 +123,23 @@ export async function handlePluginRoutes(
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/plugins") {
|
||||
if (!(await authenticateRequest(req))) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const plugins = await deps.listPluginManifests();
|
||||
const enriched = plugins.map((p) => ({
|
||||
...p,
|
||||
enabled: deps.pluginState.enabled[p.id] !== false,
|
||||
lastError: deps.pluginState.lastError[p.id] || "",
|
||||
autoLoad: deps.pluginState.autoLoad[p.id] === true,
|
||||
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
|
||||
signature: p.signature || { signed: false, trusted: false, valid: false },
|
||||
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
|
||||
serverRunning: deps.pluginRuntime.isRunning(p.id),
|
||||
}));
|
||||
const enriched = plugins
|
||||
.filter((p) => canUserAccessPlugin(user.userId, user.role, p.id))
|
||||
.map((p) => ({
|
||||
...p,
|
||||
enabled: deps.pluginState.enabled[p.id] !== false,
|
||||
lastError: deps.pluginState.lastError[p.id] || "",
|
||||
autoLoad: deps.pluginState.autoLoad[p.id] === true,
|
||||
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
|
||||
signature: p.signature || { signed: false, trusted: false, valid: false },
|
||||
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
|
||||
serverRunning: deps.pluginRuntime.isRunning(p.id),
|
||||
}));
|
||||
return Response.json({ plugins: enriched });
|
||||
}
|
||||
|
||||
@@ -205,14 +208,16 @@ export async function handlePluginRoutes(
|
||||
}
|
||||
const loaded = deps.pluginLoadedByClient.get(clientId) || new Set<string>();
|
||||
const manifests = await deps.listPluginManifests();
|
||||
const plugins = manifests.map((manifest) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name || manifest.id,
|
||||
loaded: loaded.has(manifest.id),
|
||||
enabled: deps.pluginState.enabled[manifest.id] !== false,
|
||||
lastError: deps.pluginState.lastError[manifest.id] || "",
|
||||
signature: manifest.signature || { signed: false, trusted: false, valid: false },
|
||||
}));
|
||||
const plugins = manifests
|
||||
.filter((manifest) => canUserAccessPlugin(user.userId, user.role, manifest.id))
|
||||
.map((manifest) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name || manifest.id,
|
||||
loaded: loaded.has(manifest.id),
|
||||
enabled: deps.pluginState.enabled[manifest.id] !== false,
|
||||
lastError: deps.pluginState.lastError[manifest.id] || "",
|
||||
signature: manifest.signature || { signed: false, trusted: false, valid: false },
|
||||
}));
|
||||
return Response.json({ plugins });
|
||||
}
|
||||
|
||||
@@ -453,6 +458,9 @@ export async function handlePluginRoutes(
|
||||
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
|
||||
let pluginId = "";
|
||||
try { pluginId = deps.sanitizePluginId(pluginDataListMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
const dataDir = path.join(deps.PLUGIN_ROOT, pluginId, "data");
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
async function walkDir(dir: string, base: string): Promise<{ path: string; size: number; isDir: boolean }[]> {
|
||||
@@ -482,6 +490,9 @@ export async function handlePluginRoutes(
|
||||
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
|
||||
let pluginId = "";
|
||||
try { pluginId = deps.sanitizePluginId(pluginDataReadMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
let relPath = pluginDataReadMatch[2];
|
||||
try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); }
|
||||
if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 });
|
||||
@@ -504,6 +515,9 @@ export async function handlePluginRoutes(
|
||||
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
|
||||
let pluginId = "";
|
||||
try { pluginId = deps.sanitizePluginId(pluginDataWriteMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
let relPath = pluginDataWriteMatch[2];
|
||||
try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); }
|
||||
if (relPath.includes("\u0000") || relPath.endsWith("/") || relPath.endsWith(path.sep)) {
|
||||
@@ -542,6 +556,9 @@ export async function handlePluginRoutes(
|
||||
try { requirePermission(user, "clients:control"); } catch (e) { if (e instanceof Response) return e; return new Response("Forbidden", { status: 403 }); }
|
||||
let pluginId = "";
|
||||
try { pluginId = deps.sanitizePluginId(pluginDataDeleteMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
let relPath = pluginDataDeleteMatch[2];
|
||||
try { relPath = decodeURIComponent(relPath); } catch { return new Response("Bad request", { status: 400 }); }
|
||||
if (relPath.includes("\u0000")) return new Response("Bad request", { status: 400 });
|
||||
@@ -575,6 +592,9 @@ export async function handlePluginRoutes(
|
||||
}
|
||||
let pluginId = "";
|
||||
try { pluginId = deps.sanitizePluginId(pluginExecMatch[1]); } catch { return new Response("Invalid plugin id", { status: 400 }); }
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
let body: any = {};
|
||||
try { body = await req.json(); } catch { return new Response("Bad request", { status: 400 }); }
|
||||
const filePath = typeof body.file === "string" ? body.file : "";
|
||||
@@ -715,6 +735,9 @@ export async function handlePluginRoutes(
|
||||
return new Response("Forbidden: You do not have access to this client", { status: 403 });
|
||||
}
|
||||
const pluginId = pluginLoadMatch[2];
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
const target = clientManager.getClient(targetId);
|
||||
if (!target) return new Response("Not found", { status: 404 });
|
||||
if (deps.isPluginLoaded(targetId, pluginId)) {
|
||||
@@ -772,6 +795,9 @@ export async function handlePluginRoutes(
|
||||
return new Response("Forbidden: You do not have access to this client", { status: 403 });
|
||||
}
|
||||
const pluginId = pluginEventsPollMatch[2];
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
const events = deps.drainPluginUIEvents(targetId, pluginId);
|
||||
return Response.json({ events });
|
||||
}
|
||||
@@ -792,6 +818,9 @@ export async function handlePluginRoutes(
|
||||
return new Response("Forbidden: You do not have access to this client", { status: 403 });
|
||||
}
|
||||
const pluginId = pluginEventMatch[2];
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
const target = clientManager.getClient(targetId);
|
||||
if (!target) return new Response("Not found", { status: 404 });
|
||||
if (deps.pluginState.enabled[pluginId] === false) {
|
||||
@@ -854,6 +883,9 @@ export async function handlePluginRoutes(
|
||||
return new Response("Forbidden: You do not have access to this client", { status: 403 });
|
||||
}
|
||||
const pluginId = pluginUnloadMatch[2];
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
const target = clientManager.getClient(targetId);
|
||||
if (!target) return new Response("Not found", { status: 404 });
|
||||
|
||||
@@ -889,6 +921,10 @@ export async function handlePluginRoutes(
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
|
||||
if (deps.pluginState.enabled[pluginId] === false) {
|
||||
return Response.json({ ok: false, error: "Plugin disabled" }, { status: 400 });
|
||||
}
|
||||
@@ -937,6 +973,10 @@ export async function handlePluginRoutes(
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
|
||||
if (deps.pluginState.enabled[pluginId] === false) {
|
||||
return new Response("Plugin disabled", { status: 400 });
|
||||
}
|
||||
@@ -998,6 +1038,10 @@ export async function handlePluginRoutes(
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
|
||||
const htmlFile = path.join(deps.PLUGIN_ROOT, pluginId, "assets", `${pluginId}.html`);
|
||||
const file = Bun.file(htmlFile);
|
||||
if (!(await file.exists())) {
|
||||
@@ -1033,6 +1077,10 @@ export async function handlePluginRoutes(
|
||||
return new Response("Invalid plugin id", { status: 400 });
|
||||
}
|
||||
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
|
||||
const clientId = url.searchParams.get("clientId") || "";
|
||||
if (clientId && !canUserAccessClient(user.userId, user.role, clientId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
@@ -1106,6 +1154,9 @@ export async function handlePluginRoutes(
|
||||
}
|
||||
|
||||
const [, pluginId, assetPath] = pluginAssetMatch;
|
||||
if (!canUserAccessPlugin(user.userId, user.role, pluginId)) {
|
||||
return new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
||||
}
|
||||
let decodedPath = assetPath;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(assetPath);
|
||||
|
||||
@@ -11,14 +11,17 @@ import {
|
||||
type ClientAccessRuleKind,
|
||||
type ClientAccessScope,
|
||||
type FeatureName,
|
||||
type PluginAccessScope,
|
||||
ALL_FEATURES,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
getUserClientAccessScope,
|
||||
getUserFeaturePermissions,
|
||||
getUserPluginAccessScope,
|
||||
listUsers,
|
||||
listUserClientAccessRules,
|
||||
listUserPluginAccessRules,
|
||||
removeUserClientAccessRule,
|
||||
setUserClientAccessRule,
|
||||
setUserClientAccessScope,
|
||||
@@ -26,6 +29,8 @@ import {
|
||||
setUserCanUploadFiles,
|
||||
setUserFeaturePermissions,
|
||||
resetUserFeaturePermissions,
|
||||
setUserPluginAccessScope,
|
||||
setUserPluginAccessRulesBulk,
|
||||
updateUserPassword,
|
||||
updateUserRole,
|
||||
} from "../../users";
|
||||
@@ -494,6 +499,71 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/plugin-access$/)) {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const targetUser = getUserById(userId);
|
||||
if (!targetUser) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const scope = getUserPluginAccessScope(userId);
|
||||
const rules = listUserPluginAccessRules(userId);
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Viewed plugin access policy for user: ${targetUser.username}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ scope, rules });
|
||||
}
|
||||
|
||||
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/plugin-access$/)) {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const targetUser = getUserById(userId);
|
||||
if (!targetUser) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const scope = body?.scope as PluginAccessScope;
|
||||
const pluginIds = Array.isArray(body?.pluginIds) ? body.pluginIds.filter((id: any) => typeof id === "string" && id.trim()) : undefined;
|
||||
|
||||
const scopeResult = setUserPluginAccessScope(userId, scope);
|
||||
if (!scopeResult.success) {
|
||||
return Response.json({ error: scopeResult.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (scope === "allowlist" && pluginIds !== undefined) {
|
||||
const rulesResult = setUserPluginAccessRulesBulk(userId, pluginIds);
|
||||
if (!rulesResult.success) {
|
||||
return Response.json({ error: rulesResult.error }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Updated plugin access for ${targetUser.username}: scope=${scope}${pluginIds ? `, plugins=[${pluginIds.join(",")}]` : ""}`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
scope,
|
||||
rules: listUserPluginAccessRules(userId),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
|
||||
@@ -11,6 +11,7 @@ const db = new Database(dbPath);
|
||||
export type UserRole = "admin" | "operator" | "viewer";
|
||||
export type ClientAccessScope = "none" | "allowlist" | "denylist" | "all";
|
||||
export type ClientAccessRuleKind = "allow" | "deny";
|
||||
export type PluginAccessScope = "none" | "allowlist" | "all";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
@@ -22,6 +23,7 @@ export interface User {
|
||||
created_by: string | null;
|
||||
must_change_password: number;
|
||||
client_scope: ClientAccessScope;
|
||||
plugin_scope: PluginAccessScope;
|
||||
can_build: number;
|
||||
can_upload_files: number;
|
||||
telegram_chat_id: string | null;
|
||||
@@ -35,6 +37,7 @@ export interface UserInfo {
|
||||
last_login: number | null;
|
||||
created_by: string | null;
|
||||
client_scope: ClientAccessScope;
|
||||
plugin_scope: PluginAccessScope;
|
||||
can_build: number;
|
||||
can_upload_files: number;
|
||||
telegram_chat_id: string | null;
|
||||
@@ -46,6 +49,11 @@ export interface UserClientAccessRule {
|
||||
access: ClientAccessRuleKind;
|
||||
}
|
||||
|
||||
export interface UserPluginAccessRule {
|
||||
userId: number;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
type UserAccessCacheEntry = {
|
||||
scope: ClientAccessScope;
|
||||
allow: Set<string>;
|
||||
@@ -94,6 +102,19 @@ db.exec(`
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_plugin_access_rules (
|
||||
user_id INTEGER NOT NULL,
|
||||
plugin_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, plugin_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_plugin_access_rules_user ON user_plugin_access_rules(user_id)`,
|
||||
);
|
||||
|
||||
try {
|
||||
db.exec(
|
||||
`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`,
|
||||
@@ -220,6 +241,22 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS registration_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -267,8 +304,8 @@ if (userCount.count === 0) {
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1, "all", 1, 1);
|
||||
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope, plugin_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1, "all", "all", 1, 1);
|
||||
|
||||
const createdUser = db
|
||||
.prepare("SELECT * FROM users WHERE username = ?")
|
||||
@@ -301,7 +338,7 @@ export function getUserByUsername(username: string): User | null {
|
||||
export function listUsers(): UserInfo[] {
|
||||
const users = db
|
||||
.prepare(
|
||||
"SELECT id, username, role, created_at, last_login, created_by, client_scope, can_build, can_upload_files, telegram_chat_id FROM users ORDER BY created_at DESC",
|
||||
"SELECT id, username, role, created_at, last_login, created_by, client_scope, plugin_scope, can_build, can_upload_files, telegram_chat_id FROM users ORDER BY created_at DESC",
|
||||
)
|
||||
.all() as UserInfo[];
|
||||
return users;
|
||||
@@ -454,6 +491,152 @@ export function canUserAccessClient(
|
||||
return false;
|
||||
}
|
||||
|
||||
type PluginAccessCacheEntry = {
|
||||
scope: PluginAccessScope;
|
||||
allowed: Set<string>;
|
||||
};
|
||||
|
||||
const pluginAccessCache = new Map<number, PluginAccessCacheEntry>();
|
||||
|
||||
function invalidatePluginAccessCache(userId?: number): void {
|
||||
if (userId === undefined) {
|
||||
pluginAccessCache.clear();
|
||||
return;
|
||||
}
|
||||
pluginAccessCache.delete(userId);
|
||||
}
|
||||
|
||||
function getPluginAccessCacheEntry(userId: number): PluginAccessCacheEntry {
|
||||
const cached = pluginAccessCache.get(userId);
|
||||
if (cached) return cached;
|
||||
|
||||
const row = db
|
||||
.prepare("SELECT plugin_scope FROM users WHERE id = ?")
|
||||
.get(userId) as { plugin_scope?: PluginAccessScope } | undefined;
|
||||
const rules = db
|
||||
.prepare("SELECT plugin_id as pluginId FROM user_plugin_access_rules WHERE user_id = ?")
|
||||
.all(userId) as Array<{ pluginId: string }>;
|
||||
|
||||
const entry: PluginAccessCacheEntry = {
|
||||
scope: row?.plugin_scope || "none",
|
||||
allowed: new Set<string>(),
|
||||
};
|
||||
|
||||
for (const rule of rules) {
|
||||
entry.allowed.add(rule.pluginId);
|
||||
}
|
||||
|
||||
pluginAccessCache.set(userId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function getUserPluginAccessScope(userId: number): PluginAccessScope {
|
||||
return getPluginAccessCacheEntry(userId).scope;
|
||||
}
|
||||
|
||||
export function listUserPluginAccessRules(userId: number): UserPluginAccessRule[] {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT user_id as userId, plugin_id as pluginId FROM user_plugin_access_rules WHERE user_id = ? ORDER BY plugin_id ASC",
|
||||
)
|
||||
.all(userId) as UserPluginAccessRule[];
|
||||
}
|
||||
|
||||
export function canUserAccessPlugin(
|
||||
userId: number,
|
||||
role: UserRole,
|
||||
pluginId: string,
|
||||
): boolean {
|
||||
if (role === "admin") return true;
|
||||
|
||||
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(
|
||||
userId: number,
|
||||
scope: PluginAccessScope,
|
||||
): { success: boolean; error?: string } {
|
||||
if (!["none", "allowlist", "all"].includes(scope)) {
|
||||
return { success: false, error: "Invalid plugin access scope" };
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare("UPDATE users SET plugin_scope = ? WHERE id = ?").run(scope, userId);
|
||||
invalidatePluginAccessCache(userId);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] setUserPluginAccessScope error:", err);
|
||||
return { success: false, error: err.message || "Failed to update plugin access scope" };
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserPluginAccessRule(
|
||||
userId: number,
|
||||
pluginId: string,
|
||||
): { success: boolean; error?: string } {
|
||||
const normalizedPluginId = (pluginId || "").trim();
|
||||
if (!normalizedPluginId) {
|
||||
return { success: false, error: "pluginId is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO user_plugin_access_rules (user_id, plugin_id, created_at) VALUES (?, ?, ?)",
|
||||
).run(userId, normalizedPluginId, Date.now());
|
||||
invalidatePluginAccessCache(userId);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] setUserPluginAccessRule error:", err);
|
||||
return { success: false, error: err.message || "Failed to update plugin access rule" };
|
||||
}
|
||||
}
|
||||
|
||||
export function removeUserPluginAccessRule(
|
||||
userId: number,
|
||||
pluginId: string,
|
||||
): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("DELETE FROM user_plugin_access_rules WHERE user_id = ? AND plugin_id = ?").run(
|
||||
userId,
|
||||
pluginId,
|
||||
);
|
||||
invalidatePluginAccessCache(userId);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] removeUserPluginAccessRule error:", err);
|
||||
return { success: false, error: err.message || "Failed to remove plugin access rule" };
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserPluginAccessRulesBulk(
|
||||
userId: number,
|
||||
pluginIds: string[],
|
||||
): { success: boolean; error?: string } {
|
||||
try {
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare("DELETE FROM user_plugin_access_rules WHERE user_id = ?").run(userId);
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO user_plugin_access_rules (user_id, plugin_id, created_at) VALUES (?, ?, ?)",
|
||||
);
|
||||
const now = Date.now();
|
||||
for (const pid of pluginIds) {
|
||||
const normalized = (pid || "").trim();
|
||||
if (normalized) stmt.run(userId, normalized, now);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
invalidatePluginAccessCache(userId);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] setUserPluginAccessRulesBulk error:", err);
|
||||
return { success: false, error: err.message || "Failed to update plugin access rules" };
|
||||
}
|
||||
}
|
||||
|
||||
export type FeatureName =
|
||||
| "console"
|
||||
| "remote_desktop"
|
||||
@@ -647,9 +830,9 @@ export async function createUser(
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, plugin_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none", role === "admin" || role === "operator" ? 1 : 0, role === "admin" ? 1 : 0);
|
||||
.run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none", role === "admin" ? "all" : "none", role === "admin" || role === "operator" ? 1 : 0, role === "admin" ? 1 : 0);
|
||||
|
||||
invalidateNotificationDeliveryCache();
|
||||
|
||||
@@ -694,12 +877,15 @@ export function updateUserRole(
|
||||
): { success: boolean; error?: string } {
|
||||
try {
|
||||
const nextScope: ClientAccessScope = newRole === "admin" ? "all" : "none";
|
||||
db.prepare("UPDATE users SET role = ?, client_scope = ? WHERE id = ?").run(
|
||||
const nextPluginScope: PluginAccessScope = newRole === "admin" ? "all" : "none";
|
||||
db.prepare("UPDATE users SET role = ?, client_scope = ?, plugin_scope = ? WHERE id = ?").run(
|
||||
newRole,
|
||||
nextScope,
|
||||
nextPluginScope,
|
||||
userId,
|
||||
);
|
||||
invalidateUserAccessCache(userId);
|
||||
invalidatePluginAccessCache(userId);
|
||||
invalidateNotificationDeliveryCache();
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
@@ -724,6 +910,7 @@ export function deleteUser(userId: number): {
|
||||
try {
|
||||
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
|
||||
invalidateUserAccessCache(userId);
|
||||
invalidatePluginAccessCache(userId);
|
||||
invalidateNotificationDeliveryCache();
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
|
||||
Reference in New Issue
Block a user