diff --git a/Overlord-Server/public/assets/users.js b/Overlord-Server/public/assets/users.js index 4db2388..fe2cc9d 100644 --- a/Overlord-Server/public/assets/users.js +++ b/Overlord-Server/public/assets/users.js @@ -210,7 +210,7 @@ function renderUsers() { ${user.role === "operator" ? ` - ` : ''} + ${user.role !== "admin" ? ` + + ` : ''} + +

+ Manage plugin access for ${escapeHtml(username)}. +

+
+ + +

${scopeOptions.find(o => o.value === currentScope)?.desc || ""}

+
+
+ +
+ ${allPlugins.length === 0 + ? '

No plugins installed

' + : allPlugins.map(p => ` + `).join("")} +
+
+
+ + +
+ `; + 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; diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 94f0ed5..ad827a4 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -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(); 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); diff --git a/Overlord-Server/src/server/routes/users-routes.ts b/Overlord-Server/src/server/routes/users-routes.ts index 58b05ab..0f88c9a 100644 --- a/Overlord-Server/src/server/routes/users-routes.ts +++ b/Overlord-Server/src/server/routes/users-routes.ts @@ -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) { diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts index 66ddc1e..de6217b 100644 --- a/Overlord-Server/src/users.ts +++ b/Overlord-Server/src/users.ts @@ -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; @@ -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; +}; + +const pluginAccessCache = new Map(); + +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(), + }; + + 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) {