security: add scope checks to alerts, AI models, patrol status/stream, and remaining AI endpoints

- /api/alerts/* now requires monitoring:read scope
- /api/ai/models now requires ai:chat scope
- /api/ai/patrol/status and /api/ai/patrol/stream now require ai:execute scope
- /api/ai/patrol/findings now requires ai:execute scope
- /api/ai/remediation/* endpoints now require ai:execute scope
- /api/ai/circuit/status now requires ai:execute scope
- /api/ai/incidents/* now requires ai:execute scope
- /api/ai/question/* now requires ai:chat scope
- /api/ai/agents now requires ai:execute scope
- /api/ai/cost/summary now requires settings:read scope
This commit is contained in:
rcourtman
2026-02-03 19:48:43 +00:00
parent c295ee277f
commit 832fda6c96
3 changed files with 64 additions and 25 deletions

View File

@@ -38,6 +38,9 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
const [editingNotes, setEditingNotes] = createSignal(false);
// Track if initial fetch has completed to prevent flash of "no data" state
const [hasFetched, setHasFetched] = createSignal(false);
// Live elapsed time counter (updates every second while scanning)
const [liveElapsedSeconds, setLiveElapsedSeconds] = createSignal(0);
const [scanStartTime, setScanStartTime] = createSignal<number | null>(null);
// Delayed loading spinner - only show after 150ms to prevent flash
const [showLoadingSpinner, setShowLoadingSpinner] = createSignal(false);
@@ -165,6 +168,17 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
}
});
// Live elapsed time timer - updates every second while scanning
createEffect(() => {
if (isScanning() && scanStartTime()) {
const interval = setInterval(() => {
const elapsed = Math.floor((Date.now() - scanStartTime()!) / 1000);
setLiveElapsedSeconds(elapsed);
}, 1000);
onCleanup(() => clearInterval(interval));
}
});
// Handle triggering a new discovery
const handleTriggerDiscovery = async (force = false) => {
setIsScanning(true);
@@ -172,6 +186,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
setScanProgress(null);
setScanError(null);
setScanSuccess(false);
setScanStartTime(Date.now()); // Start the live timer
setLiveElapsedSeconds(0);
try {
// triggerDiscovery runs the discovery and returns the result directly
const result = await triggerDiscovery(props.resourceType, props.hostId, props.resourceId, {
@@ -186,6 +202,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
setHttpScanInProgress(false);
setIsScanning(false);
setScanProgress(null);
setScanStartTime(null); // Stop the live timer
setScanSuccess(true);
// Clear success after user has seen it
setTimeout(() => setScanSuccess(false), 2000);
@@ -211,6 +228,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
setHttpScanInProgress(false);
setIsScanning(false);
setScanProgress(null);
setScanStartTime(null); // Stop the live timer
}
};
@@ -422,11 +440,25 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
Running: <code class="font-mono">{scanProgress()?.current_command}</code>
</div>
</Show>
<Show when={scanProgress()?.elapsed_ms}>
<div class="mt-1 text-xs text-blue-500 dark:text-blue-500">
Elapsed: {((scanProgress()?.elapsed_ms || 0) / 1000).toFixed(1)}s
</div>
</Show>
{/* Live elapsed time - always show while scanning */}
<div class="mt-1 text-xs text-blue-500 dark:text-blue-500">
Elapsed: {liveElapsedSeconds()}s
</div>
</div>
</Show>
{/* Scanning state without WebSocket progress - show live timer */}
<Show when={isScanning() && !scanProgress()}>
<div class="rounded border border-blue-200 bg-blue-50 p-3 shadow-sm dark:border-blue-800 dark:bg-blue-900/30">
<div class="flex items-center gap-2 mb-2">
<div class="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
<span class="text-sm font-medium text-blue-700 dark:text-blue-300">
Running discovery...
</span>
</div>
<div class="text-xs text-blue-500 dark:text-blue-500">
Elapsed: {liveElapsedSeconds()}s
</div>
</div>
</Show>

