package httpapi import ( "context" "errors" "fmt" "log/slog" "net/http" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/audit" "github.com/syncova/syncova/packages/auth" "github.com/syncova/syncova/packages/jobs" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/repository" "github.com/syncova/syncova/packages/retention" ) // retentionHandler bedient Aufbewahrung und Unveraenderlichkeit (Phase 11). type retentionHandler struct { // retentionStore ist die Datenzugriffsschicht der Aufbewahrung. retentionStore *retention.Store // jobStore loest Backups und Repositories auf. jobStore *jobs.PostgresStore // auditRecorder protokolliert destruktive Handlungen. auditRecorder audit.Recorder // logger protokolliert technische Fehler. logger *slog.Logger } // policyRequest ist der Rumpf von POST und PATCH auf /retention-policies. type policyRequest struct { // Name ist die Bezeichnung. Name string `json:"name"` // KeepWithinSeconds haelt alles Juengere. KeepWithinSeconds int64 `json:"keep_within_seconds,omitempty"` // KeepLast haelt die juengsten n Backups. KeepLast int `json:"keep_last,omitempty"` // KeepDaily haelt n Tagesstaende. KeepDaily int `json:"keep_daily,omitempty"` // KeepWeekly haelt n Wochenstaende. KeepWeekly int `json:"keep_weekly,omitempty"` // KeepMonthly haelt n Monatsstaende. KeepMonthly int `json:"keep_monthly,omitempty"` // KeepYearly haelt n Jahresstaende. KeepYearly int `json:"keep_yearly,omitempty"` // TimeZone ist die Zeitzone der Tages- und Monatsgrenzen. TimeZone string `json:"time_zone,omitempty"` } // toPolicy wandelt die Anfrage in eine Regel. // // KeepLast wird auf 1 ergaenzt, wenn die Anfrage **andere** Haltevorgaben macht, // aber keine Zahl fuer die juengsten Backups nennt. Das ist die Sicherung gegen // den haeufigsten Totalverlust: Ein System sichert monatelang nicht, alle // Backups fallen aus der Frist, und die Regel loescht das letzte vorhandene. // // Was hier **nicht** geschieht: Eine Anfrage ohne jede Haltevorgabe wird nicht // stillschweigend zu „behalte das letzte" gemacht. Sie ist ein Tippfehler oder // ein Missverstaendnis und wird abgelehnt — eine ungebetene Korrektur der // Eingabe faellt sonst erst auf, wenn die Regel etwas anderes tut als gedacht. func (payload policyRequest) toPolicy() retention.Policy { convertedPolicy := retention.Policy{ Name: payload.Name, KeepWithin: time.Duration(payload.KeepWithinSeconds) * time.Second, KeepLast: payload.KeepLast, KeepDaily: payload.KeepDaily, KeepWeekly: payload.KeepWeekly, KeepMonthly: payload.KeepMonthly, KeepYearly: payload.KeepYearly, TimeZone: payload.TimeZone, } if convertedPolicy.KeepLast <= 0 && payload.hasAnyKeepRule() { convertedPolicy.KeepLast = 1 } return convertedPolicy } // hasAnyKeepRule meldet, ob die Anfrage ueberhaupt eine Haltevorgabe macht. func (payload policyRequest) hasAnyKeepRule() bool { return payload.KeepWithinSeconds > 0 || payload.KeepLast > 0 || payload.KeepDaily > 0 || payload.KeepWeekly > 0 || payload.KeepMonthly > 0 || payload.KeepYearly > 0 } // applyRequest ist der Rumpf von POST /repositories/{id}/retention/apply. type applyRequest struct { // PolicyID ist die anzuwendende Regel. PolicyID uuid.UUID `json:"policy_id"` // DryRun beschraenkt den Lauf auf eine Vorschau. DryRun bool `json:"dry_run"` // ConfirmDeletion bestaetigt die Loeschung woertlich. // // Der Wert muss die Zahl der zu loeschenden Backups wiederholen. Wer sie // abtippt, hat die Vorschau gelesen — ein versehentlich gesetztes // dry_run:false reicht damit nicht aus, um Daten zu vernichten. ConfirmDeletion string `json:"confirm_deletion,omitempty"` } // legalHoldRequest ist der Rumpf von POST /backups/{id}/legal-hold. type legalHoldRequest struct { // Reason begruendet die Anordnung oder ihre Aufhebung. Reason string `json:"reason"` } // extendRetentionRequest ist der Rumpf von POST /backups/{id}/retention/extend. type extendRetentionRequest struct { // ImmutableUntil ist das neue Ende der Frist in UTC. ImmutableUntil time.Time `json:"immutable_until"` // Reason begruendet die Verlaengerung. Reason string `json:"reason"` } // deleteBackupRequest ist der Rumpf von DELETE /backups/{id}. type deleteBackupRequest struct { // ConfirmBackupID wiederholt die Kennung des Backups im Repository. ConfirmBackupID string `json:"confirm_backup_id"` // Reason begruendet die Loeschung. Reason string `json:"reason,omitempty"` } // protectionResponse ist die Schutzlage eines Backups. type protectionResponse struct { // BackupID ist das betrachtete Backup. BackupID uuid.UUID `json:"backup_id"` // BackupIDInRepository ist seine Kennung im Repository. BackupIDInRepository string `json:"backup_id_in_repository"` // ImmutableUntil ist das Ende der geltenden Frist in UTC. ImmutableUntil *time.Time `json:"immutable_until,omitempty"` // LegalHold meldet einen unbefristeten Schutz. LegalHold bool `json:"legal_hold"` // LegalHoldReason begruendet den unbefristeten Schutz. LegalHoldReason string `json:"legal_hold_reason,omitempty"` // IsProtected meldet, ob eine Loeschung derzeit unzulaessig ist. IsProtected bool `json:"is_protected"` // Description erklaert die Schutzlage in einem Satz. Description string `json:"description"` // EnforcementLevel ist die gemessene Durchsetzungsstufe des Repositorys. // // Sie steht hier, weil sie den Wert der uebrigen Felder bestimmt: Ein // „geschuetzt bis" bei advisory bedeutet etwas anderes als bei filesystem. EnforcementLevel string `json:"enforcement_level,omitempty"` // EnforcementExplanation erklaert die Durchsetzungsstufe. EnforcementExplanation string `json:"enforcement_explanation,omitempty"` } // handleListPolicies bedient GET /retention-policies. func (handler *retentionHandler) handleListPolicies(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) storedPolicies, listError := handler.retentionStore.ListPolicies(request.Context()) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } // Die vorgefertigten Regeln kommen mit: Wer eine Regel anlegen will, soll // nicht raten muessen, was ueblich ist — und die mitgelieferten schuetzen // alle das letzte vorhandene Backup. predefinedPolicies := make([]map[string]any, 0, 6) for _, predefinedPolicy := range retention.PredefinedPolicies() { predefinedPolicies = append(predefinedPolicies, map[string]any{ "name": predefinedPolicy.Name, "description": predefinedPolicy.Describe(), "keep_within_seconds": int64(predefinedPolicy.KeepWithin.Seconds()), "keep_last": predefinedPolicy.KeepLast, "keep_daily": predefinedPolicy.KeepDaily, "keep_weekly": predefinedPolicy.KeepWeekly, "keep_monthly": predefinedPolicy.KeepMonthly, "keep_yearly": predefinedPolicy.KeepYearly, }) } WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "policies": storedPolicies, "predefined": predefinedPolicies, }) } // handleCreatePolicy bedient POST /retention-policies. func (handler *retentionHandler) handleCreatePolicy(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) var policyPayload policyRequest if decodeError := decodeJSONBody(request, &policyPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } createdPolicy, createError := handler.retentionStore.CreatePolicy(request.Context(), policyPayload.toPolicy(), &actingUser.ID) if createError != nil { WriteError(responseWriter, request, requestLogger, translateRetentionError(createError)) return } handler.recordAudit(request, actingUser, audit.ActionRetentionPolicyCreated, "retention_policy", &createdPolicy.ID, map[string]any{ "name": createdPolicy.Policy.Name, "description": createdPolicy.Description, }) responsePayload := map[string]any{"policy": createdPolicy} // Eine ergaenzte Haltevorgabe wird ausgesprochen. Eine Regel, die anders // aussieht als abgeschickt, ohne dass es jemand sagt, ist eine Ueberraschung // mit Ansage. if payloadHadNoKeepLast(policyPayload) { responsePayload["notice"] = "Ohne Angabe von keep_last wurde 1 ergaenzt: Die Regel behaelt damit " + "immer das juengste Backup, auch wenn es aelter ist als die Frist." } WriteSuccess(responseWriter, request, http.StatusCreated, responsePayload) } // handleUpdatePolicy bedient PATCH /retention-policies/{id}. // // Eine Regelaenderung wird immer auditiert, auch die harmlose: Ob sie harmlos // war, stellt sich erst beim naechsten Lauf heraus (PROMPT.md §16 verlangt // bestaetigt, protokolliert, auditierbar). func (handler *retentionHandler) handleUpdatePolicy(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) policyIdentifier, parseError := parsePathIdentifier(request, "Die Regelkennung ist keine gueltige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } existingPolicy, readError := handler.retentionStore.GetPolicy(request.Context(), policyIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateRetentionError(readError)) return } var policyPayload policyRequest if decodeError := decodeJSONBody(request, &policyPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } updatedPolicy, updateError := handler.retentionStore.UpdatePolicy(request.Context(), policyIdentifier, policyPayload.toPolicy()) if updateError != nil { WriteError(responseWriter, request, requestLogger, translateRetentionError(updateError)) return } handler.recordAudit(request, actingUser, audit.ActionRetentionPolicyUpdated, "retention_policy", &policyIdentifier, map[string]any{ "previous": existingPolicy.Description, "new": updatedPolicy.Description, "affected_jobs": existingPolicy.UsedByJobCount, "policy_name": updatedPolicy.Policy.Name, "changed_by_admin": actingUser.Username, }) WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "policy": updatedPolicy, "message": "Die Regel wurde geaendert. Sie wirkt erst beim naechsten Aufbewahrungslauf; " + "pruefen Sie ihn zuerst als Vorschau.", }) } // handleDeletePolicy bedient DELETE /retention-policies/{id}. func (handler *retentionHandler) handleDeletePolicy(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) policyIdentifier, parseError := parsePathIdentifier(request, "Die Regelkennung ist keine gueltige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } if deleteError := handler.retentionStore.DeletePolicy(request.Context(), policyIdentifier); deleteError != nil { WriteError(responseWriter, request, requestLogger, translateRetentionError(deleteError)) return } handler.recordAudit(request, actingUser, audit.ActionRetentionPolicyDeleted, "retention_policy", &policyIdentifier, nil) WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "message": "Die Aufbewahrungsregel wurde geloescht.", }) } // handlePreviewRetention bedient POST /repositories/{id}/retention/preview. // // Der Endpunkt schreibt **nichts**. Er ist der Weg, eine unbekannte Regel // gefahrlos auszuprobieren — und er nennt je Backup den Grund, warum es bleibt // oder geht. func (handler *retentionHandler) handlePreviewRetention(responseWriter http.ResponseWriter, request *http.Request) { handler.runRetention(responseWriter, request, true) } // handleApplyRetention bedient POST /repositories/{id}/retention/apply. func (handler *retentionHandler) handleApplyRetention(responseWriter http.ResponseWriter, request *http.Request) { handler.runRetention(responseWriter, request, false) } // runRetention fuehrt Vorschau oder Anwendung einer Aufbewahrungsregel aus. func (handler *retentionHandler) runRetention(responseWriter http.ResponseWriter, request *http.Request, forcePreview bool) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) repositoryIdentifier, parseError := parsePathIdentifier(request, "Die Repositorykennung ist keine gueltige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } var applyPayload applyRequest if decodeError := decodeJSONBody(request, &applyPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } storedPolicy, policyError := handler.retentionStore.GetPolicy(request.Context(), applyPayload.PolicyID) if policyError != nil { WriteError(responseWriter, request, requestLogger, translateRetentionError(policyError)) return } repositoryRecord, repositoryError := handler.jobStore.GetRepository(request.Context(), repositoryIdentifier) if repositoryError != nil { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden.")) return } isDryRun := forcePreview || applyPayload.DryRun // Die Vorschau oeffnet schreibgeschuetzt: Sie soll neben einer laufenden // Sicherung stattfinden koennen und darf unter keinen Umstaenden etwas // veraendern. openedRepository, openError := repository.Open(request.Context(), repositoryRecord.Location, repository.OpenOptions{ReadOnly: isDryRun}, handler.logger) if openError != nil { WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError( "Das Repository ist derzeit nicht erreichbar oder wird gerade beschrieben.")) return } defer func() { _ = openedRepository.Close() }() enforcer, enforcerError := retention.NewEnforcer(openedRepository, handler.logger) if enforcerError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(enforcerError)) return } executionPlan, planError := enforcer.Preview(request.Context(), storedPolicy.Policy) if planError != nil { WriteError(responseWriter, request, requestLogger, translateRetentionError(planError)) return } correlationIdentifier := correlationUUIDOrNew(request.Context()) if isDryRun { if _, recordError := handler.retentionStore.RecordRun(request.Context(), repositoryIdentifier, &storedPolicy.ID, executionPlan, nil, correlationIdentifier, &actingUser.ID); recordError != nil { requestLogger.Warn("der vorschaulauf konnte nicht festgehalten werden", slog.String("grund", recordError.Error())) } WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "dry_run": true, "plan": executionPlan, "summary": executionPlan.Summary(), "message": "Es wurde nichts geloescht. Zum Ausfuehren senden Sie dieselbe Anfrage an " + "/retention/apply und bestaetigen die Zahl der zu loeschenden Backups.", }) return } if confirmationError := checkDeletionConfirmation(applyPayload.ConfirmDeletion, executionPlan); confirmationError != nil { WriteError(responseWriter, request, requestLogger, confirmationError) return } executionResult, executeError := enforcer.Execute(request.Context(), storedPolicy.Policy) if executeError != nil { WriteError(responseWriter, request, requestLogger, translateRetentionError(executeError)) return } if markError := handler.retentionStore.MarkBackupsDeleted(request.Context(), repositoryIdentifier, executionResult.DeletedBackupIDs, &actingUser.ID, "Aufbewahrungsregel "+storedPolicy.Policy.Name); markError != nil { requestLogger.Error("die loeschungen konnten in der control plane nicht vermerkt werden", slog.String("grund", markError.Error())) } runIdentifier, recordError := handler.retentionStore.RecordRun(request.Context(), repositoryIdentifier, &storedPolicy.ID, executionResult.Plan, executionResult, correlationIdentifier, &actingUser.ID) if recordError != nil { requestLogger.Error("der aufbewahrungslauf konnte nicht festgehalten werden", slog.String("grund", recordError.Error())) } handler.recordAudit(request, actingUser, audit.ActionRetentionApplied, "repository", &repositoryIdentifier, map[string]any{ "policy": storedPolicy.Policy.Name, "deleted_count": len(executionResult.DeletedBackupIDs), "deleted_ids": executionResult.DeletedBackupIDs, "protected": executionResult.Plan.ProtectedCount, "chunks_removed": executionResult.ChunksRemoved, "bytes_freed": executionResult.BytesFreed, "run_id": runIdentifier.String(), }) statusCode := http.StatusOK if executionResult.IsPartialFailure() { // Ein Teilfehler ist kein Erfolg. Er bekommt einen eigenen Status, damit // ein Skript ihn nicht uebersieht (PROMPT.md §137). statusCode = http.StatusMultiStatus } WriteSuccess(responseWriter, request, statusCode, map[string]any{ "dry_run": false, "result": executionResult, "summary": executionResult.Summary(), }) } // checkDeletionConfirmation prueft die woertliche Bestaetigung einer Loeschung. func checkDeletionConfirmation(confirmationValue string, executionPlan *retention.Plan) *APIError { if executionPlan.DeletableCount == 0 { // Nichts zu loeschen, nichts zu bestaetigen. Ein Ritual ohne Anlass // gewoehnt das Wegklicken an. return nil } expectedConfirmation := formatInteger(executionPlan.DeletableCount) if confirmationValue != expectedConfirmation { confirmationError := NewValidationError( "Diese Anwendung loescht Backups unwiderruflich. Wiederholen Sie zur Bestaetigung die " + "Zahl der zu loeschenden Backups im Feld confirm_deletion.") confirmationError.Details = map[string]any{ "deletable_count": executionPlan.DeletableCount, "confirm_with": expectedConfirmation, "summary": executionPlan.Summary(), "backup_ids": executionPlan.DeletableBackupIDs(), } return confirmationError } return nil } // handleGetProtection bedient GET /backups/{id}/protection. func (handler *retentionHandler) handleGetProtection(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) backupIdentifier, parseError := parsePathIdentifier(request, "Die Backupkennung ist keine gueltige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } protectionStatus, backupRecord, statusError := handler.readProtectionStatus(request.Context(), backupIdentifier) if statusError != nil { WriteError(responseWriter, request, requestLogger, statusError) return } WriteSuccess(responseWriter, request, http.StatusOK, handler.buildProtectionResponse(request.Context(), backupIdentifier, backupRecord, protectionStatus)) } // readProtectionStatus liest die Schutzlage eines Backups aus dem Repository. // // Massgeblich ist das Repository, nicht die Datenbank: Es muss auch ohne Control // Server deutbar bleiben, und bei einem Widerspruch gewinnt die Seite, die die // Daten haelt. func (handler *retentionHandler) readProtectionStatus(readContext context.Context, backupIdentifier uuid.UUID) (repository.ProtectionStatus, *jobs.StoredBackup, *APIError) { backupRecord, backupError := handler.jobStore.GetBackup(readContext, backupIdentifier) if backupError != nil { if errors.Is(backupError, jobs.ErrBackupNotFound) { return repository.ProtectionStatus{}, nil, NewNotFoundError("Das Backup wurde nicht gefunden.") } return repository.ProtectionStatus{}, nil, NewInternalError(backupError) } repositoryRecord, repositoryError := handler.jobStore.GetRepository(readContext, backupRecord.RepositoryID) if repositoryError != nil { return repository.ProtectionStatus{}, nil, NewInternalError(repositoryError) } openedRepository, openError := repository.Open(readContext, repositoryRecord.Location, repository.OpenOptions{ReadOnly: true}, handler.logger) if openError != nil { return repository.ProtectionStatus{}, nil, NewServiceUnavailableError( "Das Repository des Backups ist derzeit nicht erreichbar.") } defer func() { _ = openedRepository.Close() }() protectionStatus, statusError := openedRepository.ProtectionStatusOf(readContext, backupRecord.BackupIDInRepository) if statusError != nil { return repository.ProtectionStatus{}, nil, NewInternalError(statusError) } return protectionStatus, backupRecord, nil } // buildProtectionResponse wandelt eine Schutzlage in ihre Darstellung. func (handler *retentionHandler) buildProtectionResponse(buildContext context.Context, backupIdentifier uuid.UUID, backupRecord *jobs.StoredBackup, protectionStatus repository.ProtectionStatus) protectionResponse { builtResponse := protectionResponse{ BackupID: backupIdentifier, BackupIDInRepository: backupRecord.BackupIDInRepository, ImmutableUntil: protectionStatus.ImmutableUntil, LegalHold: protectionStatus.LegalHold, LegalHoldReason: protectionStatus.LegalHoldReason, IsProtected: protectionStatus.IsProtected(time.Now()), Description: protectionStatus.Describe(time.Now()), } // Die gemessene Durchsetzungsstufe kommt mit: Ohne sie liest sich // „geschuetzt bis" wie eine Garantie, die es je nach Speicher nicht gibt. repositoryRecord, repositoryError := handler.jobStore.GetRepository(buildContext, backupRecord.RepositoryID) if repositoryError == nil && repositoryRecord.EnforcementLevel != "" { builtResponse.EnforcementLevel = repositoryRecord.EnforcementLevel builtResponse.EnforcementExplanation = repository.EnforcementLevel(repositoryRecord.EnforcementLevel).Describe() } return builtResponse } // handlePlaceLegalHold bedient POST /backups/{id}/legal-hold. func (handler *retentionHandler) handlePlaceLegalHold(responseWriter http.ResponseWriter, request *http.Request) { handler.changeLegalHold(responseWriter, request, true) } // handleReleaseLegalHold bedient DELETE /backups/{id}/legal-hold. func (handler *retentionHandler) handleReleaseLegalHold(responseWriter http.ResponseWriter, request *http.Request) { handler.changeLegalHold(responseWriter, request, false) } // changeLegalHold setzt oder hebt einen unbefristeten Schutz auf. func (handler *retentionHandler) changeLegalHold(responseWriter http.ResponseWriter, request *http.Request, shouldPlace bool) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) backupIdentifier, parseError := parsePathIdentifier(request, "Die Backupkennung ist keine gueltige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } var holdPayload legalHoldRequest if decodeError := decodeJSONBody(request, &holdPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if holdPayload.Reason == "" { // Ohne Begruendung liesse sich der Hold spaeter nicht mehr aufloesen — // und seine Aufhebung nicht rechtfertigen. WriteError(responseWriter, request, requestLogger, NewValidationError( "Ein Legal Hold und seine Aufhebung verlangen eine Begruendung.")) return } openedRepository, backupRecord, openError := handler.openWritableRepositoryOfBackup(request.Context(), backupIdentifier) if openError != nil { WriteError(responseWriter, request, requestLogger, openError) return } defer func() { _ = openedRepository.Close() }() var ( resultingStatus repository.ProtectionStatus changeError error ) if shouldPlace { resultingStatus, changeError = openedRepository.PlaceLegalHold(request.Context(), backupRecord.BackupIDInRepository, actingUser.Username, holdPayload.Reason) } else { resultingStatus, changeError = openedRepository.ReleaseLegalHold(request.Context(), backupRecord.BackupIDInRepository, actingUser.Username, holdPayload.Reason) } if changeError != nil { WriteError(responseWriter, request, requestLogger, translateRetentionError(changeError)) return } if saveError := handler.retentionStore.SaveLegalHold(request.Context(), backupIdentifier, shouldPlace, holdPayload.Reason, &actingUser.ID); saveError != nil { requestLogger.Error("der legal hold konnte in der control plane nicht vermerkt werden", slog.String("grund", saveError.Error())) } auditAction := audit.ActionLegalHoldPlaced if !shouldPlace { auditAction = audit.ActionLegalHoldReleased } handler.recordAudit(request, actingUser, auditAction, "backup", &backupIdentifier, map[string]any{ "backup_in_repository": backupRecord.BackupIDInRepository, "reason": holdPayload.Reason, }) WriteSuccess(responseWriter, request, http.StatusOK, handler.buildProtectionResponse(request.Context(), backupIdentifier, backupRecord, resultingStatus)) } // handleExtendRetention bedient POST /backups/{id}/retention/extend. func (handler *retentionHandler) handleExtendRetention(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) backupIdentifier, parseError := parsePathIdentifier(request, "Die Backupkennung ist keine gueltige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } var extendPayload extendRetentionRequest if decodeError := decodeJSONBody(request, &extendPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if extendPayload.ImmutableUntil.IsZero() { WriteError(responseWriter, request, requestLogger, NewValidationError("Es wurde kein neues Ende der Aufbewahrungsfrist angegeben.")) return } openedRepository, backupRecord, openError := handler.openWritableRepositoryOfBackup(request.Context(), backupIdentifier) if openError != nil { WriteError(responseWriter, request, requestLogger, openError) return } defer func() { _ = openedRepository.Close() }() extendedStatus, extendError := openedRepository.ExtendRetention(request.Context(), backupRecord.BackupIDInRepository, extendPayload.ImmutableUntil, actingUser.Username, extendPayload.Reason) if extendError != nil { WriteError(responseWriter, request, requestLogger, translateRetentionError(extendError)) return } if saveError := handler.retentionStore.SaveImmutableUntil(request.Context(), backupIdentifier, extendPayload.ImmutableUntil); saveError != nil { requestLogger.Error("die verlaengerte frist konnte in der control plane nicht vermerkt werden", slog.String("grund", saveError.Error())) } handler.recordAudit(request, actingUser, audit.ActionRetentionExtended, "backup", &backupIdentifier, map[string]any{ "backup_in_repository": backupRecord.BackupIDInRepository, "immutable_until": extendPayload.ImmutableUntil.Format(time.RFC3339), "reason": extendPayload.Reason, }) WriteSuccess(responseWriter, request, http.StatusOK, handler.buildProtectionResponse(request.Context(), backupIdentifier, backupRecord, extendedStatus)) } // handleDeleteBackup bedient DELETE /backups/{id}. // // Drei Huerden vor der Loeschung: das Recht backups.delete, die woertliche // Wiederholung der Backupkennung und der Aufbewahrungsschutz des Repositorys, // der auch dann greift, wenn die ersten beiden erfuellt sind. func (handler *retentionHandler) handleDeleteBackup(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) backupIdentifier, parseError := parsePathIdentifier(request, "Die Backupkennung ist keine gueltige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } var deletePayload deleteBackupRequest if decodeError := decodeJSONBody(request, &deletePayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } openedRepository, backupRecord, openError := handler.openWritableRepositoryOfBackup(request.Context(), backupIdentifier) if openError != nil { WriteError(responseWriter, request, requestLogger, openError) return } defer func() { _ = openedRepository.Close() }() if deletePayload.ConfirmBackupID != backupRecord.BackupIDInRepository { confirmationError := NewValidationError( "Diese Loeschung ist unwiderruflich. Wiederholen Sie zur Bestaetigung die Kennung des " + "Backups im Feld confirm_backup_id.") confirmationError.Details = map[string]any{ "confirm_with": backupRecord.BackupIDInRepository, } WriteError(responseWriter, request, requestLogger, confirmationError) return } if deleteError := openedRepository.DeleteBackup(request.Context(), backupRecord.BackupIDInRepository); deleteError != nil { // Der Aufbewahrungsschutz meldet sich hier — und zwar auch dann, wenn // Recht und Bestaetigung vorliegen. Das ist sein Zweck. handler.recordAudit(request, actingUser, audit.ActionBackupDeletionDenied, "backup", &backupIdentifier, map[string]any{ "backup_in_repository": backupRecord.BackupIDInRepository, "reason": deleteError.Error(), }) WriteError(responseWriter, request, requestLogger, translateRetentionError(deleteError)) return } if markError := handler.retentionStore.MarkBackupsDeleted(request.Context(), backupRecord.RepositoryID, []string{backupRecord.BackupIDInRepository}, &actingUser.ID, deletePayload.Reason); markError != nil { requestLogger.Error("die loeschung konnte in der control plane nicht vermerkt werden", slog.String("grund", markError.Error())) } handler.recordAudit(request, actingUser, audit.ActionBackupDeleted, "backup", &backupIdentifier, map[string]any{ "backup_in_repository": backupRecord.BackupIDInRepository, "reason": deletePayload.Reason, }) WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "message": "Das Backup wurde geloescht. Seine Datenbloecke verschwinden mit der naechsten " + "Bereinigung, sofern kein anderes Backup sie noch braucht.", }) } // handleMeasureEnforcement bedient POST /repositories/{id}/enforcement/measure. // // Die Messung ist der Kern der Ehrlichkeit dieser Phase: Sie stellt fest, was // das Dateisystem tatsaechlich verhindert, statt es zu behaupten. func (handler *retentionHandler) handleMeasureEnforcement(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) repositoryIdentifier, parseError := parsePathIdentifier(request, "Die Repositorykennung ist keine gueltige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } repositoryRecord, repositoryError := handler.jobStore.GetRepository(request.Context(), repositoryIdentifier) if repositoryError != nil { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden.")) return } enforcementReport, measureError := repository.MeasureEnforcement(request.Context(), repositoryRecord.Location, handler.logger) if measureError != nil { WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError( "Die Durchsetzungsstufe liess sich nicht messen; das Repository ist nicht erreichbar.")) return } if saveError := handler.retentionStore.SaveEnforcementLevel(request.Context(), repositoryIdentifier, enforcementReport); saveError != nil { requestLogger.Warn("die gemessene stufe konnte nicht gespeichert werden", slog.String("grund", saveError.Error())) } WriteSuccess(responseWriter, request, http.StatusOK, enforcementReport) } // openWritableRepositoryOfBackup oeffnet das Repository eines Backups schreibend. func (handler *retentionHandler) openWritableRepositoryOfBackup(openContext context.Context, backupIdentifier uuid.UUID) (*repository.LocalRepository, *jobs.StoredBackup, *APIError) { backupRecord, backupError := handler.jobStore.GetBackup(openContext, backupIdentifier) if backupError != nil { if errors.Is(backupError, jobs.ErrBackupNotFound) { return nil, nil, NewNotFoundError("Das Backup wurde nicht gefunden.") } return nil, nil, NewInternalError(backupError) } repositoryRecord, repositoryError := handler.jobStore.GetRepository(openContext, backupRecord.RepositoryID) if repositoryError != nil { return nil, nil, NewInternalError(repositoryError) } openedRepository, openError := repository.Open(openContext, repositoryRecord.Location, repository.OpenOptions{}, handler.logger) if openError != nil { return nil, nil, NewServiceUnavailableError( "Das Repository ist derzeit nicht erreichbar oder wird gerade beschrieben.") } return openedRepository, backupRecord, nil } // recordAudit schreibt ein Auditereignis. func (handler *retentionHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, entityType string, entityIdentifier *uuid.UUID, auditDetails map[string]any) { if handler.auditRecorder == nil { return } correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context()) recordError := handler.auditRecorder.Record(request.Context(), audit.Event{ UserID: &actingUser.ID, ActorUsername: actingUser.Username, Action: auditAction, EntityType: entityType, EntityID: entityIdentifier, Result: audit.ResultSuccess, IPAddress: clientIPAddress(request), UserAgent: request.UserAgent(), CorrelationID: correlationIdentifier, Details: auditDetails, }) if recordError != nil { logging.WithContext(request.Context(), handler.logger).Error( "das auditereignis konnte nicht geschrieben werden", slog.String("aktion", string(auditAction)), slog.String("grund", recordError.Error())) } } // parsePathIdentifier liest eine Kennung aus dem Pfad. func parsePathIdentifier(request *http.Request, errorMessage string) (uuid.UUID, *APIError) { parsedIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { return uuid.Nil, NewBadRequestError(errorMessage) } return parsedIdentifier, nil } // formatInteger schreibt eine Zahl als Zeichenkette. func formatInteger(value int) string { return fmt.Sprintf("%d", value) } // translateRetentionError bildet Fehler der Fachschicht auf API-Fehler ab. func translateRetentionError(occurredError error) *APIError { switch { case errors.Is(occurredError, retention.ErrPolicyNotFound): return NewNotFoundError("Die Aufbewahrungsregel wurde nicht gefunden.") case errors.Is(occurredError, retention.ErrPolicyInUse): conflictError := NewValidationError( "Diese Aufbewahrungsregel wird noch von Sicherungsauftraegen verwendet. " + "Weisen Sie ihnen zuerst eine andere Regel zu.") conflictError.Code = ErrorCodeConflict conflictError.StatusCode = http.StatusConflict return conflictError case errors.Is(occurredError, retention.ErrPolicyEmpty), errors.Is(occurredError, retention.ErrPolicyInvalid): return NewValidationError(occurredError.Error()) case errors.Is(occurredError, repository.ErrLegalHold): // 409 und nicht 403: Der Aufrufer hat das Recht, das Backup ist nur // geschuetzt. Der Unterschied entscheidet, was er als Naechstes tut. holdError := NewValidationError( "Dieses Backup wird fuer Beweiszwecke gehalten und kann nicht geloescht werden. " + "Heben Sie zuerst den Legal Hold auf.") holdError.Code = ErrorCodeConflict holdError.StatusCode = http.StatusConflict return holdError case errors.Is(occurredError, repository.ErrRetentionLocked): lockedError := NewValidationError( "Dieses Backup steht unter Aufbewahrungsschutz und kann noch nicht geloescht werden.") lockedError.Code = ErrorCodeConflict lockedError.StatusCode = http.StatusConflict lockedError.Details = map[string]any{"detail": occurredError.Error()} return lockedError case errors.Is(occurredError, repository.ErrRetentionCannotBeShortened): shortenError := NewValidationError( "Eine Aufbewahrungsfrist laesst sich verlaengern, aber niemals verkuerzen.") shortenError.Details = map[string]any{"detail": occurredError.Error()} return shortenError case errors.Is(occurredError, repository.ErrBackupNotFound): return NewNotFoundError("Das Backup wurde im Repository nicht gefunden.") default: return NewInternalError(occurredError) } } // correlationUUIDOrNew liefert die Correlation ID des Requests als UUID. // // Die Spalte verlangt eine UUID; die Middleware laesst nur gueltige UUIDs // durch, doch bei einem Aufruf ohne Header stuende hier eine leere Zeichenkette. // Eine neue Kennung ist dann besser als ein abgebrochener Lauf — die Zuordnung // zu den Protokollzeilen geht verloren, der Aufbewahrungslauf nicht. func correlationUUIDOrNew(requestContext context.Context) uuid.UUID { correlationText, isPresent := logging.CorrelationIDFromContext(requestContext) if !isPresent { return uuid.New() } parsedIdentifier, parseError := uuid.Parse(correlationText) if parseError != nil { return uuid.New() } return parsedIdentifier } // payloadHadNoKeepLast meldet eine Anfrage ohne Angabe zu den juengsten Backups. func payloadHadNoKeepLast(payload policyRequest) bool { return payload.KeepLast <= 0 }