From f28c0d33a9e44744827dbc94387f591c45abc885 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Mon, 13 Apr 2026 14:11:49 -0500 Subject: [PATCH] fix user permissions + improve notification system --- .../public/assets/notifications.js | 25 +++++++++ Overlord-Server/public/notifications.html | 43 ++++++++++++++ Overlord-Server/src/auditLog.ts | 15 +++++ Overlord-Server/src/db.ts | 56 +++++++++++++++---- Overlord-Server/src/main-server.ts | 6 ++ Overlord-Server/src/rbac.test.ts | 4 +- .../src/server/notification-delivery.ts | 9 ++- .../src/server/routes/enrollment-routes.ts | 43 +++++++++++++- .../src/server/routes/misc-routes.ts | 18 +++++- .../routes/notifications-config-routes.ts | 12 ++++ .../src/server/ws-notifications-plugin.ts | 9 ++- Overlord-Server/src/users.ts | 41 +++++++++++++- 12 files changed, 257 insertions(+), 24 deletions(-) diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js index 7d6468b..8738531 100644 --- a/Overlord-Server/public/assets/notifications.js +++ b/Overlord-Server/public/assets/notifications.js @@ -52,6 +52,11 @@ const eventNotifOfflineInput = document.getElementById("event-notif-offline"); const eventNotifPurgatoryInput = document.getElementById("event-notif-purgatory"); const saveEventNotifsBtn = document.getElementById("save-event-notifs"); +const clientEventWebhookInput = document.getElementById("client-event-webhook"); +const clientEventTelegramInput = document.getElementById("client-event-telegram"); +const clientEventPushInput = document.getElementById("client-event-push"); +const saveEventChannelsBtn = document.getElementById("save-event-channels"); + // Browser permission status bar const desktopNotifStatusBar = document.getElementById("desktop-notif-status-bar"); const desktopNotifStatusIcon = document.getElementById("desktop-notif-status-icon"); @@ -354,6 +359,9 @@ function applyMySettings(settings) { if (telegramBotTokenInput) telegramBotTokenInput.value = settings.telegram_bot_token || ""; if (telegramChatIdInput) telegramChatIdInput.value = settings.telegram_chat_id || ""; if (telegramTemplateInput) telegramTemplateInput.value = settings.telegram_template || ""; + if (clientEventWebhookInput) clientEventWebhookInput.checked = settings.client_event_webhook !== 0; + if (clientEventTelegramInput) clientEventTelegramInput.checked = settings.client_event_telegram !== 0; + if (clientEventPushInput) clientEventPushInput.checked = settings.client_event_push !== 0; } async function loadMySettings() { @@ -676,6 +684,22 @@ function wireEventNotifSave() { }); } +function wireEventChannelsSave() { + if (!saveEventChannelsBtn) return; + saveEventChannelsBtn.addEventListener("click", async () => { + try { + await saveMySettings({ + client_event_webhook: clientEventWebhookInput?.checked ?? true, + client_event_telegram: clientEventTelegramInput?.checked ?? true, + client_event_push: clientEventPushInput?.checked ?? true, + }); + window.showToast?.("Event delivery channel settings saved", "success", 3000); + } catch (err) { + window.showToast?.(err?.message || "Failed to save event channel settings", "error", 4000); + } + }); +} + // ── Bootstrap ───────────────────────────────────────────────────────────────── initWebhookTemplateEditor(); wireKeywordSave(); @@ -684,6 +708,7 @@ wireTelegramSave(); loadMySettings(); loadEventNotifPrefs(); wireEventNotifSave(); +wireEventChannelsSave(); updateDesktopPermissionUi(); wireDesktopPermissionBtn(); initRoleUi(); diff --git a/Overlord-Server/public/notifications.html b/Overlord-Server/public/notifications.html index 04368e8..2444601 100644 --- a/Overlord-Server/public/notifications.html +++ b/Overlord-Server/public/notifications.html @@ -313,6 +313,49 @@ + +
+
+
+

Client Event Delivery Channels

+

+ Choose which channels receive client lifecycle events (online, offline, purgatory). +