View File

@@ -1238,8 +1238,8 @@ func (r *Router) setupRoutes() {
json.NewEncoder(w).Encode(map[string]string{"status": "notification sent"})
})
// Alert routes
r.mux.HandleFunc("/api/alerts/", RequireAuth(r.config, r.alertHandlers.HandleAlerts))
// Alert routes - require monitoring:read scope to view alerts
r.mux.HandleFunc("/api/alerts/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.alertHandlers.HandleAlerts)))
// Notification routes
r.mux.HandleFunc("/api/notifications/", RequireAdmin(r.config, r.notificationHandlers.HandleNotifications))
@@ -1380,7 +1380,8 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/settings/ai/update", RequirePermission(r.config, r.authorizer, auth.ActionWrite, auth.ResourceSettings, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleUpdateAISettings)))
r.mux.HandleFunc("/api/ai/test", RequirePermission(r.config, r.authorizer, auth.ActionWrite, auth.ResourceSettings, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleTestAIConnection)))
r.mux.HandleFunc("/api/ai/test/{provider}", RequirePermission(r.config, r.authorizer, auth.ActionWrite, auth.ResourceSettings, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleTestProvider)))
r.mux.HandleFunc("/api/ai/models", RequireAuth(r.config, r.aiSettingsHandler.HandleListModels))
// AI models list - require ai:chat scope (needed to select a model for chat)
r.mux.HandleFunc("/api/ai/models", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.aiSettingsHandler.HandleListModels)))
r.mux.HandleFunc("/api/ai/execute", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleExecute)))
r.mux.HandleFunc("/api/ai/execute/stream", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleExecuteStream)))
r.mux.HandleFunc("/api/ai/kubernetes/analyze", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, RequireLicenseFeature(r.licenseHandlers, license.FeatureKubernetesAI, r.aiSettingsHandler.HandleAnalyzeKubernetesCluster))))
@@ -1396,8 +1397,10 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/ai/knowledge/clear", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.aiSettingsHandler.HandleClearGuestKnowledge)))
// SECURITY: Debug context leaks system prompt and infra details - require settings:read scope
r.mux.HandleFunc("/api/ai/debug/context", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.aiSettingsHandler.HandleDebugContext)))
r.mux.HandleFunc("/api/ai/agents", RequireAuth(r.config, r.aiSettingsHandler.HandleGetConnectedAgents))
r.mux.HandleFunc("/api/ai/cost/summary", RequireAuth(r.config, r.aiSettingsHandler.HandleGetAICostSummary))
// SECURITY: Connected agents list could reveal fleet topology - require ai:execute scope
r.mux.HandleFunc("/api/ai/agents", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetConnectedAgents)))
// SECURITY: Cost summary could reveal usage patterns - require settings:read scope
r.mux.HandleFunc("/api/ai/cost/summary", RequireAuth(r.config, RequireScope(config.ScopeSettingsRead, r.aiSettingsHandler.HandleGetAICostSummary)))
r.mux.HandleFunc("/api/ai/cost/reset", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleResetAICostHistory)))
r.mux.HandleFunc("/api/ai/cost/export", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.aiSettingsHandler.HandleExportAICostHistory)))
// OAuth endpoints for Claude Pro/Max subscription authentication
@@ -1411,9 +1414,10 @@ func (r *Router) setupRoutes() {
// Note: Status remains accessible so UI can show license/upgrade state
// Read endpoints (findings, history, runs) return redacted preview data when unlicensed
// Mutation endpoints (run, acknowledge, dismiss, etc.) return 402 to prevent unauthorized actions
r.mux.HandleFunc("/api/ai/patrol/status", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolStatus))
r.mux.HandleFunc("/api/ai/patrol/stream", RequireAuth(r.config, r.aiSettingsHandler.HandlePatrolStream))
r.mux.HandleFunc("/api/ai/patrol/findings", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
// SECURITY: Patrol status and stream require ai:execute scope to access findings
r.mux.HandleFunc("/api/ai/patrol/status", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolStatus)))
r.mux.HandleFunc("/api/ai/patrol/stream", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandlePatrolStream)))
r.mux.HandleFunc("/api/ai/patrol/findings", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
r.aiSettingsHandler.HandleGetPatrolFindings(w, req)
@@ -1423,7 +1427,7 @@ func (r *Router) setupRoutes() {
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
})))
// SECURITY: AI Patrol read endpoints - require ai:execute scope
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetFindingsHistory)))
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleForcePatrol)))
@@ -1504,23 +1508,26 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/ai/learning/preferences", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetLearningPreferences)))
r.mux.HandleFunc("/api/ai/proxmox/events", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetProxmoxEvents)))
r.mux.HandleFunc("/api/ai/proxmox/correlations", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetProxmoxCorrelations)))
r.mux.HandleFunc("/api/ai/remediation/plans", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
// SECURITY: Remediation endpoints require ai:execute scope to prevent unauthorized access to remediation plans
r.mux.HandleFunc("/api/ai/remediation/plans", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
r.aiSettingsHandler.HandleGetRemediationPlans(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
r.mux.HandleFunc("/api/ai/remediation/plan", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRemediationPlan))
r.mux.HandleFunc("/api/ai/remediation/approve", RequireAuth(r.config, r.aiSettingsHandler.HandleApproveRemediationPlan))
})))
r.mux.HandleFunc("/api/ai/remediation/plan", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetRemediationPlan)))
// Approving a remediation plan is a mutation - keep with ai:execute scope
r.mux.HandleFunc("/api/ai/remediation/approve", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleApproveRemediationPlan)))
r.mux.HandleFunc("/api/ai/remediation/execute", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleExecuteRemediationPlan)))
r.mux.HandleFunc("/api/ai/remediation/rollback", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleRollbackRemediationPlan)))
r.mux.HandleFunc("/api/ai/circuit/status", RequireAuth(r.config, r.aiSettingsHandler.HandleGetCircuitBreakerStatus))
// SECURITY: Circuit breaker status could reveal operational state - require ai:execute scope
r.mux.HandleFunc("/api/ai/circuit/status", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetCircuitBreakerStatus)))
// Phase 7: Incident Recording API
r.mux.HandleFunc("/api/ai/incidents", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRecentIncidents))
r.mux.HandleFunc("/api/ai/incidents/", RequireAuth(r.config, r.aiSettingsHandler.HandleGetIncidentData))
// Phase 7: Incident Recording API - require ai:execute scope to protect incident data
r.mux.HandleFunc("/api/ai/incidents", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetRecentIncidents)))
r.mux.HandleFunc("/api/ai/incidents/", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetIncidentData)))
// AI Chat Sessions - sync across devices (legacy endpoints)
r.mux.HandleFunc("/api/ai/chat/sessions", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.aiSettingsHandler.HandleListAIChatSessions)))
@@ -1557,8 +1564,8 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/ai/approvals", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleListApprovals)))
r.mux.HandleFunc("/api/ai/approvals/", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.routeApprovals)))
// AI question endpoints
r.mux.HandleFunc("/api/ai/question/", RequireAuth(r.config, r.routeQuestions))
// AI question endpoints - require ai:chat scope for interactive AI features
r.mux.HandleFunc("/api/ai/question/", RequireAuth(r.config, RequireScope(config.ScopeAIChat, r.routeQuestions)))
// AI-powered infrastructure discovery endpoints
r.mux.HandleFunc("/api/discovery", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleListDiscoveries)))

View File

@@ -1177,7 +1177,7 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
s.broadcastProgress(&DiscoveryProgress{
ResourceID: resourceID,
Status: DiscoveryStatusRunning,
CurrentStep: "Analyzing with AI...",
CurrentStep: "Analyzing with Pulse Assistant...",
})
response, err := analyzer.AnalyzeForDiscovery(ctx, prompt)