syncova-backup/apps/api/internal/httpapi/retention_handler.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

944 lines
37 KiB
Go

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
}