+
+ +
+ +
+ + + +
+
+
"?").join(","); + where.push(`(target_client_id IN (${placeholders}) OR target_client_id IS NULL)`); + params.push(...filters.allowedClientIds); + } else if (filters.deniedClientIds && filters.deniedClientIds.length > 0) { + const placeholders = filters.deniedClientIds.map(() => "?").join(","); + where.push(`(target_client_id NOT IN (${placeholders}) OR target_client_id IS NULL)`); + params.push(...filters.deniedClientIds); + } + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereSql}`; diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 55c50c4..5c98aab 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -1215,16 +1215,31 @@ export function lookupClientByPublicKey( return { id: row.id, enrollmentStatus: row.enrollment_status }; } -export function getEnrollmentStats(): { +export function getEnrollmentStats(opts?: { + allowedClientIds?: string[]; + deniedClientIds?: string[]; +}): { pending: number; approved: number; denied: number; } { - const rows = db - .query<{ status: string; c: number }>( - `SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients GROUP BY enrollment_status`, - ) - .all(); + let sql = `SELECT COALESCE(enrollment_status,'pending') as status, COUNT(*) as c FROM clients`; + const params: any[] = []; + + if (opts?.allowedClientIds) { + if (opts.allowedClientIds.length === 0) return { pending: 0, approved: 0, denied: 0 }; + const placeholders = opts.allowedClientIds.map(() => "?").join(","); + sql += ` WHERE id IN (${placeholders})`; + params.push(...opts.allowedClientIds); + } else if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) { + const placeholders = opts.deniedClientIds.map(() => "?").join(","); + sql += ` WHERE id NOT IN (${placeholders})`; + params.push(...opts.deniedClientIds); + } + + sql += ` GROUP BY enrollment_status`; + + const rows = db.query<{ status: string; c: number }>(sql).all(...params); const stats = { pending: 0, approved: 0, denied: 0 }; for (const r of rows) { if (r.status === "approved") stats.approved = Number(r.c); @@ -1287,7 +1302,10 @@ export function getAllPushSubscriptions(): PushSubscriptionRecord[] { })); } -export function getPendingClients(): { +export function getPendingClients(opts?: { + allowedClientIds?: string[]; + deniedClientIds?: string[]; +}): { id: string; host: string | null; os: string | null; @@ -1298,12 +1316,26 @@ export function getPendingClients(): { keyFingerprint: string | null; lastSeen: number; }[] { + let sql = `SELECT id, host, os, user, ip, country, public_key as publicKey, key_fingerprint as keyFingerprint, last_seen as lastSeen + FROM clients WHERE (enrollment_status='pending' OR enrollment_status IS NULL)`; + const params: any[] = []; + + if (opts?.allowedClientIds) { + if (opts.allowedClientIds.length === 0) return []; + const placeholders = opts.allowedClientIds.map(() => "?").join(","); + sql += ` AND id IN (${placeholders})`; + params.push(...opts.allowedClientIds); + } else if (opts?.deniedClientIds && opts.deniedClientIds.length > 0) { + const placeholders = opts.deniedClientIds.map(() => "?").join(","); + sql += ` AND id NOT IN (${placeholders})`; + params.push(...opts.deniedClientIds); + } + + sql += ` ORDER BY last_seen DESC`; + return db - .query( - `SELECT id, host, os, user, ip, country, public_key as publicKey, key_fingerprint as keyFingerprint, last_seen as lastSeen - FROM clients WHERE enrollment_status='pending' OR enrollment_status IS NULL ORDER BY last_seen DESC`, - ) - .all() + .query(sql) + .all(...params) .map((r: any) => ({ id: r.id, host: r.host, diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 382f4f3..5e10985 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -264,6 +264,9 @@ const deliverNotificationWithScreenshotForRecord = (record: NotificationRecord) telegramBotToken: u.telegram_bot_token || "", telegramChatId: u.telegram_chat_id || "", telegramTemplate: u.telegram_template, + clientEventWebhook: u.client_event_webhook === 1, + clientEventTelegram: u.client_event_telegram === 1, + clientEventPush: u.client_event_push === 1, })); }; return deliverNotificationWithScreenshot(record, getUserDeliveryTargets); @@ -293,6 +296,9 @@ const notificationPluginHandlers = createNotificationPluginHandlers({ telegramBotToken: u.telegram_bot_token || "", telegramChatId: u.telegram_chat_id || "", telegramTemplate: u.telegram_template, + clientEventWebhook: u.client_event_webhook === 1, + clientEventTelegram: u.client_event_telegram === 1, + clientEventPush: u.client_event_push === 1, }); if (event === "client_purgatory") { return getUsersForNotificationDelivery() diff --git a/Overlord-Server/src/rbac.test.ts b/Overlord-Server/src/rbac.test.ts index ab9dfb0..bbb1c30 100644 --- a/Overlord-Server/src/rbac.test.ts +++ b/Overlord-Server/src/rbac.test.ts @@ -54,8 +54,8 @@ describe("checkPermission", () => { expect(checkPermission(makeUser({ role: "admin" }), "audit:view")).toBe(true); }); - test("operator does not have audit:view", () => { - expect(checkPermission(makeUser({ role: "operator" }), "audit:view")).toBe(false); + test("operator has audit:view", () => { + expect(checkPermission(makeUser({ role: "operator" }), "audit:view")).toBe(true); }); }); diff --git a/Overlord-Server/src/server/notification-delivery.ts b/Overlord-Server/src/server/notification-delivery.ts index 7db53cf..858b8ae 100644 --- a/Overlord-Server/src/server/notification-delivery.ts +++ b/Overlord-Server/src/server/notification-delivery.ts @@ -43,6 +43,9 @@ export type UserDeliveryTarget = { telegramBotToken: string; telegramChatId: string; telegramTemplate: string | null; + clientEventWebhook: boolean; + clientEventTelegram: boolean; + clientEventPush: boolean; }; export const DEFAULT_WEBHOOK_TEMPLATE = @@ -324,6 +327,7 @@ export async function deliverWebPushClientEvent( info: { id: string; host?: string; user?: string; os?: string }, canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean, getUserRole: (userId: number) => string | undefined, + isClientEventPushEnabled?: (userId: number) => boolean, ): Promise { const subs = getAllPushSubscriptions(); if (subs.length === 0) return; @@ -356,6 +360,7 @@ export async function deliverWebPushClientEvent( subs.map(async (sub) => { const role = getUserRole(sub.userId); if (!role) return; + if (isClientEventPushEnabled && !isClientEventPushEnabled(sub.userId)) return; if (event === "client_purgatory") { if (role !== "admin" && role !== "operator") return; } else if (role !== "admin") { @@ -418,7 +423,7 @@ export async function deliverClientEventToExternalChannels( await Promise.allSettled( targets.map(async (target) => { - if (target.webhookEnabled && target.webhookUrl) { + if (target.webhookEnabled && target.clientEventWebhook && target.webhookUrl) { const url = target.webhookUrl.trim(); if (!url) return; let parsed: URL; @@ -474,7 +479,7 @@ export async function deliverClientEventToExternalChannels( } } - if (target.telegramEnabled && target.telegramBotToken && target.telegramChatId) { + if (target.telegramEnabled && target.clientEventTelegram && target.telegramBotToken && target.telegramChatId) { const token = target.telegramBotToken.trim(); const chatId = target.telegramChatId.trim(); if (!token || !chatId) return; diff --git a/Overlord-Server/src/server/routes/enrollment-routes.ts b/Overlord-Server/src/server/routes/enrollment-routes.ts index db1e07a..19d1b38 100644 --- a/Overlord-Server/src/server/routes/enrollment-routes.ts +++ b/Overlord-Server/src/server/routes/enrollment-routes.ts @@ -14,6 +14,25 @@ import { import { logAudit, AuditAction } from "../../auditLog"; import * as clientManager from "../../clientManager"; import { setOnlineState } from "../../db"; +import { + canUserAccessClient, + getUserClientAccessScope, + listUserClientRuleIdsByAccess, +} from "../../users"; + +function getClientScopeFilters(userId: number, role: string): { allowedClientIds?: string[]; deniedClientIds?: string[] } { + if (role === "admin") return {}; + const scope = getUserClientAccessScope(userId); + if (scope === "none") return { allowedClientIds: [] }; + if (scope === "all") return {}; + if (scope === "allowlist") { + return { allowedClientIds: listUserClientRuleIdsByAccess(userId, "allow") }; + } + if (scope === "denylist") { + return { deniedClientIds: listUserClientRuleIdsByAccess(userId, "deny") }; + } + return { allowedClientIds: [] }; +} let _postApproveHook: ((clientId: string) => void) | undefined; @@ -31,7 +50,8 @@ export async function handleEnrollmentRoutes( if (!user) return new Response("Unauthorized", { status: 401 }); if (user.role === "viewer") return new Response("Forbidden", { status: 403 }); - const clients = getPendingClients(); + const scopeFilters = getClientScopeFilters(user.userId, user.role); + const clients = getPendingClients(scopeFilters); return Response.json({ items: clients }); } @@ -40,7 +60,8 @@ export async function handleEnrollmentRoutes( const user = await authenticateRequest(req); if (!user) return new Response("Unauthorized", { status: 401 }); - const stats = getEnrollmentStats(); + const scopeFilters = getClientScopeFilters(user.userId, user.role); + const stats = getEnrollmentStats(scopeFilters); return Response.json(stats); } @@ -66,6 +87,10 @@ export async function handleEnrollmentRoutes( const current = getClientEnrollmentStatus(clientId); if (!current) return Response.json({ error: "Client not found" }, { status: 404 }); + if (!canUserAccessClient(user.userId, user.role, clientId)) { + return new Response("Forbidden: Client access denied", { status: 403 }); + } + setClientEnrollmentStatus(clientId, "approved", user.username); _postApproveHook?.(clientId); @@ -94,6 +119,10 @@ export async function handleEnrollmentRoutes( const current = getClientEnrollmentStatus(clientId); if (!current) return Response.json({ error: "Client not found" }, { status: 404 }); + if (!canUserAccessClient(user.userId, user.role, clientId)) { + return new Response("Forbidden: Client access denied", { status: 403 }); + } + setClientEnrollmentStatus(clientId, "denied"); logAudit({ @@ -120,6 +149,10 @@ export async function handleEnrollmentRoutes( const current = getClientEnrollmentStatus(clientId); if (!current) return Response.json({ error: "Client not found" }, { status: 404 }); + if (!canUserAccessClient(user.userId, user.role, clientId)) { + return new Response("Forbidden: Client access denied", { status: 403 }); + } + setClientEnrollmentStatus(clientId, "pending"); return Response.json({ ok: true, status: "pending" }); @@ -151,6 +184,7 @@ export async function handleEnrollmentRoutes( if (action === "ban-ip") { let banned = 0; for (const id of ids) { + if (!canUserAccessClient(user.userId, user.role, id)) continue; const clientIp = getClientIp(id); if (!clientIp) continue; banIp(clientIp, `Bulk banned from purgatory by ${user.username}`); @@ -178,6 +212,7 @@ export async function handleEnrollmentRoutes( const status = action === "approve" ? "approved" : action === "deny" ? "denied" : "pending"; let updated = 0; for (const id of ids) { + if (!canUserAccessClient(user.userId, user.role, id)) continue; const ok = setClientEnrollmentStatus( id, status as "approved" | "denied" | "pending", @@ -215,6 +250,10 @@ export async function handleEnrollmentRoutes( const targetIp = getClientIp(clientId); if (!targetIp) return Response.json({ error: "Client IP not found" }, { status: 404 }); + if (!canUserAccessClient(user.userId, user.role, clientId)) { + return new Response("Forbidden: Client access denied", { status: 403 }); + } + banIp(targetIp, `Banned from purgatory by ${user.username} for client ${clientId}`); setClientEnrollmentStatus(clientId, "denied"); diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 8c58936..63ce47a 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -4,7 +4,7 @@ import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfi import { getClientMetricsSummary } from "../../db"; import { metrics } from "../../metrics"; import { requirePermission } from "../../rbac"; -import { getUserTelegramChatId, setUserTelegramChatId } from "../../users"; +import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess } from "../../users"; import { runCertbotSetup } from "../certbot-setup"; import { getActiveProxies, @@ -311,6 +311,20 @@ export async function handleMiscRoutes( const endDate = Number(url.searchParams.get("endDate") || 0) || undefined; const successOnly = url.searchParams.get("successOnly") === "true"; + let allowedClientIds: string[] | undefined; + let deniedClientIds: string[] | undefined; + if (user.role !== "admin") { + const scope = getUserClientAccessScope(user.userId); + if (scope === "none") { + return Response.json({ logs: [], total: 0, page, pageSize }, { headers: deps.CORS_HEADERS }); + } + if (scope === "allowlist") { + allowedClientIds = listUserClientRuleIdsByAccess(user.userId, "allow"); + } else if (scope === "denylist") { + deniedClientIds = listUserClientRuleIdsByAccess(user.userId, "deny"); + } + } + const result = getAuditLogs({ page, pageSize, @@ -320,6 +334,8 @@ export async function handleMiscRoutes( startDate, endDate, successOnly, + allowedClientIds, + deniedClientIds, }); return Response.json(result, { headers: deps.CORS_HEADERS }); diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.ts b/Overlord-Server/src/server/routes/notifications-config-routes.ts index 4700efd..6de815e 100644 --- a/Overlord-Server/src/server/routes/notifications-config-routes.ts +++ b/Overlord-Server/src/server/routes/notifications-config-routes.ts @@ -224,6 +224,9 @@ export async function handleNotificationsConfigRoutes( telegram_bot_token: null, telegram_chat_id: null, telegram_template: null, + client_event_webhook: 1, + client_event_telegram: 1, + client_event_push: 1, }; return Response.json({ settings, @@ -276,6 +279,15 @@ export async function handleNotificationsConfigRoutes( if (typeof body?.telegram_template === "string") { patch.telegram_template = body.telegram_template.trim() || null; } + if (typeof body?.client_event_webhook === "boolean" || typeof body?.client_event_webhook === "number") { + patch.client_event_webhook = body.client_event_webhook ? 1 : 0; + } + if (typeof body?.client_event_telegram === "boolean" || typeof body?.client_event_telegram === "number") { + patch.client_event_telegram = body.client_event_telegram ? 1 : 0; + } + if (typeof body?.client_event_push === "boolean" || typeof body?.client_event_push === "number") { + patch.client_event_push = body.client_event_push ? 1 : 0; + } const result = updateUserNotificationSettings(user.userId, patch); if (!result.success) { diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts index 44c13e7..072a040 100644 --- a/Overlord-Server/src/server/ws-notifications-plugin.ts +++ b/Overlord-Server/src/server/ws-notifications-plugin.ts @@ -357,9 +357,14 @@ export function createNotificationPluginHandlers(deps: CreateDeps) { safeSendViewer(session.viewer, item); } - void deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole); - const externalTargets = deps.getDeliveryTargetsForClientEvent(event, info.id); + const pushEnabledByUser = new Map(externalTargets.map((t) => [t.userId, t.clientEventPush])); + + void deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole, (userId) => { + const enabled = pushEnabledByUser.get(userId); + return enabled !== undefined ? enabled : true; + }); + void deliverClientEventToExternalChannels(event, info, externalTargets); }, diff --git a/Overlord-Server/src/users.ts b/Overlord-Server/src/users.ts index d2c5b02..03522b5 100644 --- a/Overlord-Server/src/users.ts +++ b/Overlord-Server/src/users.ts @@ -176,6 +176,22 @@ try { } } +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); + } + } +} + const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number; }; @@ -569,7 +585,7 @@ export function canBuildClients(userId: number, role: UserRole): boolean { } export function canViewAuditLogs(role: UserRole): boolean { - return role === "admin"; + return role === "admin" || role === "operator"; } export function hasPermission(role: UserRole, permission: string, userId?: number): boolean { @@ -661,12 +677,15 @@ export interface UserNotificationSettings { telegram_bot_token: string | null; telegram_chat_id: string | null; telegram_template: string | null; + client_event_webhook: number; + client_event_telegram: number; + client_event_push: number; } export function getUserNotificationSettings(userId: number): UserNotificationSettings | null { const row = db .prepare( - "SELECT webhook_enabled, webhook_url, webhook_template, telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template FROM users WHERE id = ?", + "SELECT webhook_enabled, webhook_url, webhook_template, telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template, client_event_webhook, client_event_telegram, client_event_push FROM users WHERE id = ?", ) .get(userId) as UserNotificationSettings | undefined; return row ?? null; @@ -707,6 +726,18 @@ export function updateUserNotificationSettings( fields.push("telegram_template = ?"); values.push(settings.telegram_template || null); } + if ("client_event_webhook" in settings) { + fields.push("client_event_webhook = ?"); + values.push(settings.client_event_webhook ? 1 : 0); + } + if ("client_event_telegram" in settings) { + fields.push("client_event_telegram = ?"); + values.push(settings.client_event_telegram ? 1 : 0); + } + if ("client_event_push" in settings) { + fields.push("client_event_push = ?"); + values.push(settings.client_event_push ? 1 : 0); + } if (fields.length === 0) return { success: true }; @@ -733,6 +764,9 @@ export interface UserDeliveryRow { telegram_bot_token: string | null; telegram_chat_id: string | null; telegram_template: string | null; + client_event_webhook: number; + client_event_telegram: number; + client_event_push: number; } export function getUsersForNotificationDelivery(): UserDeliveryRow[] { @@ -744,7 +778,8 @@ export function getUsersForNotificationDelivery(): UserDeliveryRow[] { .prepare( `SELECT id, username, role, client_scope, webhook_enabled, webhook_url, webhook_template, - telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template + telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template, + client_event_webhook, client_event_telegram, client_event_push FROM users WHERE (webhook_enabled = 1 AND webhook_url IS NOT NULL AND webhook_url != '') OR (telegram_enabled = 1 AND telegram_bot_token IS NOT NULL AND telegram_bot_token != ''