From 7075cef326dce7004943d2262b7d4105434f94b9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 25 Oct 2025 14:54:03 +0000 Subject: [PATCH] Harden API auth and token handling --- .../src/components/FirstRunSetup.tsx | 8 +- .../components/Settings/APITokenManager.tsx | 12 +-- .../components/Settings/CommandBuilder.tsx | 20 ++-- .../Settings/QuickSecuritySetup.tsx | 5 +- .../src/components/Settings/Settings.tsx | 28 ++++-- .../src/hooks/useScopedTokenManager.ts | 25 ++++- frontend-modern/src/utils/apiClient.ts | 16 +++ frontend-modern/src/utils/tokenStorage.ts | 98 +++++++++++++++++++ internal/api/auth.go | 38 ++----- internal/api/notifications.go | 54 ++++++++++ internal/api/router.go | 51 ++++++---- 11 files changed, 268 insertions(+), 87 deletions(-) create mode 100644 frontend-modern/src/utils/tokenStorage.ts diff --git a/frontend-modern/src/components/FirstRunSetup.tsx b/frontend-modern/src/components/FirstRunSetup.tsx index e1d8a6c..7cf198c 100644 --- a/frontend-modern/src/components/FirstRunSetup.tsx +++ b/frontend-modern/src/components/FirstRunSetup.tsx @@ -1,6 +1,8 @@ import { Component, createSignal, Show, onMount } from 'solid-js'; import { showSuccess, showError } from '@/utils/toast'; import { copyToClipboard } from '@/utils/clipboard'; +import { clearStoredAPIToken } from '@/utils/tokenStorage'; +import { clearApiToken as clearApiClientToken, setApiToken as setApiClientToken } from '@/utils/apiClient'; import { STORAGE_KEYS } from '@/constants'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { showTokenReveal } from '@/stores/tokenReveal'; @@ -11,7 +13,6 @@ export const FirstRunSetup: Component = () => { const [password, setPassword] = createSignal(''); const [confirmPassword, setConfirmPassword] = createSignal(''); const [useCustomPassword, setUseCustomPassword] = createSignal(false); - const [, setApiToken] = createSignal(''); const [isSettingUp, setIsSettingUp] = createSignal(false); const [showCredentials, setShowCredentials] = createSignal(false); const [savedUsername, setSavedUsername] = createSignal(''); @@ -98,7 +99,7 @@ export const FirstRunSetup: Component = () => { // Generate API token const token = generateToken(); - setApiToken(token); + setApiClientToken(token); try { const response = await fetch('/api/security/quick-setup', { @@ -139,7 +140,8 @@ export const FirstRunSetup: Component = () => { } try { - localStorage.removeItem('apiToken'); + clearStoredAPIToken(); + clearApiClientToken(); } catch (storageError) { console.warn('Unable to clear cached API token', storageError); } diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index 9b8e9d0..1fea366 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -5,6 +5,8 @@ import { formatRelativeTime } from '@/utils/format'; import { useWebSocket } from '@/App'; import type { DockerHost, Host } from '@/types/api'; import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal'; +import { setStoredAPIToken } from '@/utils/tokenStorage'; +import { setApiToken as setApiClientToken } from '@/utils/apiClient'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { ApiIcon } from '@/components/icons/ApiIcon'; @@ -261,14 +263,8 @@ export const APITokenManager: Component = (props) => { showSuccess('New API token generated. Copy it below while it is still visible.'); props.onTokensChanged?.(); - try { - window.localStorage.setItem('apiToken', token); - window.dispatchEvent( - new StorageEvent('storage', { key: 'apiToken', newValue: token }), - ); - } catch (storageErr) { - console.warn('Unable to persist API token in localStorage', storageErr); - } + setStoredAPIToken(token); + setApiClientToken(token); } catch (err) { console.error('Failed to generate API token', err); showError('Failed to generate API token'); diff --git a/frontend-modern/src/components/Settings/CommandBuilder.tsx b/frontend-modern/src/components/Settings/CommandBuilder.tsx index ea40151..990bfef 100644 --- a/frontend-modern/src/components/Settings/CommandBuilder.tsx +++ b/frontend-modern/src/components/Settings/CommandBuilder.tsx @@ -2,6 +2,8 @@ import { Component, createSignal, Show, createMemo, createEffect } from 'solid-j import { apiFetch } from '@/utils/apiClient'; import { SecurityAPI, type APITokenRecord } from '@/api/security'; import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal'; +import { setStoredAPIToken } from '@/utils/tokenStorage'; +import { setApiToken as setApiClientToken } from '@/utils/apiClient'; interface CommandBuilderProps { command: string; @@ -203,20 +205,14 @@ export const CommandBuilder: Component = (props) => { reopenLatestTokenDialog(); // Auto-populate the command builder - setTokenInput(newToken); - if (props.onTokenGenerated) { - props.onTokenGenerated(newToken, record); - } - - if (typeof window !== 'undefined') { - try { - window.localStorage.setItem('apiToken', newToken); - window.dispatchEvent(new StorageEvent('storage', { key: 'apiToken', newValue: newToken })); - } catch (storageErr) { - console.warn('Unable to persist API token in localStorage', storageErr); - } + setTokenInput(newToken); + if (props.onTokenGenerated) { + props.onTokenGenerated(newToken, record); } + setStoredAPIToken(newToken); + setApiClientToken(newToken); + window.showToast('success', 'New API token generated. Copy it from the dialog while it is visible.'); } catch (error) { console.error('Token generation failed:', error); diff --git a/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx b/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx index 5e48679..3a7c106 100644 --- a/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx +++ b/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx @@ -1,6 +1,8 @@ import { Component, createSignal, Show } from 'solid-js'; import { showSuccess, showError } from '@/utils/toast'; import { copyToClipboard } from '@/utils/clipboard'; +import { clearStoredAPIToken } from '@/utils/tokenStorage'; +import { clearApiToken as clearApiClientToken } from '@/utils/apiClient'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form'; @@ -125,7 +127,8 @@ export const QuickSecuritySetup: Component = (props) => } try { - localStorage.removeItem('apiToken'); + clearStoredAPIToken(); + clearApiClientToken(); } catch (storageError) { console.warn('Unable to clear cached API token', storageError); } diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 47514c7..858464b 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -4,6 +4,12 @@ import { useNavigate, useLocation } from '@solidjs/router'; import { useWebSocket } from '@/App'; import { showSuccess, showError } from '@/utils/toast'; import { copyToClipboard } from '@/utils/clipboard'; +import { + clearStoredAPIToken, + getStoredAPIToken, + setStoredAPIToken, +} from '@/utils/tokenStorage'; +import { clearApiToken as clearApiClientToken, setApiToken as setApiClientToken } from '@/utils/apiClient'; import { NodeModal } from './NodeModal'; import { ChangePasswordModal } from './ChangePasswordModal'; import { DockerAgents } from './DockerAgents'; @@ -1723,7 +1729,7 @@ const Settings: Component = (props) => { if ( !hasPasswordAuth && securityStatus()?.apiTokenConfigured && - !localStorage.getItem('apiToken') + !getStoredAPIToken() ) { setApiTokenModalSource('export'); setShowApiTokenModal(true); @@ -1747,7 +1753,7 @@ const Settings: Component = (props) => { } // Add API token if configured - const apiToken = localStorage.getItem('apiToken'); + const apiToken = getStoredAPIToken(); if (apiToken) { headers['X-API-Token'] = apiToken; } @@ -1767,9 +1773,10 @@ const Settings: Component = (props) => { const hasPasswordAuth = securityStatus()?.hasAuthentication; if (!hasPasswordAuth) { // Clear invalid token if we had one - const hadToken = localStorage.getItem('apiToken'); + const hadToken = getStoredAPIToken(); if (hadToken) { - localStorage.removeItem('apiToken'); + clearStoredAPIToken(); + clearApiClientToken(); showError('Invalid or expired API token. Please re-enter.'); setApiTokenModalSource('export'); setShowApiTokenModal(true); @@ -1827,7 +1834,7 @@ const Settings: Component = (props) => { if ( !hasPasswordAuth && securityStatus()?.apiTokenConfigured && - !localStorage.getItem('apiToken') + !getStoredAPIToken() ) { setApiTokenModalSource('import'); setShowApiTokenModal(true); @@ -1861,7 +1868,7 @@ const Settings: Component = (props) => { } // Add API token if configured - const apiToken = localStorage.getItem('apiToken'); + const apiToken = getStoredAPIToken(); if (apiToken) { headers['X-API-Token'] = apiToken; } @@ -1884,9 +1891,10 @@ const Settings: Component = (props) => { const hasPasswordAuth = securityStatus()?.hasAuthentication; if (!hasPasswordAuth) { // Clear invalid token if we had one - const hadToken = localStorage.getItem('apiToken'); + const hadToken = getStoredAPIToken(); if (hadToken) { - localStorage.removeItem('apiToken'); + clearStoredAPIToken(); + clearApiClientToken(); showError('Invalid or expired API token. Please re-enter.'); setApiTokenModalSource('import'); setShowApiTokenModal(true); @@ -6437,7 +6445,9 @@ const Settings: Component = (props) => { type="button" onClick={() => { if (apiTokenInput()) { - localStorage.setItem('apiToken', apiTokenInput()); + const tokenValue = apiTokenInput()!; + setStoredAPIToken(tokenValue); + setApiClientToken(tokenValue); const source = apiTokenModalSource(); setShowApiTokenModal(false); setApiTokenInput(''); diff --git a/frontend-modern/src/hooks/useScopedTokenManager.ts b/frontend-modern/src/hooks/useScopedTokenManager.ts index 51c4135..fa029f2 100644 --- a/frontend-modern/src/hooks/useScopedTokenManager.ts +++ b/frontend-modern/src/hooks/useScopedTokenManager.ts @@ -39,12 +39,21 @@ const readStoredToken = (primaryKey: string, legacyKeys: readonly string[] = []) if (typeof window === 'undefined') { return null; } - const primary = window.localStorage.getItem(primaryKey); + let storage: Storage | undefined; + try { + storage = window.sessionStorage; + } catch { + storage = undefined; + } + if (!storage) { + return null; + } + const primary = storage.getItem(primaryKey); if (primary) { return primary; } for (const key of legacyKeys) { - const value = window.localStorage.getItem(key); + const value = storage.getItem(key); if (value) { return value; } @@ -69,16 +78,22 @@ export const useScopedTokenManager = (options: UseScopedTokenManagerOptions): Sc if (typeof window === 'undefined') { return; } + let storage: Storage | undefined; + try { + storage = window.sessionStorage; + } catch { + storage = undefined; + } try { if (current) { - window.localStorage.setItem(storageKey, current); + storage?.setItem(storageKey, current); try { window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: current })); } catch (eventErr) { console.debug('Unable to dispatch storage event for token update', eventErr); } } else { - window.localStorage.removeItem(storageKey); + storage?.removeItem(storageKey); try { window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: null })); } catch (eventErr) { @@ -86,7 +101,7 @@ export const useScopedTokenManager = (options: UseScopedTokenManagerOptions): Sc } } } catch (err) { - console.warn('Unable to persist API token in localStorage', err); + console.warn('Unable to persist API token in sessionStorage', err); } }); diff --git a/frontend-modern/src/utils/apiClient.ts b/frontend-modern/src/utils/apiClient.ts index d912a1b..e3a8e9c 100644 --- a/frontend-modern/src/utils/apiClient.ts +++ b/frontend-modern/src/utils/apiClient.ts @@ -87,6 +87,21 @@ class ApiClient { sessionStorage.removeItem('pulse_auth_user'); } + clearApiToken() { + this.apiToken = null; + try { + const stored = sessionStorage.getItem('pulse_auth'); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed?.type === 'token') { + sessionStorage.removeItem('pulse_auth'); + } + } + } catch { + sessionStorage.removeItem('pulse_auth'); + } + } + // Check if we have any auth configured hasAuth(): boolean { return !!(this.authHeader || this.apiToken); @@ -263,5 +278,6 @@ export const setBasicAuth = (username: string, password: string) => apiClient.setBasicAuth(username, password); export const setApiToken = (token: string) => apiClient.setApiToken(token); export const clearAuth = () => apiClient.clearAuth(); +export const clearApiToken = () => apiClient.clearApiToken(); export const hasAuth = () => apiClient.hasAuth(); export const checkAuthRequired = () => apiClient.checkAuthRequired(); diff --git a/frontend-modern/src/utils/tokenStorage.ts b/frontend-modern/src/utils/tokenStorage.ts new file mode 100644 index 0000000..2258721 --- /dev/null +++ b/frontend-modern/src/utils/tokenStorage.ts @@ -0,0 +1,98 @@ +import { onCleanup } from 'solid-js'; + +const STORAGE_KEY = 'pulse_api_token'; + +let memoryToken: string | null = null; +const listeners = new Set<(value: string | null) => void>(); + +const getSessionStorage = (): Storage | undefined => { + if (typeof window === 'undefined') { + return undefined; + } + try { + return window.sessionStorage; + } catch { + return undefined; + } +}; + +const notify = (value: string | null) => { + listeners.forEach((listener) => { + try { + listener(value); + } catch { + // Ignore listener failures to avoid breaking notification flow. + } + }); + + if (typeof window !== 'undefined') { + try { + window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY, newValue: value ?? null })); + } catch { + // Some browsers disallow programmatic StorageEvent dispatch; ignore failures. + } + } +}; + +const writeValue = (value: string | null) => { + memoryToken = value; + const storage = getSessionStorage(); + if (!storage) { + notify(value); + return; + } + + try { + if (value === null) { + storage.removeItem(STORAGE_KEY); + } else { + storage.setItem(STORAGE_KEY, value); + } + } catch { + // If sessionStorage is unavailable (e.g., Safari private mode), fall back to memory token only. + } + + notify(value); +}; + +export const getStoredAPIToken = (): string | null => { + const storage = getSessionStorage(); + if (!storage) { + return memoryToken; + } + + try { + const stored = storage.getItem(STORAGE_KEY); + if (stored !== null) { + memoryToken = stored; + } + return stored ?? memoryToken; + } catch { + return memoryToken; + } +}; + +export const setStoredAPIToken = (token: string): void => { + writeValue(token); +}; + +export const clearStoredAPIToken = (): void => { + writeValue(null); +}; + +export const subscribeAPIToken = (listener: (token: string | null) => void): (() => void) => { + listeners.add(listener); + listener(getStoredAPIToken()); + return () => { + listeners.delete(listener); + }; +}; + +export const useAPITokenSubscription = (listener: (token: string | null) => void): void => { + if (typeof window === 'undefined') { + listener(getStoredAPIToken()); + return; + } + const unsubscribe = subscribeAPIToken(listener); + onCleanup(unsubscribe); +}; diff --git a/internal/api/auth.go b/internal/api/auth.go index 0192e4b..02778d7 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -230,7 +230,6 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool } // API-only mode: when only API token is configured (no password auth) - // Allow read-only endpoints for the UI to work if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.HasAPITokens() { // Check if an API token was provided providedToken := r.Header.Get("X-API-Token") @@ -251,32 +250,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool return false } - // No token provided - allow read-only endpoints for UI - if r.Method == "GET" || r.URL.Path == "/ws" { - // Allow these endpoints without auth for UI to function - allowedPaths := []string{ - "/api/state", - "/api/config/nodes", - "/api/config/system", - "/api/settings", - "/api/discover", - "/api/security/status", - "/api/version", - "/api/health", - "/api/updates/check", - "/api/system/diagnostics", - "/api/guests/metadata", - "/ws", // WebSocket for real-time updates - } - for _, path := range allowedPaths { - if r.URL.Path == path || strings.HasPrefix(r.URL.Path, path+"/") { - log.Debug().Str("path", r.URL.Path).Msg("Allowing read-only access in API-only mode") - return true - } - } - } - - // Require token for everything else + // Require a valid token for all requests in API-only mode if w != nil { w.Header().Set("WWW-Authenticate", `Bearer realm="API Token Required"`) http.Error(w, "API token required", http.StatusUnauthorized) @@ -342,7 +316,15 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool // Check basic auth if cfg.AuthUser != "" && cfg.AuthPass != "" { auth := r.Header.Get("Authorization") - log.Debug().Str("auth_header", auth).Str("url", r.URL.Path).Msg("Checking auth") + authScheme := "none" + if auth != "" { + if idx := strings.IndexByte(auth, ' '); idx != -1 { + authScheme = strings.ToLower(auth[:idx]) + } else { + authScheme = strings.ToLower(auth) + } + } + log.Debug().Str("auth_scheme", authScheme).Str("url", r.URL.Path).Msg("Checking Authorization header") if auth != "" { const prefix = "Basic " if strings.HasPrefix(auth, prefix) { diff --git a/internal/api/notifications.go b/internal/api/notifications.go index d556023..10732f9 100644 --- a/internal/api/notifications.go +++ b/internal/api/notifications.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" "github.com/rcourtman/pulse-go-rewrite/internal/utils" @@ -525,32 +526,85 @@ func (h *NotificationHandlers) TestWebhook(w http.ResponseWriter, r *http.Reques func (h *NotificationHandlers) HandleNotifications(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/notifications") + requireAnyScope := func(required string, scopes ...string) bool { + record := getAPITokenRecordFromRequest(r) + if record == nil { + return true + } + for _, scope := range scopes { + if scope != "" && record.HasScope(scope) { + return true + } + } + respondMissingScope(w, required) + return false + } + switch { case path == "/email" && r.Method == http.MethodGet: + if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) { + return + } h.GetEmailConfig(w, r) case path == "/email" && r.Method == http.MethodPut: + if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) { + return + } h.UpdateEmailConfig(w, r) case path == "/apprise" && r.Method == http.MethodGet: + if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) { + return + } h.GetAppriseConfig(w, r) case path == "/apprise" && r.Method == http.MethodPut: + if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) { + return + } h.UpdateAppriseConfig(w, r) case path == "/webhooks" && r.Method == http.MethodGet: + if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) { + return + } h.GetWebhooks(w, r) case path == "/webhooks" && r.Method == http.MethodPost: + if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) { + return + } h.CreateWebhook(w, r) case path == "/webhooks/test" && r.Method == http.MethodPost: + if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) { + return + } h.TestWebhook(w, r) case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodPut: + if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) { + return + } h.UpdateWebhook(w, r) case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodDelete: + if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) { + return + } h.DeleteWebhook(w, r) case path == "/webhook-templates" && r.Method == http.MethodGet: + if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) { + return + } h.GetWebhookTemplates(w, r) case path == "/webhook-history" && r.Method == http.MethodGet: + if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) { + return + } h.GetWebhookHistory(w, r) case path == "/email-providers" && r.Method == http.MethodGet: + if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) { + return + } h.GetEmailProviders(w, r) case path == "/test" && r.Method == http.MethodPost: + if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) { + return + } h.TestNotification(w, r) default: http.Error(w, "Not found", http.StatusNotFound) diff --git a/internal/api/router.go b/internal/api/router.go index e32b645..6f840c9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -137,45 +137,54 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleCommandAck))) r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions))) r.mux.HandleFunc("/api/version", r.handleVersion) - r.mux.HandleFunc("/api/storage/", r.handleStorage) - r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts) - r.mux.HandleFunc("/api/charts", r.handleCharts) + r.mux.HandleFunc("/api/storage/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorage))) + r.mux.HandleFunc("/api/storage-charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorageCharts))) + r.mux.HandleFunc("/api/charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleCharts))) r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics)) r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsRegisterProxyNodes))) r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken))) r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy) r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript) r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript) - r.mux.HandleFunc("/api/config", r.handleConfig) - r.mux.HandleFunc("/api/backups", r.handleBackups) - r.mux.HandleFunc("/api/backups/", r.handleBackups) - r.mux.HandleFunc("/api/backups/unified", r.handleBackups) - r.mux.HandleFunc("/api/backups/pve", r.handleBackupsPVE) - r.mux.HandleFunc("/api/backups/pbs", r.handleBackupsPBS) - r.mux.HandleFunc("/api/snapshots", r.handleSnapshots) + r.mux.HandleFunc("/api/config", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleConfig))) + r.mux.HandleFunc("/api/backups", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups))) + r.mux.HandleFunc("/api/backups/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups))) + r.mux.HandleFunc("/api/backups/unified", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups))) + r.mux.HandleFunc("/api/backups/pve", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackupsPVE))) + r.mux.HandleFunc("/api/backups/pbs", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackupsPBS))) + r.mux.HandleFunc("/api/snapshots", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleSnapshots))) // Guest metadata routes - r.mux.HandleFunc("/api/guests/metadata", guestMetadataHandler.HandleGetMetadata) - r.mux.HandleFunc("/api/guests/metadata/", func(w http.ResponseWriter, req *http.Request) { + r.mux.HandleFunc("/api/guests/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, guestMetadataHandler.HandleGetMetadata))) + r.mux.HandleFunc("/api/guests/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: + if !ensureScope(w, req, config.ScopeMonitoringRead) { + return + } guestMetadataHandler.HandleGetMetadata(w, req) case http.MethodPut, http.MethodPost: + if !ensureScope(w, req, config.ScopeMonitoringWrite) { + return + } guestMetadataHandler.HandleUpdateMetadata(w, req) case http.MethodDelete: + if !ensureScope(w, req, config.ScopeMonitoringWrite) { + return + } guestMetadataHandler.HandleDeleteMetadata(w, req) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } - }) + })) // Update routes - r.mux.HandleFunc("/api/updates/check", updateHandlers.HandleCheckUpdates) - r.mux.HandleFunc("/api/updates/apply", updateHandlers.HandleApplyUpdate) - r.mux.HandleFunc("/api/updates/status", updateHandlers.HandleUpdateStatus) - r.mux.HandleFunc("/api/updates/plan", updateHandlers.HandleGetUpdatePlan) - r.mux.HandleFunc("/api/updates/history", updateHandlers.HandleListUpdateHistory) - r.mux.HandleFunc("/api/updates/history/entry", updateHandlers.HandleGetUpdateHistoryEntry) + r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates))) + r.mux.HandleFunc("/api/updates/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyUpdate))) + r.mux.HandleFunc("/api/updates/status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStatus))) + r.mux.HandleFunc("/api/updates/plan", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdatePlan))) + r.mux.HandleFunc("/api/updates/history", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleListUpdateHistory))) + r.mux.HandleFunc("/api/updates/history/entry", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdateHistoryEntry))) // Config management routes r.mux.HandleFunc("/api/config/nodes", func(w http.ResponseWriter, req *http.Request) { @@ -860,10 +869,10 @@ func (r *Router) setupRoutes() { }) // Alert routes - r.mux.HandleFunc("/api/alerts/", r.alertHandlers.HandleAlerts) + r.mux.HandleFunc("/api/alerts/", RequireAuth(r.config, r.alertHandlers.HandleAlerts)) // Notification routes - r.mux.HandleFunc("/api/notifications/", r.notificationHandlers.HandleNotifications) + r.mux.HandleFunc("/api/notifications/", RequireAdmin(r.config, r.notificationHandlers.HandleNotifications)) // Settings routes r.mux.HandleFunc("/api/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, getSettings)))