package httpapi import ( "log/slog" "net/http" "time" "github.com/syncova/syncova/packages/agentregistry" "github.com/syncova/syncova/packages/agenttasks" "github.com/syncova/syncova/packages/alerting" "github.com/syncova/syncova/packages/audit" "github.com/syncova/syncova/packages/auth" "github.com/syncova/syncova/packages/hypervisor" "github.com/syncova/syncova/packages/jobs" "github.com/syncova/syncova/packages/metrics" "github.com/syncova/syncova/packages/platform/config" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/platform/health" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/platform/netguard" "github.com/syncova/syncova/packages/ransomware" "github.com/syncova/syncova/packages/recovery" "github.com/syncova/syncova/packages/reports" "github.com/syncova/syncova/packages/retention" "github.com/syncova/syncova/packages/security" "github.com/syncova/syncova/packages/verification" ) // apiBasePath ist das Präfix aller fachlichen Endpunkte (SYNCOVA_API.md). // Die Version ist Teil des Pfads, damit spätere Verträge parallel bestehen können. const apiBasePath = "/api/v1" // Grenzen des Anmeldeschutzes. // // Sie greifen je Absenderadresse und ergänzen die kontobezogene Sperre: ohne sie // könnte ein Angreifer viele Konten mit je wenigen Versuchen durchprobieren. const ( // loginRateLimitAttempts ist die Anzahl erlaubter Anmeldeversuche je Zeitfenster. loginRateLimitAttempts = 10 // loginRateLimitWindow ist das betrachtete Zeitfenster. loginRateLimitWindow = 5 * time.Minute // rateLimiterCleanupInterval ist der Abstand der Speicherbereinigung. rateLimiterCleanupInterval = 10 * time.Minute ) // RouterDependencies bündelt, was der Router zur Bedienung der Endpunkte braucht. type RouterDependencies struct { // Config ist die geladene Dienstkonfiguration. Config config.Config // Logger ist der Basis-Logger des Dienstes. Logger *slog.Logger // HealthRegistry liefert den Zustand aller überwachten Komponenten. HealthRegistry *health.Registry // BuildVersion ist die ausgelieferte Version, sichtbar unter /health. BuildVersion string // AuthService ist die Domänenlogik der Identitätsverwaltung. AuthService *auth.Service // AuthRepository ist die Datenzugriffsschicht für Rollen und Berechtigungen. AuthRepository *auth.Repository // AuditRecorder protokolliert sicherheitsrelevante Handlungen. AuditRecorder audit.Recorder // AgentService ist die Domänenlogik der Agent-Verwaltung. AgentService *agentregistry.Service // RestoreStore ist die Datenzugriffsschicht der Wiederherstellungen. // // Sie darf nil sein; dann werden die Wiederherstellungsrouten nicht // eingebunden. RestoreStore *recovery.Store // JobStore ist die Datenzugriffsschicht der Sicherungsaufträge. // // Sie darf nil sein; dann werden die Auftragsrouten nicht eingebunden. Das // erlaubt einen Dienst ohne Auftragsverwaltung, ohne dass ein Aufruf in // einen Nil-Zeiger läuft. JobStore *jobs.PostgresStore // VerificationStore ist die Datenzugriffsschicht der Prüfungen. // // Sie darf nil sein; dann werden die Prüfrouten nicht eingebunden. VerificationStore *verification.Store // RetentionStore ist die Datenzugriffsschicht der Aufbewahrung. // // Sie darf nil sein; dann werden die Aufbewahrungsrouten nicht eingebunden. RetentionStore *retention.Store // MetricsStore bildet die Zeitreihen der Diagramme. // // Sie darf nil sein; dann werden die Kennzahlenrouten nicht eingebunden. MetricsStore *metrics.Store // AlertStore ist die Datenzugriffsschicht der Meldungen. // // Sie darf nil sein; dann werden die Meldungsrouten nicht eingebunden. AlertStore *alerting.Store // SecretStore verschlüsselt die Zugangsgeheimnisse der Kanäle. SecretStore crypto.SecretStore // SecurityInspector beurteilt die Sicherheitslage. // // Er darf nil sein; dann wird das Security Center nicht eingebunden. SecurityInspector *security.Inspector // RansomwareDetector bewertet Sicherungslaeufe statistisch. // // Er darf nil sein; dann wird die Auffaelligkeitsbewertung nicht eingebunden. RansomwareDetector *ransomware.Detector // ReportGenerator erzeugt die Berichte. // // Er darf nil sein; dann werden die Berichtsrouten nicht eingebunden. ReportGenerator *reports.Generator // AgentTaskStore vermittelt Sicherungsaufträge an Agenten. // // Er darf nil sein; dann werden die Auftragsrouten nicht eingebunden. AgentTaskStore *agenttasks.Store // HypervisorStore verwaltet die Virtualisierungsumgebungen (Phase 7). // // Er darf nil sein; dann werden die Verbundrouten nicht eingebunden. Das // ist der Normalfall ohne Schlüsselmaterial: Ohne SecretStore lassen sich // keine Zugangsdaten ablegen, und ein Verbund ohne Zugangsdaten wäre eine // Maske, die nichts bewirkt. HypervisorStore *hypervisor.Store } // NewRouter baut den vollständigen HTTP-Handler des API-Dienstes. // // Die Middleware-Reihenfolge ist bewusst gewählt: Recovery und Correlation ID // liegen außen, damit auch Fehler innerer Schichten protokolliert und einer // Operation zugeordnet werden können. func NewRouter(routerDependencies RouterDependencies) http.Handler { requestMultiplexer := http.NewServeMux() registerHealthRoutes(requestMultiplexer, routerDependencies) registerAuthRoutes(requestMultiplexer, routerDependencies) registerUserRoutes(requestMultiplexer, routerDependencies) registerRoleRoutes(requestMultiplexer, routerDependencies) registerAgentRoutes(requestMultiplexer, routerDependencies) registerJobRoutes(requestMultiplexer, routerDependencies) registerRestoreRoutes(requestMultiplexer, routerDependencies) registerVerificationRoutes(requestMultiplexer, routerDependencies) registerRetentionRoutes(requestMultiplexer, routerDependencies) registerMetricsRoutes(requestMultiplexer, routerDependencies) registerAlertRoutes(requestMultiplexer, routerDependencies) registerSecurityRoutes(requestMultiplexer, routerDependencies) registerRansomwareRoutes(requestMultiplexer, routerDependencies) registerReportRoutes(requestMultiplexer, routerDependencies) registerHypervisorRoutes(requestMultiplexer, routerDependencies) // Alles Unbekannte wird in der Standard-Fehlerhülle beantwortet, damit Clients // nie eine HTML-Fehlerseite von net/http erhalten. requestMultiplexer.HandleFunc("/", func(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), routerDependencies.Logger) WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der angefragte Endpunkt existiert nicht.")) }) middlewareChain := []Middleware{ RecoveryMiddleware(routerDependencies.Logger), CorrelationMiddleware(), SecurityHeadersMiddleware(), CORSMiddleware(routerDependencies.Config.HTTP.AllowedOrigins), BodyLimitMiddleware(routerDependencies.Config.HTTP.MaxRequestBodyBytes), } // Der allgemeine Begrenzer schuetzt die gesamte API, nicht nur die // Anmeldung (Phase 19). // // Bis dahin war ausschliesslich der Login begrenzt: Ein angemeldeter // Benutzer — oder ein entwendetes Token — konnte die Anlage mit Anfragen // ueberfluten. Besonders teuer sind die Endpunkte, die im Hintergrund // arbeiten: Ein Bericht erzeugt ein PDF, eine Vorabpruefung liest jeden // Block eines Backups, das Security Center stellt zehn Abfragen. if routerDependencies.Config.HTTP.RequestsPerMinute > 0 { generalRateLimiter := NewRateLimiter( routerDependencies.Config.HTTP.RequestsPerMinute, time.Minute) startRateLimiterCleanup(generalRateLimiter) middlewareChain = append(middlewareChain, RateLimitMiddleware(generalRateLimiter, routerDependencies.Logger)) } middlewareChain = append(middlewareChain, AccessLogMiddleware(routerDependencies.Logger)) return Chain(requestMultiplexer, middlewareChain...) } // registerHealthRoutes bindet die Betriebs- und Zustandsendpunkte ein. // // Sie sind bewusst ohne Anmeldung erreichbar: ein Load Balancer kann sich nicht // anmelden, und der Zustandsbericht enthält keine vertraulichen Angaben. func registerHealthRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { healthHandlerInstance := &healthHandler{ healthRegistry: routerDependencies.HealthRegistry, logger: logging.WithComponent(routerDependencies.Logger, "health"), buildVersion: routerDependencies.BuildVersion, environment: routerDependencies.Config.Environment, } requestMultiplexer.HandleFunc("GET /health/live", healthHandlerInstance.handleLiveness) requestMultiplexer.HandleFunc("GET /health/ready", healthHandlerInstance.handleReadiness) requestMultiplexer.HandleFunc("GET "+apiBasePath+"/health", healthHandlerInstance.handleSystemHealth) } // registerAuthRoutes bindet Anmeldung und Selbstverwaltung ein. func registerAuthRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { authHandlerInstance := &authHandler{ authService: routerDependencies.AuthService, logger: logging.WithComponent(routerDependencies.Logger, "auth"), } // Der Begrenzer schützt die unauthentifizierten Endpunkte vor dem // Durchprobieren von Zugangsdaten. loginRateLimiter := NewRateLimiter(loginRateLimitAttempts, loginRateLimitWindow) startRateLimiterCleanup(loginRateLimiter) rateLimitedChain := func(handlerFunction http.HandlerFunc) http.HandlerFunc { limitedHandler := RateLimitMiddleware(loginRateLimiter, routerDependencies.Logger)(handlerFunction) return limitedHandler.ServeHTTP } // Anmeldung: ohne Nachweis erreichbar, aber mengenmäßig begrenzt. requestMultiplexer.HandleFunc("POST "+apiBasePath+"/auth/login", rateLimitedChain(authHandlerInstance.handleLogin)) requestMultiplexer.HandleFunc("POST "+apiBasePath+"/auth/mfa/verify", rateLimitedChain(authHandlerInstance.handleVerifyMFA)) requestMultiplexer.HandleFunc("POST "+apiBasePath+"/auth/refresh", rateLimitedChain(authHandlerInstance.handleRefresh)) // Alles Weitere setzt eine gültige Sitzung voraus. authenticated := authenticatedHandler(routerDependencies) requestMultiplexer.Handle("POST "+apiBasePath+"/auth/logout", authenticated(authHandlerInstance.handleLogout)) requestMultiplexer.Handle("GET "+apiBasePath+"/me", authenticated(authHandlerInstance.handleGetCurrentUser)) // Den eigenen zweiten Faktor darf jeder angemeldete Benutzer einrichten; // dafür ist keine gesonderte Berechtigung nötig. requestMultiplexer.Handle("POST "+apiBasePath+"/me/mfa/enroll", authenticated(authHandlerInstance.handleBeginMFAEnrollment)) requestMultiplexer.Handle("POST "+apiBasePath+"/me/mfa/confirm", authenticated(authHandlerInstance.handleConfirmMFAEnrollment)) } // registerUserRoutes bindet die Benutzerverwaltung ein. func registerUserRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { userHandlerInstance := &userHandler{ authService: routerDependencies.AuthService, logger: logging.WithComponent(routerDependencies.Logger, "users"), } protected := protectedHandler(routerDependencies) requestMultiplexer.Handle("GET "+apiBasePath+"/users", protected("users.read", userHandlerInstance.handleListUsers)) requestMultiplexer.Handle("POST "+apiBasePath+"/users", protected("users.write", userHandlerInstance.handleCreateUser)) requestMultiplexer.Handle("GET "+apiBasePath+"/users/{id}", protected("users.read", userHandlerInstance.handleGetUser)) requestMultiplexer.Handle("PATCH "+apiBasePath+"/users/{id}", protected("users.write", userHandlerInstance.handleUpdateUser)) requestMultiplexer.Handle("DELETE "+apiBasePath+"/users/{id}", protected("users.write", userHandlerInstance.handleDeleteUser)) requestMultiplexer.Handle("POST "+apiBasePath+"/users/{id}/mfa/disable", protected("users.write", userHandlerInstance.handleDisableUserMFA)) } // registerRoleRoutes bindet Rollen, Berechtigungen und das Auditprotokoll ein. func registerRoleRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { roleHandlerInstance := &roleHandler{ repository: routerDependencies.AuthRepository, auditRecorder: routerDependencies.AuditRecorder, logger: logging.WithComponent(routerDependencies.Logger, "roles"), } auditHandlerInstance := &auditHandler{ auditRecorder: routerDependencies.AuditRecorder, logger: logging.WithComponent(routerDependencies.Logger, "audit"), } protected := protectedHandler(routerDependencies) requestMultiplexer.Handle("GET "+apiBasePath+"/roles", protected("roles.read", roleHandlerInstance.handleListRoles)) requestMultiplexer.Handle("POST "+apiBasePath+"/roles", protected("roles.write", roleHandlerInstance.handleCreateRole)) requestMultiplexer.Handle("GET "+apiBasePath+"/roles/{id}", protected("roles.read", roleHandlerInstance.handleGetRole)) requestMultiplexer.Handle("PATCH "+apiBasePath+"/roles/{id}", protected("roles.write", roleHandlerInstance.handleUpdateRole)) requestMultiplexer.Handle("DELETE "+apiBasePath+"/roles/{id}", protected("roles.write", roleHandlerInstance.handleDeleteRole)) requestMultiplexer.Handle("GET "+apiBasePath+"/permissions", protected("roles.read", roleHandlerInstance.handleListPermissions)) // Der Zugriff auf das Auditprotokoll ist gesondert beschränkt (SYNCOVA_API.md §17). requestMultiplexer.Handle("GET "+apiBasePath+"/audit-events", protected("audit.read", auditHandlerInstance.handleListAuditEvents)) } // registerJobRoutes bindet die Sicherungsaufträge ein (SYNCOVA_API.md §9). func registerJobRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.JobStore == nil { return } jobHandlerInstance := &jobHandler{ store: routerDependencies.JobStore, auditRecorder: routerDependencies.AuditRecorder, logger: logging.WithComponent(routerDependencies.Logger, "jobs"), } protected := protectedHandler(routerDependencies) // Die Repositoryliste gehört fachlich zu den Zielen, wird aber vom // Auftrags-Handler bedient: Sie hat kein eigenes Verhalten, und ein zweiter // Handler für eine einzelne lesende Route wäre Aufwand ohne Nutzen. requestMultiplexer.Handle("GET "+apiBasePath+"/repositories", protected("repositories.read", jobHandlerInstance.handleListRepositories)) // Eintragen, ansehen, umschalten und prüfen (SYNCOVA_API.md §8). // // Bis Phase 22 fehlten diese Endpunkte vollständig: Ein Repository ließ // sich nur über SQL in die Control Plane bringen. Aufgefallen ist das erst // beim vollständigen Durchlauf — die Anlage war über ihre eigene API nicht // in Betrieb zu nehmen. repositoryHandlerInstance := &repositoryHandler{ store: routerDependencies.JobStore, auditRecorder: routerDependencies.AuditRecorder, logger: logging.WithComponent(routerDependencies.Logger, "repositories"), } requestMultiplexer.Handle("POST "+apiBasePath+"/repositories", protected("repositories.write", repositoryHandlerInstance.handleRegisterRepository)) requestMultiplexer.Handle("GET "+apiBasePath+"/repositories/{id}", protected("repositories.read", repositoryHandlerInstance.handleGetRepository)) requestMultiplexer.Handle("PATCH "+apiBasePath+"/repositories/{id}", protected("repositories.write", repositoryHandlerInstance.handleUpdateRepository)) requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/test", protected("repositories.read", repositoryHandlerInstance.handleTestRepository)) requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/health-check", protected("repositories.read", repositoryHandlerInstance.handleRepositoryHealth)) // Integritätslauf und Katalogaufbau hängen am Schreibrecht: Beide lesen das // gesamte Repository und binden dessen Datenträger für die Dauer des Laufs. requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/integrity-scan", protected("repositories.write", repositoryHandlerInstance.handleRepositoryIntegrityScan)) requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/rebuild-catalog", protected("repositories.write", repositoryHandlerInstance.handleRebuildCatalog)) // Die Wiederherstellungspunkte sind die zentrale Auskunft der Anlage. Sie // hängen am Leserecht für Backups, nicht an dem für Aufträge: Wer beurteilen // soll, ob etwas wiederherstellbar ist, braucht keinen Zugriff auf die // Zeitpläne. requestMultiplexer.Handle("GET "+apiBasePath+"/backups", protected("backups.read", jobHandlerInstance.handleListBackups)) // Die Übersicht fasst zusammen, was ohnehin lesbar ist, und braucht deshalb // kein eigenes Recht über das Lesen von Backups hinaus. requestMultiplexer.Handle("GET "+apiBasePath+"/dashboard", protected("backups.read", jobHandlerInstance.handleDashboard)) requestMultiplexer.Handle("GET "+apiBasePath+"/jobs", protected("jobs.read", jobHandlerInstance.handleListJobs)) requestMultiplexer.Handle("POST "+apiBasePath+"/jobs", protected("jobs.write", jobHandlerInstance.handleCreateJob)) requestMultiplexer.Handle("GET "+apiBasePath+"/jobs/{id}", protected("jobs.read", jobHandlerInstance.handleGetJob)) // Das Löschen verlangt dasselbe Recht wie das Ändern (siehe 000002: die // Beschreibung von jobs.write nennt das Löschen ausdrücklich mit). Es wird // immer auditiert. requestMultiplexer.Handle("DELETE "+apiBasePath+"/jobs/{id}", protected("jobs.write", jobHandlerInstance.handleDeleteJob)) // Ausführen, Aussetzen und Fortsetzen greifen in den Betrieb ein, ändern // aber keine Konfiguration — dafür gibt es jobs.run. requestMultiplexer.Handle("POST "+apiBasePath+"/jobs/{id}/run", protected("jobs.run", jobHandlerInstance.handleRunJob)) requestMultiplexer.Handle("POST "+apiBasePath+"/jobs/{id}/pause", protected("jobs.run", jobHandlerInstance.handlePauseJob)) requestMultiplexer.Handle("POST "+apiBasePath+"/jobs/{id}/resume", protected("jobs.run", jobHandlerInstance.handleResumeJob)) requestMultiplexer.Handle("GET "+apiBasePath+"/jobs/{id}/runs", protected("jobs.read", jobHandlerInstance.handleListJobRuns)) // Der Abbruch eines Laufs steht unter /backup-runs (SYNCOVA_API.md §10): // Er betrifft den Lauf, nicht den Auftrag, und der Aufrufer kennt an dieser // Stelle oft nur die Laufkennung. requestMultiplexer.Handle("POST "+apiBasePath+"/backup-runs/{id}/cancel", protected("jobs.run", jobHandlerInstance.handleCancelRun)) } // registerRestoreRoutes bindet die Wiederherstellung ein (SYNCOVA_API.md §13). func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.RestoreStore == nil || routerDependencies.JobStore == nil { return } restoreHandlerInstance := &restoreHandler{ restoreStore: routerDependencies.RestoreStore, jobStore: routerDependencies.JobStore, auditRecorder: routerDependencies.AuditRecorder, targetGuard: recovery.NewTargetGuard(routerDependencies.Config.Hardening.RestoreAllowedRoots), logger: logging.WithComponent(routerDependencies.Logger, "restores"), } protected := protectedHandler(routerDependencies) // Die Vorabprüfung schreibt nichts und braucht deshalb nur das Leserecht. // Sie soll niedrigschwellig sein: Wer den Zustand der Backups beurteilen // soll, muss sie ausführen können. requestMultiplexer.Handle("POST "+apiBasePath+"/restores/validate", protected("restores.read", restoreHandlerInstance.handleValidateRestore)) requestMultiplexer.Handle("GET "+apiBasePath+"/restores", protected("restores.read", restoreHandlerInstance.handleListRestores)) requestMultiplexer.Handle("POST "+apiBasePath+"/restores", protected("restores.execute", restoreHandlerInstance.handleCreateRestore)) requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore)) requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore)) requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore)) // Blättern in Dateisystem und Backup. // // Beide gehören zur Wiederherstellung und tragen deshalb deren Leserecht: // Wer eine Wiederherstellung vorbereiten darf, muss sehen können, was im // Backup steckt und wohin sich zurückschreiben lässt. browseHandlerInstance := &browseHandler{ restoreHandlerReference: restoreHandlerInstance, targetGuard: restoreHandlerInstance.targetGuard, } requestMultiplexer.Handle("GET "+apiBasePath+"/filesystem/browse", protected("restores.read", browseHandlerInstance.handleBrowseFilesystem)) requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/contents", protected("restores.read", browseHandlerInstance.handleBrowseBackupContents)) } // registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14). func registerVerificationRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.VerificationStore == nil || routerDependencies.JobStore == nil { return } verificationHandlerInstance := &verificationHandler{ verificationStore: routerDependencies.VerificationStore, jobStore: routerDependencies.JobStore, auditRecorder: routerDependencies.AuditRecorder, logger: logging.WithComponent(routerDependencies.Logger, "verification"), } protected := protectedHandler(routerDependencies) requestMultiplexer.Handle("GET "+apiBasePath+"/verification", protected("verification.read", verificationHandlerInstance.handleListVerifications)) requestMultiplexer.Handle("POST "+apiBasePath+"/verification", protected("verification.write", verificationHandlerInstance.handleCreateVerification)) requestMultiplexer.Handle("GET "+apiBasePath+"/verification/{id}", protected("verification.read", verificationHandlerInstance.handleGetVerification)) requestMultiplexer.Handle("GET "+apiBasePath+"/verification/{id}/results", protected("verification.read", verificationHandlerInstance.handleGetVerificationResults)) requestMultiplexer.Handle("POST "+apiBasePath+"/verification/{id}/cancel", protected("verification.write", verificationHandlerInstance.handleCancelVerification)) // Die Bewertung steht am Backup, nicht bei den Prüfungen: Sie beantwortet // eine Frage über das Backup („kann ich mich darauf verlassen?"), nicht über // einen einzelnen Prüflauf. Sie schreibt nichts und braucht deshalb nur das // Leserecht auf Backups. requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/assurance", protected("backups.read", verificationHandlerInstance.handleGetAssurance)) } // registerRetentionRoutes bindet Aufbewahrung und Unveränderlichkeit ein (Phase 11). func registerRetentionRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.RetentionStore == nil || routerDependencies.JobStore == nil { return } retentionHandlerInstance := &retentionHandler{ retentionStore: routerDependencies.RetentionStore, jobStore: routerDependencies.JobStore, auditRecorder: routerDependencies.AuditRecorder, logger: logging.WithComponent(routerDependencies.Logger, "retention"), } protected := protectedHandler(routerDependencies) // Aufbewahrungsregeln. requestMultiplexer.Handle("GET "+apiBasePath+"/retention-policies", protected("repositories.read", retentionHandlerInstance.handleListPolicies)) requestMultiplexer.Handle("POST "+apiBasePath+"/retention-policies", protected("retention.write", retentionHandlerInstance.handleCreatePolicy)) requestMultiplexer.Handle("PATCH "+apiBasePath+"/retention-policies/{id}", protected("retention.write", retentionHandlerInstance.handleUpdatePolicy)) requestMultiplexer.Handle("DELETE "+apiBasePath+"/retention-policies/{id}", protected("retention.write", retentionHandlerInstance.handleDeletePolicy)) // Die Vorschau schreibt nichts und braucht deshalb nur das Leserecht. Sie // soll niedrigschwellig sein: Wer wissen will, was eine Regel anrichtet, muss // sie ausprobieren können, ohne sie ausführen zu dürfen. requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/retention/preview", protected("repositories.read", retentionHandlerInstance.handlePreviewRetention)) requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/retention/apply", protected("retention.write", retentionHandlerInstance.handleApplyRetention)) // Die Messung fasst nur eine Probedatei an und beantwortet die wichtigste // Frage des gehärteten Betriebs: Was verhindert dieser Speicher wirklich? requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/enforcement/measure", protected("repositories.write", retentionHandlerInstance.handleMeasureEnforcement)) // Schutzlage eines einzelnen Backups. requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/protection", protected("backups.read", retentionHandlerInstance.handleGetProtection)) // Verlängern und Legal Hold hängen an immutability.manage — nicht an // backups.delete. Wer aufräumen darf, darf deshalb noch lange keinen // Aufbewahrungsschutz aufheben. requestMultiplexer.Handle("POST "+apiBasePath+"/backups/{id}/retention/extend", protected("immutability.manage", retentionHandlerInstance.handleExtendRetention)) requestMultiplexer.Handle("POST "+apiBasePath+"/backups/{id}/legal-hold", protected("immutability.manage", retentionHandlerInstance.handlePlaceLegalHold)) requestMultiplexer.Handle("DELETE "+apiBasePath+"/backups/{id}/legal-hold", protected("immutability.manage", retentionHandlerInstance.handleReleaseLegalHold)) // Das Löschen eines einzelnen Backups. requestMultiplexer.Handle("DELETE "+apiBasePath+"/backups/{id}", protected("backups.delete", retentionHandlerInstance.handleDeleteBackup)) } // registerMetricsRoutes bindet die Kennzahlen und Diagramme ein (Phase 13). func registerMetricsRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.MetricsStore == nil { return } metricsHandlerInstance := &metricsHandler{ metricsStore: routerDependencies.MetricsStore, logger: logging.WithComponent(routerDependencies.Logger, "metrics"), } protected := protectedHandler(routerDependencies) // Die Kennzahlen fassen zusammen, was ohnehin lesbar ist, und hängen deshalb // am Überwachungsrecht statt an einem eigenen. requestMultiplexer.Handle("GET "+apiBasePath+"/metrics", protected("monitoring.read", metricsHandlerInstance.handleListCharts)) requestMultiplexer.Handle("GET "+apiBasePath+"/metrics/{metric}", protected("monitoring.read", metricsHandlerInstance.handleGetChart)) } // registerAlertRoutes bindet Meldungen und Benachrichtigungen ein (Phase 14). func registerAlertRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.AlertStore == nil { return } alertHandlerInstance := &alertHandler{ alertStore: routerDependencies.AlertStore, secretStore: routerDependencies.SecretStore, auditRecorder: routerDependencies.AuditRecorder, addressGuard: netguard.NewGuard(routerDependencies.Config.Hardening.AllowInternalNotificationTargets), logger: logging.WithComponent(routerDependencies.Logger, "alerts"), } protected := protectedHandler(routerDependencies) requestMultiplexer.Handle("GET "+apiBasePath+"/alerts", protected("alerts.read", alertHandlerInstance.handleListAlerts)) requestMultiplexer.Handle("GET "+apiBasePath+"/alerts/summary", protected("alerts.read", alertHandlerInstance.handleAlertSummary)) requestMultiplexer.Handle("GET "+apiBasePath+"/alerts/{id}", protected("alerts.read", alertHandlerInstance.handleGetAlert)) // Bestätigen und Auflösen greifen in den Betrieb ein, ändern aber keine // Konfiguration — beides hängt an alerts.write. requestMultiplexer.Handle("POST "+apiBasePath+"/alerts/{id}/acknowledge", protected("alerts.write", alertHandlerInstance.handleAcknowledgeAlert)) requestMultiplexer.Handle("POST "+apiBasePath+"/alerts/{id}/resolve", protected("alerts.write", alertHandlerInstance.handleResolveAlert)) // Die Kanäle sind Konfiguration und hängen deshalb am Einstellungsrecht: Wer // Benachrichtigungen umleitet, kann erreichen, dass niemand mehr von einem // Ausfall erfährt. requestMultiplexer.Handle("GET "+apiBasePath+"/notification-channels", protected("settings.read", alertHandlerInstance.handleListChannels)) requestMultiplexer.Handle("POST "+apiBasePath+"/notification-channels", protected("settings.write", alertHandlerInstance.handleCreateChannel)) requestMultiplexer.Handle("DELETE "+apiBasePath+"/notification-channels/{id}", protected("settings.write", alertHandlerInstance.handleDeleteChannel)) } // registerSecurityRoutes bindet das Security Center ein (Phase 15). func registerSecurityRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.SecurityInspector == nil { return } securityHandlerInstance := &securityHandler{ inspector: routerDependencies.SecurityInspector, logger: logging.WithComponent(routerDependencies.Logger, "security"), } protected := protectedHandler(routerDependencies) requestMultiplexer.Handle("GET "+apiBasePath+"/security", protected("security.read", securityHandlerInstance.handleGetAssessment)) requestMultiplexer.Handle("GET "+apiBasePath+"/security/findings", protected("security.read", securityHandlerInstance.handleListFindings)) } // registerAgentRoutes bindet die Agent-Endpunkte ein (SYNCOVA_API.md §6). func registerAgentRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { agentHandlerInstance := &agentHandler{ agentService: routerDependencies.AgentService, logger: logging.WithComponent(routerDependencies.Logger, "agents"), } protected := protectedHandler(routerDependencies) // Verwaltung durch Benutzer. requestMultiplexer.Handle("GET "+apiBasePath+"/agents", protected("agents.read", agentHandlerInstance.handleListAgents)) requestMultiplexer.Handle("GET "+apiBasePath+"/agents/{id}", protected("agents.read", agentHandlerInstance.handleGetAgent)) requestMultiplexer.Handle("GET "+apiBasePath+"/agents/{id}/health", protected("agents.read", agentHandlerInstance.handleAgentHealth)) requestMultiplexer.Handle("POST "+apiBasePath+"/agents/{id}/revoke", protected("agents.write", agentHandlerInstance.handleRevokeAgent)) requestMultiplexer.Handle("POST "+apiBasePath+"/agents/{id}/rotate-credentials", protected("agents.write", agentHandlerInstance.handleRotateCredentials)) // Die Ausstellung eines Aufnahme-Tokens nimmt ein neues System in die // Infrastruktur auf und ist deshalb an ein eigenes Recht gebunden. requestMultiplexer.Handle("POST "+apiBasePath+"/agents/enrollment-tokens", protected("agents.enroll", agentHandlerInstance.handleIssueEnrollmentToken)) // Die Registrierung ist ohne Benutzeranmeldung erreichbar: ein sich // aufnehmender Agent besitzt noch kein Betriebstoken. Sein Nachweis ist // das Aufnahme-Token, das die Domänenlogik prüft. requestMultiplexer.HandleFunc("POST "+apiBasePath+"/agents/register", agentHandlerInstance.handleRegisterAgent) // Die Lebendmeldung verlangt das Betriebstoken des Agents, nicht die // Sitzung eines Benutzers. agentAuthenticated := AgentAuthenticationMiddleware(routerDependencies.AgentService, routerDependencies.Logger) requestMultiplexer.Handle("POST "+apiBasePath+"/agents/heartbeat", agentAuthenticated(http.HandlerFunc(agentHandlerInstance.handleHeartbeat))) // Die Auftragsübermittlung hängt ebenfalls am Betriebstoken des Agenten. // // Der Agent **holt** ab; der Server drückt nicht. Ein Agent steht hinter // einer Firewall, oft hinter NAT — die Verbindung geht immer von ihm aus. if routerDependencies.AgentTaskStore != nil { agentTaskHandlerInstance := &agentTaskHandler{ taskStore: routerDependencies.AgentTaskStore, logger: logging.WithComponent(routerDependencies.Logger, "agent-tasks"), } requestMultiplexer.Handle("POST "+apiBasePath+"/agents/tasks/claim", agentAuthenticated(http.HandlerFunc(agentTaskHandlerInstance.handleClaimTask))) requestMultiplexer.Handle("POST "+apiBasePath+"/agents/tasks/{id}/progress", agentAuthenticated(http.HandlerFunc(agentTaskHandlerInstance.handleReportProgress))) requestMultiplexer.Handle("POST "+apiBasePath+"/agents/tasks/{id}/result", agentAuthenticated(http.HandlerFunc(agentTaskHandlerInstance.handleReportResult))) } } // authenticatedHandler baut einen Handler, der eine gültige Sitzung verlangt. func authenticatedHandler(routerDependencies RouterDependencies) func(http.HandlerFunc) http.Handler { return func(handlerFunction http.HandlerFunc) http.Handler { return AuthenticationMiddleware(routerDependencies.AuthService, routerDependencies.Logger)(handlerFunction) } } // protectedHandler baut einen Handler, der Sitzung und Berechtigung verlangt. // // Beide Prüfungen sind hier untrennbar verbunden: ein Endpunkt lässt sich damit // nicht versehentlich ohne Berechtigungsprüfung einbinden (PROMPT.md §42). func protectedHandler(routerDependencies RouterDependencies) func(string, http.HandlerFunc) http.Handler { return func(requiredPermission string, handlerFunction http.HandlerFunc) http.Handler { permissionCheckedHandler := RequirePermission( requiredPermission, routerDependencies.AuthService, routerDependencies.AuditRecorder, routerDependencies.Logger, handlerFunction, ) return AuthenticationMiddleware(routerDependencies.AuthService, routerDependencies.Logger)(permissionCheckedHandler) } } // startRateLimiterCleanup entfernt regelmäßig abgelaufene Einträge. // // Ohne Bereinigung wüchse die Zählerkarte mit jeder neuen Absenderadresse. func startRateLimiterCleanup(limiter *RateLimiter) { cleanupTicker := time.NewTicker(rateLimiterCleanupInterval) go func() { // Die Aufräumroutine läuft für die Lebensdauer des Dienstes. for range cleanupTicker.C { limiter.Cleanup() } }() } // registerRansomwareRoutes bindet die Auffaelligkeitsbewertung ein. // // Sie haengt an backups.read, nicht an einem eigenen Sicherheitsrecht: Wer die // Backups sehen darf, muss auch erfahren duerfen, ob einer davon aus dem // Rahmen faellt — die Bewertung ist eine Eigenschaft des Laufs, kein Geheimnis. func registerRansomwareRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.RansomwareDetector == nil { return } ransomwareHandlerInstance := &ransomwareHandler{ detector: routerDependencies.RansomwareDetector, logger: logging.WithComponent(routerDependencies.Logger, "ransomware"), } protected := protectedHandler(routerDependencies) requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/ransomware-assessment", protected("backups.read", ransomwareHandlerInstance.handleGetAssessment)) } // registerReportRoutes bindet die Berichte ein. // // Beide Routen haengen an reports.read. Die Berechtigung gibt es seit Phase 1; // Viewer und Auditor tragen sie bereits — der Auditor ist genau die Rolle, fuer // die der Bericht fuer Pruefungen geschrieben wurde. func registerReportRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.ReportGenerator == nil { return } reportHandlerInstance := &reportHandler{ reportGenerator: routerDependencies.ReportGenerator, auditRecorder: routerDependencies.AuditRecorder, logger: logging.WithComponent(routerDependencies.Logger, "reports"), } protected := protectedHandler(routerDependencies) requestMultiplexer.Handle("GET "+apiBasePath+"/reports", protected("reports.read", reportHandlerInstance.handleListReports)) requestMultiplexer.Handle("POST "+apiBasePath+"/reports/generate", protected("reports.read", reportHandlerInstance.handleGenerateReport)) } // registerHypervisorRoutes bindet die Endpunkte der Virtualisierungsumgebungen ein. // // Die Rechte sind die bereits vorhandenen providers.read und providers.write — // ein neues Recht hätte jede bestehende Rollenzuweisung stillschweigend // verändert. func registerHypervisorRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) { if routerDependencies.HypervisorStore == nil { return } hypervisorHandlerInstance := &hypervisorHandler{ clusterStore: routerDependencies.HypervisorStore, auditRecorder: routerDependencies.AuditRecorder, logger: logging.WithComponent(routerDependencies.Logger, "hypervisor"), } protected := protectedHandler(routerDependencies) requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters", protected("providers.read", hypervisorHandlerInstance.handleListClusters)) requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters/{id}", protected("providers.read", hypervisorHandlerInstance.handleGetCluster)) requestMultiplexer.Handle("POST "+apiBasePath+"/proxmox/clusters", protected("providers.write", hypervisorHandlerInstance.handleCreateCluster)) requestMultiplexer.Handle("DELETE "+apiBasePath+"/proxmox/clusters/{id}", protected("providers.write", hypervisorHandlerInstance.handleDeleteCluster)) // Prüfung und Bestandsaufnahme lesen nur — aber sie bauen eine Verbindung // nach außen auf und erzeugen Last auf dem Verbund. Deshalb hängen sie am // Schreibrecht, nicht am Leserecht. requestMultiplexer.Handle("POST "+apiBasePath+"/proxmox/clusters/{id}/test", protected("providers.write", hypervisorHandlerInstance.handleTestConnection)) requestMultiplexer.Handle("POST "+apiBasePath+"/proxmox/clusters/{id}/discover", protected("providers.write", hypervisorHandlerInstance.handleDiscover)) requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters/{id}/hosts", protected("providers.read", hypervisorHandlerInstance.handleListHosts)) requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters/{id}/vms", protected("providers.read", hypervisorHandlerInstance.handleListClusterMachines)) requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/vms/{id}", protected("providers.read", hypervisorHandlerInstance.handleGetVirtualMachine)) // Zusätzlich zur Vertragsliste: der Bestand über alle Verbünde hinweg. Eine // Übersicht, die zuerst nach dem Verbund fragt, ist keine Übersicht. requestMultiplexer.Handle("GET "+apiBasePath+"/virtual-machines", protected("providers.read", hypervisorHandlerInstance.handleListVirtualMachines)) }