syncova-backup/migrations/000007_immutability.up.sql
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

223 lines
11 KiB
SQL

-- 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';