-- Immutability und Aufbewahrung (Phase 11). -- -- Der Grundsatz dieser Migration steht in einer einzigen Spalte: -- repositories.enforcement_level. Sie haelt fest, was das Dateisystem des -- Repositorys **nachweislich** verhindert — gemessen, nicht behauptet. Ein -- Backup-Produkt, das „unveraenderlich" in eine Datenbankspalte schreibt und -- dabei belaesst, verkauft eine Ransomware-Strategie, die keine ist. -- --------------------------------------------------------------------------- -- Repositories: gemessene Durchsetzung und Fristen -- --------------------------------------------------------------------------- ALTER TABLE repositories -- enforcement_level ist die zuletzt gemessene Durchsetzungsstufe. -- -- NULL bedeutet: nie gemessen. Der Unterschied zu 'advisory' ist wichtig — -- „wir wissen es nicht" ist keine Aussage ueber den Schutz. ADD COLUMN enforcement_level TEXT, -- enforcement_measured_at ist der Zeitpunkt der Messung in UTC. ADD COLUMN enforcement_measured_at TIMESTAMPTZ, -- enforcement_report haelt die einzelnen Versuche der Messung. -- -- Der Bericht bleibt erhalten, nicht nur sein Ergebnis: Wer wissen will, -- worauf sich die Stufe stuetzt, soll es nachlesen koennen. ADD COLUMN enforcement_report JSONB, -- retention_seconds ist die Aufbewahrungsfrist neuer Backups. -- -- Massgeblich bleibt der Descriptor im Repository; diese Spalte ist sein -- Abbild fuer Uebersichten. Bei Widerspruch gilt das Repository — es muss -- auch ohne Control Server deutbar bleiben. ADD COLUMN retention_seconds BIGINT, -- minimum_retention_seconds ist die kuerzeste je zulaessige Frist. ADD COLUMN minimum_retention_seconds BIGINT, ADD CONSTRAINT repositories_enforcement_level_valid CHECK (enforcement_level IS NULL OR enforcement_level IN ('none', 'advisory', 'filesystem', 'storage')), ADD CONSTRAINT repositories_retention_not_negative CHECK (retention_seconds IS NULL OR retention_seconds >= 0), ADD CONSTRAINT repositories_minimum_retention_not_negative CHECK (minimum_retention_seconds IS NULL OR minimum_retention_seconds >= 0); -- --------------------------------------------------------------------------- -- Backups: Legal Hold neben der Frist -- --------------------------------------------------------------------------- ALTER TABLE backups -- legal_hold meldet einen unbefristeten Schutz fuer Beweiszwecke. -- -- Getrennt von immutable_until, weil die Abhilfe eine andere ist: Eine Frist -- laeuft ab, ein Legal Hold muss ausdruecklich aufgehoben werden. ADD COLUMN legal_hold BOOLEAN NOT NULL DEFAULT false, -- legal_hold_reason begruendet den unbefristeten Schutz. ADD COLUMN legal_hold_reason TEXT, -- legal_hold_placed_at ist der Zeitpunkt der Anordnung in UTC. ADD COLUMN legal_hold_placed_at TIMESTAMPTZ, -- legal_hold_placed_by benennt den Anordnenden. ADD COLUMN legal_hold_placed_by UUID REFERENCES users(id) ON DELETE SET NULL, -- deleted_at ist der Zeitpunkt der Loeschung in UTC. -- -- Ein geloeschtes Backup wird nicht aus der Tabelle entfernt: Die Frage -- „warum ist das Backup von vorletzter Woche weg?" muss beantwortbar -- bleiben, gerade wenn niemand mehr weiss, wer geloescht hat. ADD COLUMN deleted_at TIMESTAMPTZ, -- deleted_by benennt den Loeschenden; NULL bei automatischer Aufbewahrung. ADD COLUMN deleted_by UUID REFERENCES users(id) ON DELETE SET NULL, -- deletion_reason begruendet die Loeschung. ADD COLUMN deletion_reason TEXT, -- Ein Legal Hold ohne Begruendung waere nach einem Jahr nicht mehr -- aufloesbar: Niemand traut sich, einen Schutz aufzuheben, dessen Anlass -- unbekannt ist. ADD CONSTRAINT backups_legal_hold_needs_reason CHECK ( legal_hold = false OR (legal_hold_reason IS NOT NULL AND legal_hold_reason <> '') ), -- Ein Backup unter Legal Hold darf nicht als geloescht vermerkt sein. Die -- Regel steht in der Datenbank und nicht nur im Code: Im Code muesste jede -- Stelle sie einhalten, und eine vergisst es. ADD CONSTRAINT backups_legal_hold_not_deleted CHECK ( legal_hold = false OR deleted_at IS NULL ); -- Fuer die Uebersicht: Was steht unter Schutz? CREATE INDEX backups_legal_hold_idx ON backups (repository_id) WHERE legal_hold = true; -- Fuer die Aufbewahrung: Welche Backups sind noch da? CREATE INDEX backups_not_deleted_idx ON backups (repository_id, completed_at DESC) WHERE deleted_at IS NULL; -- --------------------------------------------------------------------------- -- Aufbewahrungsregeln -- --------------------------------------------------------------------------- -- retention_policies existiert seit 000002 mit einer freien rules-Spalte. -- Die einzelnen Haltevorgaben bekommen eigene Spalten: Eine CHECK-Bedingung -- gegen ein JSON-Feld liesse sich nicht formulieren, und genau hier wird eine -- gebraucht — eine Regel ohne jede Haltevorgabe wuerde alle Backups loeschen. ALTER TABLE retention_policies -- keep_within_seconds haelt jedes Backup, das juenger ist als diese Spanne. ADD COLUMN keep_within_seconds BIGINT, -- keep_last haelt die juengsten n Backups, unabhaengig vom Alter. ADD COLUMN keep_last INTEGER NOT NULL DEFAULT 1, -- keep_daily haelt n Tagesstaende. ADD COLUMN keep_daily INTEGER, -- keep_weekly haelt n Wochenstaende. ADD COLUMN keep_weekly INTEGER, -- keep_monthly haelt n Monatsstaende. ADD COLUMN keep_monthly INTEGER, -- keep_yearly haelt n Jahresstaende. ADD COLUMN keep_yearly INTEGER, -- time_zone ist die Zeitzone der Tages- und Monatsgrenzen. -- -- Ohne sie liefe die Einteilung in UTC, und wer taeglich um 00:30 deutscher -- Zeit sichert, verlore systematisch die falschen Backups. ADD COLUMN time_zone TEXT, -- created_by benennt den Anlegenden. ADD COLUMN created_by UUID REFERENCES users(id) ON DELETE SET NULL, -- Der Standardwert 1 fuer keep_last ist die wichtigste Zeile dieser -- Migration: Er verhindert die haeufigste Katastrophe einer -- Aufbewahrungsregel — ein System sichert monatelang nicht, alle Backups -- fallen aus der Frist, und die Regel loescht das letzte vorhandene. ADD CONSTRAINT retention_policies_keeps_something CHECK ( COALESCE(keep_within_seconds, 0) > 0 OR keep_last > 0 OR COALESCE(keep_daily, 0) > 0 OR COALESCE(keep_weekly, 0) > 0 OR COALESCE(keep_monthly, 0) > 0 OR COALESCE(keep_yearly, 0) > 0 ), ADD CONSTRAINT retention_policies_counts_not_negative CHECK ( keep_last >= 0 AND COALESCE(keep_daily, 0) >= 0 AND COALESCE(keep_weekly, 0) >= 0 AND COALESCE(keep_monthly, 0) >= 0 AND COALESCE(keep_yearly, 0) >= 0 AND COALESCE(keep_within_seconds, 0) >= 0 ); -- --------------------------------------------------------------------------- -- Laeufe der Aufbewahrung -- --------------------------------------------------------------------------- -- Jede Anwendung einer Aufbewahrungsregel wird festgehalten. Ohne diese Tabelle -- liesse sich im Nachhinein nicht beantworten, warum ein Backup fehlt — und das -- ist die erste Frage, die im Ernstfall gestellt wird. CREATE TABLE retention_runs ( -- id ist der oeffentliche Bezeichner. id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- repository_id ist das betroffene Repository. repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, -- retention_policy_id ist die angewandte Regel. retention_policy_id UUID REFERENCES retention_policies(id) ON DELETE SET NULL, -- policy_name haelt den Namen fest, auch wenn die Regel spaeter verschwindet. policy_name TEXT NOT NULL, -- dry_run meldet eine reine Vorschau ohne Loeschung. dry_run BOOLEAN NOT NULL DEFAULT true, -- evaluated_count ist die Zahl betrachteter Backups. evaluated_count INTEGER NOT NULL DEFAULT 0, -- kept_count ist die Zahl behaltener Backups. kept_count INTEGER NOT NULL DEFAULT 0, -- deleted_count ist die Zahl geloeschter Backups. deleted_count INTEGER NOT NULL DEFAULT 0, -- protected_count ist die Zahl faelliger, aber geschuetzter Backups. protected_count INTEGER NOT NULL DEFAULT 0, -- failed_count ist die Zahl gescheiterter Loeschungen. failed_count INTEGER NOT NULL DEFAULT 0, -- chunks_removed ist die Zahl bereinigter Datenbloecke. chunks_removed BIGINT NOT NULL DEFAULT 0, -- bytes_freed ist der freigegebene Speicher. bytes_freed BIGINT NOT NULL DEFAULT 0, -- plan haelt die Einzelentscheidungen samt Begruendung. plan JSONB, -- error_message ist die verstaendliche Fehlermeldung. error_message TEXT, -- correlation_id verbindet den Lauf mit seinen Protokollzeilen. correlation_id UUID NOT NULL, -- triggered_by benennt den Ausloesenden; NULL bei automatischem Lauf. triggered_by UUID REFERENCES users(id) ON DELETE SET NULL, -- started_at ist der Beginn in UTC. started_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- completed_at ist das Ende in UTC. completed_at TIMESTAMPTZ, -- Eine Vorschau darf niemals etwas geloescht haben. Die Regel gehoert in die -- Datenbank: Sie ist die Zusicherung, auf die sich der Betreiber verlaesst, -- wenn er eine unbekannte Regel ausprobiert. CONSTRAINT retention_runs_dry_run_deletes_nothing CHECK ( dry_run = false OR (deleted_count = 0 AND chunks_removed = 0 AND bytes_freed = 0) ), CONSTRAINT retention_runs_counts_not_negative CHECK ( evaluated_count >= 0 AND kept_count >= 0 AND deleted_count >= 0 AND protected_count >= 0 AND failed_count >= 0 AND chunks_removed >= 0 AND bytes_freed >= 0 ) ); CREATE INDEX retention_runs_repository_idx ON retention_runs (repository_id, started_at DESC); CREATE INDEX retention_runs_correlation_idx ON retention_runs (correlation_id); -- --------------------------------------------------------------------------- -- Berechtigungen -- --------------------------------------------------------------------------- -- Drei neue Rechte, und ihre Trennung ist der Kern der Zugriffssteuerung dieser -- Phase: Wer Backups loeschen darf, darf deshalb noch lange keinen -- Aufbewahrungsschutz aufheben. INSERT INTO permissions (name, description) VALUES ('backups.delete', 'Backups löschen'), ('retention.write', 'Aufbewahrungsregeln anlegen, ändern und anwenden'), ('immutability.manage', 'Aufbewahrungsschutz verlängern und Legal Holds setzen oder aufheben'); -- backups.delete: Wer sichert, raeumt auch auf. INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r, permissions p WHERE r.name IN ('super_administrator', 'backup_operator') AND p.name IN ('backups.delete', 'retention.write'); -- immutability.manage bleibt der Sicherheitsverwaltung und dem Super -- Administrator vorbehalten. Ein Legal Hold ist eine rechtliche Anordnung, und -- das Aufheben eines Aufbewahrungsschutzes ist der Schritt, der einem Angreifer -- den Weg oeffnet — er gehoert nicht in dieselbe Hand wie das Loeschen. INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r, permissions p WHERE r.name IN ('super_administrator', 'security_administrator') AND p.name = 'immutability.manage';