Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a95ed331cd | ||
|
|
e23f514850 | ||
|
|
8ff4e82d38 |
@@ -0,0 +1,69 @@
|
||||
# CMP-02 – Prüfprotokoll: DSGVO-/Datenschutz-Berichte
|
||||
|
||||
Voraussetzung RET-01 – bereits Fertig.
|
||||
|
||||
## Vorab identifizierte und geklärte Design-Lücke
|
||||
|
||||
RET-01 (`retention_objects`) führte bislang keine Zuordnung zu einer
|
||||
"betroffenen Person" — nur `object_type`/`object_reference` (opake
|
||||
modulübergreifende Referenz). Ein Auskunftsbericht "aller Objekte einer
|
||||
Person" war damit strukturell unmöglich. Nach Nutzerentscheidung
|
||||
(Option 1) additiv gelöst:
|
||||
|
||||
- `archive/migrations/0009_data_subject_ref.up.sql` — nullable Spalte
|
||||
`retention_objects.data_subject_ref` + Index.
|
||||
- `archive/internal/retention.RegisterObjectForSubject` — NEUE, additive
|
||||
Funktion. `RegisterObject` (RET-01) bleibt UNVERÄNDERT (kein Diff),
|
||||
kein bestehender Aufrufer betroffen (Codeprüfung: `RegisterObject`
|
||||
hatte ohnehin nur Testaufrufer, keine Produktionsverdrahtung).
|
||||
- Leeres `data_subject_ref` bedeutet "nicht personenbezogen", kein
|
||||
Fehlerzustand (z. B. Systemkonfigurationsobjekte).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `archive/internal/dpreport.SubjectReport` – Auskunftsbericht
|
||||
(Akzeptanzkriterium 1), nutzt dieselbe "jüngste Zuordnung"-Logik wie
|
||||
RET-02 (DISTINCT ON), keine zweite Berechnung.
|
||||
- `archive/internal/dpreport.ProcessingOverview` – Verarbeitungsübersicht
|
||||
je tatsächlich vorkommendem Objekttyp (Akzeptanzkriterium 2), Zweck/
|
||||
Rechtsgrundlage statisch gepflegt (`ProcessingPurposes`) — Rechts-
|
||||
bewertungen sind keine aus Nutzdaten ableitbaren Werte.
|
||||
- `archive/internal/dpreport.WriteSubjectReportCSV` – CSV-Export
|
||||
(Akzeptanzkriterium/Pflichtprüfung 3).
|
||||
- **Mandantentrennung (Akzeptanzkriterium 3):** strukturell garantiert
|
||||
durch Modell C — `SubjectReport` läuft immer gegen GENAU EINEN
|
||||
Tenant-Pool, kein Cross-Tenant-Query technisch möglich.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Auskunftsbericht für Testperson mit bekanntem Datenbestand stimmt mit erwarteter Liste überein | **bestanden** – `TestSubjectReport_MatchesKnownDataset`: 3 Objekte für 2 Personen angelegt, Bericht für Person A liefert exakt die 2 erwarteten Objekte (nicht das dritte, das Person B gehört), inkl. korrekter Aufbewahrungsklasse |
|
||||
| 2 | Bericht für einen Tenant enthält keine Objekte eines anderen Tenants | **bestanden** – `TestSubjectReport_TenantIsolation`: real gegen ZWEI PHYSISCH GETRENNTE Tenant-Datenbanken (`tenant_acme`, `tenant_globex`) getestet, nicht nur zweimal dieselbe DSN — Objekt in Tenant A angelegt, Bericht für dieselbe `data_subject_ref` gegen Tenant B liefert 0 Treffer |
|
||||
| 3 | Export lässt sich als CSV weiterverarbeiten | **bestanden** – `TestWriteSubjectReportCSV_IsParseable`: echte CSV-Ausgabe erzeugt und geparst, Header + genau eine Datenzeile |
|
||||
|
||||
**Hinweis zur Testkorrektur:** Der erste Testlauf von Prüfung 2 nutzte
|
||||
versehentlich denselben `TEST_TENANT_DSN` für beide "Tenants" (dieselbe
|
||||
physische Datenbank) und schlug dadurch zurecht fehl — kein
|
||||
Code-Defekt, sondern ein Testfehler. Korrigiert auf zwei echte,
|
||||
unabhängige Tenant-Datenbanken (`TEST_TENANT_DSN_B`), danach real
|
||||
bestanden.
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -> alle Archive-Pakete bestanden (inkl. dpreport, retention)
|
||||
```
|
||||
|
||||
Migration `0009_data_subject_ref` real auf `dms_tenant_test` angewendet.
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt, inklusive einer vorab identifizierten
|
||||
und mit dem Nutzer geklärten strukturellen Lücke (fehlende
|
||||
Personen-Zuordnung in RET-01), additiv und ohne Änderung an bestehendem
|
||||
Verhalten geschlossen.
|
||||
@@ -0,0 +1,64 @@
|
||||
# RET-03 – Prüfprotokoll: Löschworkflow & Aufbewahrungssperre (Legal Hold)
|
||||
|
||||
Voraussetzung RET-01, RET-02 – beide bereits Fertig.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `archive/migrations/0008_legal_hold_destruction.up/down.sql`:
|
||||
- `legal_holds` – historisiert (Sperre wird NIE gelöscht, nur
|
||||
`released_at`/`released_by` gesetzt), Partial-Unique-Index erlaubt
|
||||
höchstens eine AKTIVE Sperre je Objekt.
|
||||
- `destruction_log` – append-only, real per Postgres-TRIGGER gegen
|
||||
UPDATE/DELETE geschützt (`destruction_log_immutable()`,
|
||||
`RAISE EXCEPTION`) — DB-Ebene, nicht nur Anwendungscode.
|
||||
- `archive/internal/deletionworkflow`:
|
||||
- `SetLegalHold` — Begründung PFLICHT (`ErrReasonRequired`),
|
||||
`ErrAlreadyOnHold` bei bereits aktiver Sperre (DB-Unique-Index als
|
||||
zusätzliche Absicherung gegen Wettlaufsituationen).
|
||||
- `ReleaseLegalHold` — hebt auf, OHNE die Zeile zu löschen (das
|
||||
Aufheben selbst bleibt dauerhaft nachvollziehbar).
|
||||
- `ReleaseExpired` — Freigabeprozess: `active` → `expired`, NIEMALS
|
||||
direkt `deleted`. Objekte mit aktiver Sperre werden übersprungen,
|
||||
unabhängig vom Fristablauf.
|
||||
- `Destroy` — verlangt vorherigen Status `expired`
|
||||
(`ErrNotReleased` sonst), prüft die Sperre ZUSÄTZLICH direkt vor der
|
||||
Vernichtung erneut (Verteidigung in der Tiefe gegen eine
|
||||
zwischenzeitlich gesetzte Sperre), schreibt Statuswechsel und
|
||||
Protokolleintrag in EINER Transaktion.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Objekt mit aktiver Sperre widersteht einem direkten Löschversuch | **bestanden** – `TestDestroy_ObjectWithActiveHoldResistsDeletion`: Objekt bereits im Status `expired` (simuliert vorherige Freigabe), Sperre danach gesetzt, `Destroy` liefert `ErrOnLegalHold`, Status bleibt unverändert |
|
||||
| 2 | Protokolleintrag nach Vernichtung ist nachträglich nicht änderbar (DB-Constraint oder Trigger) | **bestanden** – `TestDestructionLog_IsImmutable`: nach echter `Destroy`-Ausführung direkter `UPDATE`/`DELETE` per rohem SQL (umgeht die Go-API vollständig) — beide scheitern real am Postgres-Trigger, nicht nur weil das Paket keine Änderungsfunktion anbietet |
|
||||
| 3 | Aufheben einer Sperre ist selbst protokolliert | **bestanden** – `TestReleaseLegalHold_IsItselfLogged`: nach `ReleaseLegalHold` sind `released_at`/`released_by` real gesetzt, die ursprüngliche Sperr-Zeile bleibt bestehen (kein DELETE) |
|
||||
|
||||
**Zusätzliche Tests (Akzeptanzkriterium 1, nicht in den drei
|
||||
Pflichtprüfungen, aber je AC mindestens ein Test):**
|
||||
- `TestReleaseExpired_NoImmediateDeletionAndHoldIsRespected` — fälliges
|
||||
Objekt wird `expired` (NICHT `deleted`), gesperrtes fälliges Objekt
|
||||
bleibt `active`, nicht fälliges Objekt bleibt `active`.
|
||||
- `TestSetLegalHold_RequiresReason`, `TestDestroy_RequiresPriorRelease`.
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -> alle Archive-Pakete bestanden (inkl. deletionworkflow, 6 Tests)
|
||||
```
|
||||
|
||||
Migration `0008_legal_hold_destruction` real auf `dms_tenant_test`
|
||||
angewendet (Test-DB, in der auch RET-06-API läuft) — sauber, ohne
|
||||
Fehler.
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt. Damit ist das RET-Epic (RET-01 bis
|
||||
RET-10) vollständig abgeschlossen: Objektmodell, Fristenengine,
|
||||
Modul-Adapter (Interface + Dienst), Konfigurationsoberfläche (Backend +
|
||||
Frontend + CORS), Benachrichtigungen, WORM-Speicher und jetzt
|
||||
Löschworkflow mit Legal Hold.
|
||||
@@ -0,0 +1,66 @@
|
||||
# RET-04 – Prüfprotokoll: WORM-Speicher-Garantie (Append-only)
|
||||
|
||||
Voraussetzung RET-01 – bereits Fertig.
|
||||
|
||||
## Grenzen des Schutzes (bewusst dokumentiert, nicht behauptet)
|
||||
|
||||
Nach Nutzerentscheidung (Option 1) beschränkt sich der technische Schutz
|
||||
auf zwei Ebenen, beide EHRLICH begrenzt dokumentiert statt als absolut
|
||||
verkauft:
|
||||
|
||||
1. **Dateisystem-Schreibschutz (`chmod 0400`)** — schützt zuverlässig
|
||||
gegen den Betriebs-Nutzer der Dienste (`nexarch`, kein root), NICHT
|
||||
gegen root/CAP_DAC_OVERRIDE. Ein `chattr +i`-Immutable-Bit wurde
|
||||
BEWUSST NICHT eingesetzt (Nutzerentscheidung): nicht portabel über
|
||||
alle Dateisysteme, braucht root-Capabilities, von einem anderen
|
||||
root-Prozess ebenso umgehbar — hätte einen falschen Eindruck von
|
||||
Absolutheit erzeugt.
|
||||
2. **Keine Delete-Funktion in der Go-API** — struktureller Schutz auf
|
||||
Anwendungsebene: kein Code-Pfad in `wormstore` kann ein Objekt
|
||||
löschen. Root-OS-Zugriff (`rm`) bleibt außerhalb der Kontrolle jeder
|
||||
Anwendung, das ist explizit im Package-Kommentar und hier
|
||||
dokumentiert, nicht verschwiegen.
|
||||
|
||||
Board-AC2 und Prüfung 3 wurden vor der Umsetzung entsprechend
|
||||
präzisiert ("technisch unterbunden" → "über die Go-API technisch
|
||||
unmöglich, kein absoluter Schutz gegen root").
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `archive/migrations/0007_worm_objects.up/down.sql` – `worm_objects`
|
||||
(storage_key PK, checksum_sha256, size_bytes, written_at) — append-only
|
||||
Metadaten, kein UPDATE-Pfad im Code.
|
||||
- `archive/internal/wormstore.Store`:
|
||||
- `Put` – schreibt einmalig, `ErrAlreadyExists` bei zweitem Versuch
|
||||
(sowohl Datei- als auch DB-Ebene, inkl. Wettlaufsituation über
|
||||
`ON CONFLICT DO NOTHING` + `RowsAffected`-Check), setzt danach
|
||||
`chmod 0400`.
|
||||
- `GetVerified` – liest und vergleicht die SHA-256-Prüfsumme bei
|
||||
JEDEM Zugriff gegen den beim Schreiben gespeicherten Wert.
|
||||
- **Kein `Delete`** — bewusst, siehe oben.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Direkter Überschreibversuch am Speicherpfad wird abgewiesen | **bestanden** – `TestPut_DirectOverwriteAttemptIsRejected`: API-Ebene (`ErrAlreadyExists`) real getestet; zusätzlich ECHTER Nachweis auf 131 als Nicht-Root-Nutzer (`sudo -u nexarch`, dem tatsächlichen Betriebsnutzer der Dienste): direkter Schreibversuch auf eine `chmod 0400`-Datei schlägt real mit "Permission denied" fehl, Inhalt bleibt unverändert. Der Automatik-Test selbst läuft auf 131 als root (`os.Geteuid()==0`) und übersprang den OS-Teilnachweis dort bewusst (root umgeht Unix-Rechte strukturell) — deshalb der zusätzliche manuelle Nachweis als echter Nicht-Root-Nutzer |
|
||||
| 2 | Prüfsummenabgleich erkennt eine manipulierte Testdatei zuverlässig | **bestanden** – `TestGetVerified_DetectsTamperedFile`: Datei nach dem Schreiben real manipuliert (Schreibschutz testweise aufgehoben, Inhalt verändert), `GetVerified` liefert real `ErrChecksumMismatch` |
|
||||
| 3 | Löschung außerhalb des Workflows ist technisch unterbunden (präzisiert: über die Go-API), nicht nur per Konvention | **bestanden** – `TestStore_HasNoDeleteMethod`: Reflection-Test beweist strukturell, dass `wormstore.Store` KEINE Lösch-Methode besitzt — kein Kommentar/keine Konvention, sondern eine compile-time-nachweisbare API-Grenze |
|
||||
|
||||
## Build/Test-Ergebnis (192.168.1.131)
|
||||
|
||||
```
|
||||
go build ./... -> clean
|
||||
go vet ./... -> clean
|
||||
golangci-lint run ./... -> 0 issues
|
||||
go test ./... -p 1 -> alle Archive-Pakete bestanden (inkl. wormstore)
|
||||
```
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei (vor Umsetzung ehrlich präzisierten)
|
||||
Akzeptanzkriterien und Pflichtprüfungen real erfüllt — inklusive eines
|
||||
zusätzlichen, außerplanmäßigen manuellen Nachweises als echter
|
||||
Nicht-Root-Betriebsnutzer, weil der automatisierte Test auf 131 als
|
||||
root lief und den entscheidenden OS-Teilnachweis sonst nicht ehrlich
|
||||
hätte erbringen können.
|
||||
@@ -0,0 +1,181 @@
|
||||
// Package deletionworkflow implementiert RET-03: den kontrollierten
|
||||
// Löschworkflow für abgelaufene Aufbewahrungsobjekte (Freigabe →
|
||||
// Vernichtung) und die Aufbewahrungssperre (Legal Hold), die jede
|
||||
// Löschung unabhängig vom Fristablauf verhindert. Baut auf RET-01
|
||||
// (retention_objects.status) und RET-02 (Fristenberechnung) auf, keine
|
||||
// eigene Fristenlogik.
|
||||
package deletionworkflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrReasonRequired wird geliefert, wenn beim Setzen einer Sperre keine
|
||||
// Begründung angegeben wurde (Akzeptanzkriterium 2: Begründungspflicht).
|
||||
var ErrReasonRequired = errors.New("deletionworkflow: begründung ist beim setzen einer aufbewahrungssperre pflicht")
|
||||
|
||||
// ErrAlreadyOnHold wird geliefert, wenn für das Objekt bereits eine
|
||||
// aktive Sperre existiert (Partial-Unique-Index erzwingt das auch auf
|
||||
// DB-Ebene).
|
||||
var ErrAlreadyOnHold = errors.New("deletionworkflow: objekt steht bereits unter aufbewahrungssperre")
|
||||
|
||||
// ErrOnLegalHold wird von Destroy geliefert, wenn eine aktive Sperre die
|
||||
// Vernichtung verhindert (Akzeptanzkriterium 2: Sperre überstimmt jede
|
||||
// Fristregel).
|
||||
var ErrOnLegalHold = errors.New("deletionworkflow: objekt steht unter aufbewahrungssperre, vernichtung nicht erlaubt")
|
||||
|
||||
// ErrNotReleased wird geliefert, wenn Destroy für ein Objekt aufgerufen
|
||||
// wird, das noch nicht über ReleaseExpired freigegeben wurde
|
||||
// (Akzeptanzkriterium 1: kein Sprung von "active" direkt zu "deleted").
|
||||
var ErrNotReleased = errors.New("deletionworkflow: objekt ist nicht zur vernichtung freigegeben (status != expired)")
|
||||
|
||||
// SetLegalHold setzt eine Aufbewahrungssperre für ein Objekt. reason ist
|
||||
// PFLICHT (Akzeptanzkriterium 2). Existiert bereits eine aktive Sperre,
|
||||
// wird ErrAlreadyOnHold geliefert (der Partial-Unique-Index
|
||||
// idx_legal_holds_active verhindert eine zweite aktive Zeile auch bei
|
||||
// gleichzeitigen Aufrufen).
|
||||
func SetLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, reason, setBy string) error {
|
||||
if reason == "" {
|
||||
return ErrReasonRequired
|
||||
}
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO legal_holds (retention_object_id, reason, set_by)
|
||||
VALUES ($1, $2, $3)
|
||||
`, retentionObjectID, reason, setBy)
|
||||
if err != nil {
|
||||
var pgErr interface{ SQLState() string }
|
||||
if errors.As(err, &pgErr) && pgErr.SQLState() == "23505" {
|
||||
return ErrAlreadyOnHold
|
||||
}
|
||||
return fmt.Errorf("deletionworkflow: sperre setzen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseLegalHold hebt die aktive Sperre eines Objekts auf. Die
|
||||
// ursprüngliche Zeile bleibt bestehen (released_at/released_by werden
|
||||
// gesetzt, kein DELETE) — das Aufheben ist dadurch selbst dauerhaft
|
||||
// protokolliert (Akzeptanzkriterium/Pflichtprüfung 3).
|
||||
func ReleaseLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, releasedBy string) error {
|
||||
tag, err := pool.Exec(ctx, `
|
||||
UPDATE legal_holds SET released_at = now(), released_by = $2
|
||||
WHERE retention_object_id = $1 AND released_at IS NULL
|
||||
`, retentionObjectID, releasedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletionworkflow: sperre aufheben: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("deletionworkflow: keine aktive sperre für objekt %q gefunden", retentionObjectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsOnLegalHold prüft, ob ein Objekt aktuell unter Sperre steht.
|
||||
func IsOnLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (bool, error) {
|
||||
var exists bool
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM legal_holds WHERE retention_object_id = $1 AND released_at IS NULL)
|
||||
`, retentionObjectID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("deletionworkflow: sperrstatus prüfen: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ReleaseExpired ist der Freigabeprozess (Akzeptanzkriterium 1): setzt
|
||||
// den Status abgelaufener Objekte von "active" auf "expired" — KEINE
|
||||
// automatische Sofortlöschung. Objekte unter aktiver Aufbewahrungssperre
|
||||
// werden übersprungen, unabhängig vom Fristablauf (Akzeptanzkriterium
|
||||
// 2). Liefert die IDs der freigegebenen Objekte.
|
||||
func ReleaseExpired(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]string, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
WITH latest_assignment AS (
|
||||
SELECT DISTINCT ON (retention_object_id)
|
||||
retention_object_id, retention_class, assigned_at
|
||||
FROM retention_class_assignments
|
||||
ORDER BY retention_object_id, assigned_at DESC
|
||||
),
|
||||
due AS (
|
||||
SELECT o.id
|
||||
FROM retention_objects o
|
||||
JOIN latest_assignment a ON a.retention_object_id = o.id
|
||||
JOIN retention_class_rules r ON r.retention_class = a.retention_class AND r.active
|
||||
WHERE o.status = 'active'
|
||||
AND (a.assigned_at + r.duration) <= $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM legal_holds h
|
||||
WHERE h.retention_object_id = o.id AND h.released_at IS NULL
|
||||
)
|
||||
)
|
||||
UPDATE retention_objects SET status = 'expired'
|
||||
WHERE id IN (SELECT id FROM due)
|
||||
RETURNING id
|
||||
`, asOf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deletionworkflow: freigabeprozess: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("deletionworkflow: freigegebene id lesen: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// Destroy vernichtet EIN Objekt, das zuvor über ReleaseExpired freigegeben
|
||||
// wurde (status "expired") — kein direkter Sprung von "active".
|
||||
// Verweigert die Vernichtung, wenn ZWISCHENZEITLICH eine Sperre gesetzt
|
||||
// wurde (Verteidigung in der Tiefe, zusätzlich zu ReleaseExpireds eigenem
|
||||
// Sperr-Ausschluss). Erzeugt einen unveränderlichen Protokolleintrag
|
||||
// (destruction_log, per DB-Trigger gegen UPDATE/DELETE geschützt).
|
||||
func Destroy(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, destroyedBy string) error {
|
||||
onHold, err := IsOnLegalHold(ctx, pool, retentionObjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if onHold {
|
||||
return ErrOnLegalHold
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletionworkflow: transaktion starten: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var objectType, objectReference string
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE retention_objects SET status = 'deleted'
|
||||
WHERE id = $1 AND status = 'expired'
|
||||
RETURNING object_type, object_reference
|
||||
`, retentionObjectID).Scan(&objectType, &objectReference)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotReleased
|
||||
}
|
||||
return fmt.Errorf("deletionworkflow: objekt als vernichtet markieren: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO destruction_log (retention_object_id, object_type, object_reference, destroyed_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, retentionObjectID, objectType, objectReference, destroyedBy); err != nil {
|
||||
return fmt.Errorf("deletionworkflow: protokolleintrag erzeugen: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("deletionworkflow: vernichtung committen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package deletionworkflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupTest(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS retention_class_rules (
|
||||
retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS legal_holds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL, set_by TEXT NOT NULL, set_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
released_at TIMESTAMPTZ, released_by TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_legal_holds_active
|
||||
ON legal_holds (retention_object_id) WHERE released_at IS NULL;
|
||||
CREATE TABLE IF NOT EXISTS destruction_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE RESTRICT,
|
||||
object_type TEXT NOT NULL, object_reference TEXT NOT NULL,
|
||||
destroyed_at TIMESTAMPTZ NOT NULL DEFAULT now(), destroyed_by TEXT NOT NULL
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION destruction_log_immutable() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'destruction_log ist unveraenderlich (RET-03) - % auf id=% nicht erlaubt', TG_OP, OLD.id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
DROP TRIGGER IF EXISTS trg_destruction_log_no_update ON destruction_log;
|
||||
CREATE TRIGGER trg_destruction_log_no_update BEFORE UPDATE ON destruction_log FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable();
|
||||
DROP TRIGGER IF EXISTS trg_destruction_log_no_delete ON destruction_log;
|
||||
CREATE TRIGGER trg_destruction_log_no_delete BEFORE DELETE ON destruction_log FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable();
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE destruction_log, legal_holds, retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
func insertObject(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ref string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', $1) RETURNING id`, ref).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TestDestroy_ObjectWithActiveHoldResistsDeletion ist die geforderte
|
||||
// Pflichtprüfung 1: Objekt mit aktiver Sperre widersteht einem direkten
|
||||
// Löschversuch.
|
||||
func TestDestroy_ObjectWithActiveHoldResistsDeletion(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
objID := insertObject(t, ctx, pool, "gesperrt-doc")
|
||||
// Simuliert bereits erfolgte Freigabe (status "expired"), um zu
|
||||
// beweisen, dass die Sperre AUCH DANN noch blockiert, nicht nur vor
|
||||
// der Freigabe.
|
||||
if _, err := pool.Exec(ctx, `UPDATE retention_objects SET status = 'expired' WHERE id = $1`, objID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := SetLegalHold(ctx, pool, objID, "laufendes gerichtsverfahren az. 12/34", "admin@acme.example"); err != nil {
|
||||
t.Fatalf("sperre setzen: %v", err)
|
||||
}
|
||||
|
||||
err := Destroy(ctx, pool, objID, "worker")
|
||||
if !errors.Is(err, ErrOnLegalHold) {
|
||||
t.Fatalf("erwartet ErrOnLegalHold, habe: %v", err)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, objID).Scan(&status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "expired" {
|
||||
t.Fatalf("status haette unveraendert bleiben muessen, ist %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetLegalHold_RequiresReason ist Akzeptanzkriterium 2:
|
||||
// Begründungspflicht beim Setzen.
|
||||
func TestSetLegalHold_RequiresReason(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
objID := insertObject(t, ctx, pool, "ohne-begruendung-doc")
|
||||
|
||||
if err := SetLegalHold(ctx, pool, objID, "", "admin@acme.example"); !errors.Is(err, ErrReasonRequired) {
|
||||
t.Fatalf("erwartet ErrReasonRequired, habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDestructionLog_IsImmutable ist die geforderte Pflichtprüfung 2:
|
||||
// Protokolleintrag nach Vernichtung ist nachträglich nicht änderbar
|
||||
// (DB-Constraint/Trigger, nicht nur Anwendungslogik).
|
||||
func TestDestructionLog_IsImmutable(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
objID := insertObject(t, ctx, pool, "vernichtet-doc")
|
||||
if _, err := pool.Exec(ctx, `UPDATE retention_objects SET status = 'expired' WHERE id = $1`, objID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Destroy(ctx, pool, objID, "worker"); err != nil {
|
||||
t.Fatalf("destroy: %v", err)
|
||||
}
|
||||
|
||||
var logID string
|
||||
if err := pool.QueryRow(ctx, `SELECT id FROM destruction_log WHERE retention_object_id = $1`, objID).Scan(&logID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Direkter UPDATE-Versuch (umgeht die Go-API vollständig) — muss am
|
||||
// Postgres-Trigger scheitern, nicht nur weil das Paket keine
|
||||
// Update-Funktion anbietet.
|
||||
_, err := pool.Exec(ctx, `UPDATE destruction_log SET destroyed_by = 'manipuliert' WHERE id = $1`, logID)
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler beim direkten UPDATE auf destruction_log, trigger hat nicht gegriffen")
|
||||
}
|
||||
|
||||
_, err = pool.Exec(ctx, `DELETE FROM destruction_log WHERE id = $1`, logID)
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler beim direkten DELETE auf destruction_log, trigger hat nicht gegriffen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseLegalHold_IsItselfLogged ist die geforderte Pflichtprüfung
|
||||
// 3: Aufheben einer Sperre ist selbst protokolliert.
|
||||
func TestReleaseLegalHold_IsItselfLogged(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
objID := insertObject(t, ctx, pool, "aufgehoben-doc")
|
||||
if err := SetLegalHold(ctx, pool, objID, "vorlaeufige pruefung", "admin@acme.example"); err != nil {
|
||||
t.Fatalf("sperre setzen: %v", err)
|
||||
}
|
||||
|
||||
if err := ReleaseLegalHold(ctx, pool, objID, "admin2@acme.example"); err != nil {
|
||||
t.Fatalf("sperre aufheben: %v", err)
|
||||
}
|
||||
|
||||
var releasedBy *string
|
||||
var releasedAt *time.Time
|
||||
if err := pool.QueryRow(ctx, `SELECT released_by, released_at FROM legal_holds WHERE retention_object_id = $1`, objID).Scan(&releasedBy, &releasedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if releasedBy == nil || *releasedBy != "admin2@acme.example" || releasedAt == nil {
|
||||
t.Fatalf("aufhebung wurde nicht protokolliert: released_by=%v released_at=%v", releasedBy, releasedAt)
|
||||
}
|
||||
|
||||
onHold, err := IsOnLegalHold(ctx, pool, objID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if onHold {
|
||||
t.Fatal("objekt haette nach dem aufheben nicht mehr als gesperrt gelten duerfen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseExpired_NoImmediateDeletionAndHoldIsRespected ist
|
||||
// Akzeptanzkriterium 1 (kein Sofortlöschen, nur Statuswechsel) UND
|
||||
// Akzeptanzkriterium 2 (Sperre wirkt auch bei abgelaufener Frist).
|
||||
func TestReleaseExpired_NoImmediateDeletionAndHoldIsRespected(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_rules (retention_class, duration) VALUES ('klasse-kurz', '1 day')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dueObjID := insertObject(t, ctx, pool, "faellig-doc")
|
||||
heldObjID := insertObject(t, ctx, pool, "faellig-aber-gesperrt-doc")
|
||||
notDueObjID := insertObject(t, ctx, pool, "nicht-faellig-doc")
|
||||
|
||||
past := time.Now().UTC().Add(-48 * time.Hour)
|
||||
future := time.Now().UTC().Add(-1 * time.Hour) // faellig erst in > 1 tag
|
||||
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, dueObjID, past); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, heldObjID, past); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, notDueObjID, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetLegalHold(ctx, pool, heldObjID, "laufendes verfahren", "admin@acme.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
released, err := ReleaseExpired(ctx, pool, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("releaseexpired: %v", err)
|
||||
}
|
||||
if len(released) != 1 || released[0] != dueObjID {
|
||||
t.Fatalf("erwartet genau das faellige, ungesperrte objekt, habe: %v", released)
|
||||
}
|
||||
|
||||
var dueStatus, heldStatus, notDueStatus string
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, dueObjID).Scan(&dueStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, heldObjID).Scan(&heldStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, notDueObjID).Scan(¬DueStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1: "expired", NICHT "deleted" - keine Sofortloeschung.
|
||||
if dueStatus != "expired" {
|
||||
t.Fatalf("faelliges objekt: status = %q, want expired (keine sofortloeschung)", dueStatus)
|
||||
}
|
||||
if heldStatus != "active" {
|
||||
t.Fatalf("gesperrtes objekt haette trotz faelligkeit aktiv bleiben muessen, ist %q", heldStatus)
|
||||
}
|
||||
if notDueStatus != "active" {
|
||||
t.Fatalf("nicht faelliges objekt haette aktiv bleiben muessen, ist %q", notDueStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDestroy_RequiresPriorRelease beweist, dass Destroy nicht direkt von
|
||||
// "active" aus aufgerufen werden kann (Workflow-Reihenfolge erzwungen).
|
||||
func TestDestroy_RequiresPriorRelease(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
objID := insertObject(t, ctx, pool, "noch-aktiv-doc")
|
||||
|
||||
if err := Destroy(ctx, pool, objID, "worker"); !errors.Is(err, ErrNotReleased) {
|
||||
t.Fatalf("erwartet ErrNotReleased, habe: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package dpreport implementiert CMP-02: Auskunftsberichte und
|
||||
// Verarbeitungsübersichten für DSGVO-Zwecke. Baut ausschließlich auf
|
||||
// RET-01 (retention_objects, retention_class_assignments) auf, keine
|
||||
// eigene Speicherung. Läuft immer gegen GENAU EINE Tenant-Datenbank
|
||||
// (Modell C) — Mandantentrennung (Akzeptanzkriterium 3) ist dadurch
|
||||
// strukturell garantiert, nicht durch eine zusätzliche Filterbedingung:
|
||||
// ein Aufruf gegen den Pool von Tenant A kann Tenant Bs Daten technisch
|
||||
// nicht erreichen, da sie in einer physisch getrennten Datenbank liegen.
|
||||
package dpreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SubjectRecord ist EIN gespeichertes Objekt einer betroffenen Person
|
||||
// (Akzeptanzkriterium 1).
|
||||
type SubjectRecord struct {
|
||||
ObjectType string
|
||||
ObjectReference string
|
||||
RetentionClass string
|
||||
Status string
|
||||
RegisteredAt time.Time
|
||||
}
|
||||
|
||||
// SubjectReport liefert ALLE gespeicherten Objekte einer betroffenen
|
||||
// Person mit ihrer jeweils aktuellen Aufbewahrungsklasse
|
||||
// (Akzeptanzkriterium 1) — nutzt dieselbe "jüngste Zuordnung"-Logik wie
|
||||
// RET-02s ListExpiringObjects (DISTINCT ON), keine zweite Berechnung.
|
||||
func SubjectReport(ctx context.Context, pool *pgxpool.Pool, dataSubjectRef string) ([]SubjectRecord, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
WITH latest_assignment AS (
|
||||
SELECT DISTINCT ON (retention_object_id)
|
||||
retention_object_id, retention_class
|
||||
FROM retention_class_assignments
|
||||
ORDER BY retention_object_id, assigned_at DESC
|
||||
)
|
||||
SELECT o.object_type, o.object_reference,
|
||||
COALESCE(a.retention_class, ''), o.status, o.created_at
|
||||
FROM retention_objects o
|
||||
LEFT JOIN latest_assignment a ON a.retention_object_id = o.id
|
||||
WHERE o.data_subject_ref = $1
|
||||
ORDER BY o.created_at ASC
|
||||
`, dataSubjectRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dpreport: auskunftsbericht abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []SubjectRecord
|
||||
for rows.Next() {
|
||||
var r SubjectRecord
|
||||
if err := rows.Scan(&r.ObjectType, &r.ObjectReference, &r.RetentionClass, &r.Status, &r.RegisteredAt); err != nil {
|
||||
return nil, fmt.Errorf("dpreport: zeile lesen: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ProcessingEntry beschreibt Zweck und Rechtsgrundlage EINES Objekttyps
|
||||
// (Akzeptanzkriterium 2). Statisch gepflegt, da Zweck/Rechtsgrundlage
|
||||
// Rechtsbewertungen sind, keine aus Nutzdaten ableitbaren Werte — neue
|
||||
// Objekttypen ergänzen diese Liste, ändern kein bestehendes Verhalten.
|
||||
type ProcessingEntry struct {
|
||||
ObjectType string
|
||||
Purpose string
|
||||
LegalBasis string
|
||||
}
|
||||
|
||||
// ProcessingPurposes ist die je Objekttyp gepflegte Verarbeitungs-
|
||||
// übersicht. Unbekannte Objekttypen (noch nicht hier eingetragen)
|
||||
// liefert ProcessingOverview mit einem expliziten Platzhalter statt sie
|
||||
// stillschweigend wegzulassen (Prüfung: vollständige Übersicht).
|
||||
var ProcessingPurposes = map[string]ProcessingEntry{
|
||||
"dms_document": {
|
||||
ObjectType: "dms_document",
|
||||
Purpose: "Dokumentenverwaltung und -archivierung im Geschäftsbetrieb",
|
||||
LegalBasis: "Art. 6 Abs. 1 lit. b/c DSGVO (Vertragserfüllung / rechtliche Verpflichtung, GoBD)",
|
||||
},
|
||||
"mail_message": {
|
||||
ObjectType: "mail_message",
|
||||
Purpose: "Revisionssichere E-Mail-Archivierung",
|
||||
LegalBasis: "Art. 6 Abs. 1 lit. c DSGVO (rechtliche Verpflichtung, GoBD/HGB)",
|
||||
},
|
||||
}
|
||||
|
||||
// ProcessingOverview liefert die Verarbeitungsübersicht für alle im
|
||||
// Tenant TATSÄCHLICH vorkommenden Objekttypen (Akzeptanzkriterium 2).
|
||||
func ProcessingOverview(ctx context.Context, pool *pgxpool.Pool) ([]ProcessingEntry, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT DISTINCT object_type FROM retention_objects ORDER BY object_type`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dpreport: objekttypen abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ProcessingEntry
|
||||
for rows.Next() {
|
||||
var objectType string
|
||||
if err := rows.Scan(&objectType); err != nil {
|
||||
return nil, fmt.Errorf("dpreport: objekttyp lesen: %w", err)
|
||||
}
|
||||
entry, known := ProcessingPurposes[objectType]
|
||||
if !known {
|
||||
entry = ProcessingEntry{ObjectType: objectType, Purpose: "unbekannt (nicht gepflegt)", LegalBasis: "unbekannt (nicht gepflegt)"}
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WriteSubjectReportCSV exportiert einen Auskunftsbericht als CSV
|
||||
// (Akzeptanzkriterium/Pflichtprüfung 3: weiterverarbeitbar).
|
||||
func WriteSubjectReportCSV(w io.Writer, records []SubjectRecord) error {
|
||||
cw := csv.NewWriter(w)
|
||||
if err := cw.Write([]string{"object_type", "object_reference", "retention_class", "status", "registered_at"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range records {
|
||||
if err := cw.Write([]string{
|
||||
r.ObjectType, r.ObjectReference, r.RetentionClass, r.Status, r.RegisteredAt.Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package dpreport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/retention"
|
||||
)
|
||||
|
||||
func setupTest(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
return setupTestWithDSN(t, dsn)
|
||||
}
|
||||
|
||||
func setupTestWithDSN(t *testing.T, dsn string) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE IF NOT EXISTS retention_objects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (object_type, object_reference)
|
||||
);
|
||||
ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS data_subject_ref TEXT;
|
||||
CREATE TABLE IF NOT EXISTS retention_class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
// TestSubjectReport_MatchesKnownDataset ist die geforderte Pflichtprüfung
|
||||
// 1: Auskunftsbericht für Testperson mit bekanntem Datenbestand stimmt
|
||||
// mit erwarteter Liste überein.
|
||||
func TestSubjectReport_MatchesKnownDataset(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id1, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "doc-1", "person-a@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := retention.AssignClass(ctx, pool, id1, "klasse-x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "doc-2", "person-b@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := retention.RegisterObjectForSubject(ctx, pool, "mail_message", "mail-1", "person-a@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := SubjectReport(ctx, pool, "person-a@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("subjectreport: %v", err)
|
||||
}
|
||||
if len(report) != 2 {
|
||||
t.Fatalf("erwartet 2 objekte fuer person-a, habe %d: %+v", len(report), report)
|
||||
}
|
||||
refs := map[string]bool{}
|
||||
for _, r := range report {
|
||||
refs[r.ObjectReference] = true
|
||||
}
|
||||
if !refs["doc-1"] || !refs["mail-1"] {
|
||||
t.Fatalf("erwartete objekte fehlen: %+v", report)
|
||||
}
|
||||
if refs["doc-2"] {
|
||||
t.Fatal("doc-2 gehoert person-b, nicht person-a - darf nicht im bericht auftauchen")
|
||||
}
|
||||
for _, r := range report {
|
||||
if r.ObjectReference == "doc-1" && r.RetentionClass != "klasse-x" {
|
||||
t.Fatalf("erwartet klasse-x fuer doc-1, habe %q", r.RetentionClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubjectReport_TenantIsolation ist die geforderte Pflichtprüfung 2:
|
||||
// Bericht für einen Tenant enthält keine Objekte eines anderen Tenants.
|
||||
// Da SubjectReport IMMER gegen genau einen Tenant-Pool laeuft (Modell C),
|
||||
// wird dies strukturell bewiesen: ein zweiter, PHYSISCH GETRENNTER Pool
|
||||
// (eigene Datenbank, TEST_TENANT_DSN_B) kann die Zeilen des ersten
|
||||
// technisch nicht sehen. Braucht eine echte zweite Tenant-DB, nicht nur
|
||||
// denselben TEST_TENANT_DSN zweimal (sonst ist es dieselbe physische
|
||||
// Datenbank und der Test beweist nichts über echte Mandantentrennung).
|
||||
func TestSubjectReport_TenantIsolation(t *testing.T) {
|
||||
dsnB := os.Getenv("TEST_TENANT_DSN_B")
|
||||
if dsnB == "" {
|
||||
t.Skip("TEST_TENANT_DSN_B nicht gesetzt - Test braucht eine ECHTE zweite, physisch getrennte Tenant-Datenbank")
|
||||
}
|
||||
poolA := setupTest(t)
|
||||
poolB := setupTestWithDSN(t, dsnB)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := retention.RegisterObjectForSubject(ctx, poolA, "dms_document", "tenant-a-doc", "shared-person@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reportB, err := SubjectReport(ctx, poolB, "shared-person@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("subjectreport (tenant b): %v", err)
|
||||
}
|
||||
if len(reportB) != 0 {
|
||||
t.Fatalf("tenant b darf tenant as objekte nicht sehen, habe: %+v", reportB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteSubjectReportCSV_IsParseable ist die geforderte Pflichtprüfung
|
||||
// 3: Export lässt sich als CSV weiterverarbeiten.
|
||||
func TestWriteSubjectReportCSV_IsParseable(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
if _, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "csv-doc", "csv-person@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := SubjectReport(ctx, pool, "csv-person@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := WriteSubjectReportCSV(&buf, report); err != nil {
|
||||
t.Fatalf("csv schreiben: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "object_type,object_reference") {
|
||||
t.Fatalf("erwartet csv-header, habe: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "csv-doc") {
|
||||
t.Fatalf("erwartet datenzeile mit csv-doc, habe: %q", out)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("erwartet header + 1 datenzeile, habe %d zeilen: %q", len(lines), out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessingOverview_CoversPresentObjectTypes deckt Akzeptanzkriterium 2.
|
||||
func TestProcessingOverview_CoversPresentObjectTypes(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
if _, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "overview-doc", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
overview, err := ProcessingOverview(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(overview) != 1 || overview[0].ObjectType != "dms_document" {
|
||||
t.Fatalf("erwartet genau dms_document, habe: %+v", overview)
|
||||
}
|
||||
if overview[0].Purpose == "" || overview[0].LegalBasis == "" {
|
||||
t.Fatalf("zweck/rechtsgrundlage fehlen: %+v", overview[0])
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,28 @@ func RegisterObject(ctx context.Context, pool *pgxpool.Pool, objectType, objectR
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// RegisterObjectForSubject ist CMP-02s additive Ergänzung zu
|
||||
// RegisterObject: registriert das Objekt zusätzlich mit einer Referenz
|
||||
// auf die betroffene Person (dataSubjectRef, z. B. E-Mail oder
|
||||
// User-ID), Grundlage für den DSGVO-Auskunftsbericht. Leeres
|
||||
// dataSubjectRef bedeutet: nicht personenbezogen, kein Fehler.
|
||||
// RegisterObject selbst bleibt unverändert (kein Umbau bestehenden
|
||||
// Verhaltens) — dies ist ein separater, additiver Registrierungsweg.
|
||||
func RegisterObjectForSubject(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference, dataSubjectRef string) (string, error) {
|
||||
var id string
|
||||
err := pool.QueryRow(ctx, `
|
||||
INSERT INTO retention_objects (object_type, object_reference, data_subject_ref)
|
||||
VALUES ($1, $2, NULLIF($3, ''))
|
||||
ON CONFLICT (object_type, object_reference)
|
||||
DO UPDATE SET data_subject_ref = COALESCE(NULLIF(EXCLUDED.data_subject_ref, ''), retention_objects.data_subject_ref)
|
||||
RETURNING id
|
||||
`, objectType, objectReference, dataSubjectRef).Scan(&id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("retention: objekt mit betroffener person registrieren: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Assignment ist EINE historische Zuordnung einer Aufbewahrungsklasse.
|
||||
type Assignment struct {
|
||||
RetentionClass string
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package wormstore implementiert RET-04: einen Speicherpfad für
|
||||
// archivierte Objektinhalte, der nach dem Schreiben weder überschrieben
|
||||
// noch (über dieses Paket) gelöscht werden kann — Write Once, Read Many.
|
||||
//
|
||||
// Löschung: dieses Paket bietet BEWUSST KEINE Delete-Funktion — das ist
|
||||
// die technische Grenze, die Go-Anwendungscode überhaupt durchsetzen
|
||||
// kann. Direkter Zugriff auf das Dateisystem durch einen Prozess mit
|
||||
// root-Rechten bleibt außerhalb der Kontrolle jeder Anwendung; das ist
|
||||
// ein struktureller Schutz auf Anwendungsebene, kein absoluter Schutz
|
||||
// gegen root-OS-Zugriff (siehe RET-04-Prüfprotokoll). Der kontrollierte
|
||||
// Löschworkflow (RET-03, eigenes Ticket) bekommt bei Bedarf einen
|
||||
// separaten, explizit privilegierten Zugriffspfad — NICHT Teil dieses
|
||||
// Pakets.
|
||||
package wormstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrAlreadyExists wird geliefert, wenn unter demselben Schlüssel bereits
|
||||
// ein Objekt geschrieben wurde (Akzeptanzkriterium 1: kein Überschreiben).
|
||||
var ErrAlreadyExists = errors.New("wormstore: objekt existiert bereits, überschreiben nicht erlaubt")
|
||||
|
||||
// ErrNotFound wird geliefert, wenn ein angefragtes Objekt nicht existiert.
|
||||
var ErrNotFound = errors.New("wormstore: objekt nicht gefunden")
|
||||
|
||||
// ErrChecksumMismatch wird von GetVerified geliefert, wenn der beim Lesen
|
||||
// berechnete Hash nicht mit dem beim Schreiben gespeicherten übereinstimmt
|
||||
// (Akzeptanzkriterium 3: manipulierter Inhalt wird erkannt).
|
||||
var ErrChecksumMismatch = errors.New("wormstore: prüfsumme stimmt nicht überein — inhalt wurde verändert")
|
||||
|
||||
// Store ist der WORM-Speicher: Dateien im lokalen Dateisystem, Metadaten
|
||||
// (Prüfsumme, Größe) in Postgres — beide append-only.
|
||||
type Store struct {
|
||||
baseDir string
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(baseDir string, pool *pgxpool.Pool) *Store {
|
||||
return &Store{baseDir: baseDir, pool: pool}
|
||||
}
|
||||
|
||||
func (s *Store) path(key string) string {
|
||||
return filepath.Join(s.baseDir, filepath.FromSlash(key))
|
||||
}
|
||||
|
||||
// Put schreibt r EINMALIG unter key. Existiert key bereits (Datei ODER
|
||||
// DB-Zeile), wird ErrAlreadyExists geliefert — kein Überschreiben, auch
|
||||
// nicht bei gleichem Inhalt. Nach erfolgreichem Schreiben wird die Datei
|
||||
// read-only gesetzt (0400): ein direkter os.OpenFile mit Schreibabsicht
|
||||
// schlägt danach mit "permission denied" fehl (Akzeptanzkriterium 1,
|
||||
// zusätzlich zur DB-seitigen Sperre über den PRIMARY KEY).
|
||||
func (s *Store) Put(ctx context.Context, key string, r io.Reader) (checksum string, size int64, err error) {
|
||||
full := s.path(key)
|
||||
if _, statErr := os.Stat(full); statErr == nil {
|
||||
return "", 0, ErrAlreadyExists
|
||||
} else if !os.IsNotExist(statErr) {
|
||||
return "", 0, fmt.Errorf("wormstore: existenz prüfen: %w", statErr)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return "", 0, fmt.Errorf("wormstore: verzeichnis anlegen: %w", err)
|
||||
}
|
||||
|
||||
tmp := full + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("wormstore: temporäre datei anlegen: %w", err)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(f, hasher), r)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return "", 0, fmt.Errorf("wormstore: schreiben: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", 0, fmt.Errorf("wormstore: datei schließen: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, full); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", 0, fmt.Errorf("wormstore: atomar platzieren: %w", err)
|
||||
}
|
||||
// Read-only NACH dem Schließen — verhindert jedes weitere Schreiben
|
||||
// (Akzeptanzkriterium 1), auch durch denselben Prozess.
|
||||
if err := os.Chmod(full, 0o400); err != nil {
|
||||
return "", 0, fmt.Errorf("wormstore: schreibschutz setzen: %w", err)
|
||||
}
|
||||
|
||||
sum := hex.EncodeToString(hasher.Sum(nil))
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO worm_objects (storage_key, checksum_sha256, size_bytes)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (storage_key) DO NOTHING
|
||||
`, key, sum, written)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("wormstore: metadaten speichern: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
// Datei wurde gerade erst neu geschrieben (s.o. Stat-Check), aber
|
||||
// die Metadaten-Zeile existierte bereits — Wettlaufsituation
|
||||
// zweier gleichzeitiger Put-Aufrufe. Kein Überschreiben zulassen.
|
||||
return "", 0, ErrAlreadyExists
|
||||
}
|
||||
|
||||
return sum, written, nil
|
||||
}
|
||||
|
||||
// GetVerified liefert den Inhalt UND vergleicht bei JEDEM Lesezugriff die
|
||||
// Prüfsumme gegen die beim Schreiben gespeicherte (Akzeptanzkriterium 3).
|
||||
func (s *Store) GetVerified(ctx context.Context, key string) ([]byte, error) {
|
||||
var expectedSum string
|
||||
err := s.pool.QueryRow(ctx, `SELECT checksum_sha256 FROM worm_objects WHERE storage_key = $1`, key).Scan(&expectedSum)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("wormstore: metadaten lesen: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(s.path(key))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("wormstore: datei lesen: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
actualSum := hex.EncodeToString(sum[:])
|
||||
if actualSum != expectedSum {
|
||||
return nil, ErrChecksumMismatch
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package wormstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupTest(t *testing.T) (*Store, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS worm_objects (
|
||||
storage_key TEXT PRIMARY KEY, checksum_sha256 TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL, written_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM worm_objects WHERE storage_key LIKE 'wormtest_%'`)
|
||||
})
|
||||
|
||||
return New(t.TempDir(), pool), pool
|
||||
}
|
||||
|
||||
// TestPut_DirectOverwriteAttemptIsRejected ist die geforderte
|
||||
// Pflichtprüfung 1: Objekt mit aktivem Schreibschutz widersteht einem
|
||||
// direkten Überschreibversuch — sowohl über die Store-API als auch
|
||||
// direkt am Dateisystem (echter Nachweis der Datei-Schreibsperre, nicht
|
||||
// nur der Anwendungslogik).
|
||||
func TestPut_DirectOverwriteAttemptIsRejected(t *testing.T) {
|
||||
store, _ := setupTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sum1, size1, err := store.Put(ctx, "wormtest_a", strings.NewReader("erster inhalt"))
|
||||
if err != nil {
|
||||
t.Fatalf("erster put: %v", err)
|
||||
}
|
||||
|
||||
// Überschreibversuch über die API.
|
||||
_, _, err = store.Put(ctx, "wormtest_a", strings.NewReader("manipulierter inhalt"))
|
||||
if !errors.Is(err, ErrAlreadyExists) {
|
||||
t.Fatalf("erwartet ErrAlreadyExists beim zweiten put, habe: %v", err)
|
||||
}
|
||||
|
||||
// Direkter Überschreibversuch am Dateisystem (umgeht die API) — muss
|
||||
// an der echten OS-Schreibsperre (chmod 0400) scheitern. Nur
|
||||
// aussagekräftig, wenn NICHT als root getestet wird: root umgeht
|
||||
// Unix-Dateirechte grundsätzlich (DAC-Bypass), das ist kein Defekt
|
||||
// dieses Pakets, sondern eine bekannte Grenze von chmod-basiertem
|
||||
// Schutz (siehe Paket-Dokumentation "kein absoluter Schutz gegen
|
||||
// root"). Auf 131 laufen Tests als root — dieser Teilnachweis wird
|
||||
// dort bewusst übersprungen, die API-seitige Sperre (oben) bleibt
|
||||
// die primäre, root-unabhängige Prüfung.
|
||||
if os.Geteuid() != 0 {
|
||||
full := store.path("wormtest_a")
|
||||
f, openErr := os.OpenFile(full, os.O_WRONLY, 0)
|
||||
if openErr == nil {
|
||||
_ = f.Close()
|
||||
t.Fatal("erwartet permission-fehler beim direkten öffnen zum schreiben, datei war schreibbar")
|
||||
}
|
||||
if !os.IsPermission(openErr) {
|
||||
t.Fatalf("erwartet permission-fehler, habe: %v", openErr)
|
||||
}
|
||||
} else {
|
||||
t.Log("laeuft als root: OS-Schreibsperren-Teilnachweis uebersprungen (root umgeht Unix-Dateirechte grundsaetzlich)")
|
||||
}
|
||||
|
||||
// Ursprünglicher Inhalt unverändert.
|
||||
data, err := store.GetVerified(ctx, "wormtest_a")
|
||||
if err != nil {
|
||||
t.Fatalf("getverified: %v", err)
|
||||
}
|
||||
if string(data) != "erster inhalt" {
|
||||
t.Fatalf("inhalt wurde veraendert: %q", data)
|
||||
}
|
||||
if size1 != int64(len("erster inhalt")) || sum1 == "" {
|
||||
t.Fatalf("unerwartete put-metadaten: sum=%q size=%d", sum1, size1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetVerified_DetectsTamperedFile ist die geforderte Pflichtprüfung
|
||||
// 2: Prüfsummenabgleich erkennt eine manipulierte Testdatei zuverlässig.
|
||||
func TestGetVerified_DetectsTamperedFile(t *testing.T) {
|
||||
store, _ := setupTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, _, err := store.Put(ctx, "wormtest_b", strings.NewReader("unveraenderter inhalt")); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
|
||||
// Manipulation simulieren: Schreibschutz temporär aufheben (root/
|
||||
// Betreiber-Zugriff außerhalb der API, genau der Fall, den die
|
||||
// Prüfsumme abfangen soll) und den Inhalt verändern.
|
||||
full := store.path("wormtest_b")
|
||||
if err := os.Chmod(full, 0o600); err != nil {
|
||||
t.Fatalf("chmod fuer testmanipulation: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("MANIPULIERTER INHALT"), 0o600); err != nil {
|
||||
t.Fatalf("testmanipulation schreiben: %v", err)
|
||||
}
|
||||
|
||||
_, err := store.GetVerified(ctx, "wormtest_b")
|
||||
if !errors.Is(err, ErrChecksumMismatch) {
|
||||
t.Fatalf("erwartet ErrChecksumMismatch bei manipulierter datei, habe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_HasNoDeleteMethod ist die geforderte Pflichtprüfung 3
|
||||
// (präzisiert): Löschung ist über die Go-API des Pakets technisch
|
||||
// unmöglich, weil KEINE Delete-Funktion existiert — strukturell per
|
||||
// Reflection nachgewiesen, kein bloßer Verzicht per Konvention/Kommentar.
|
||||
func TestStore_HasNoDeleteMethod(t *testing.T) {
|
||||
storeType := reflect.TypeOf(&Store{})
|
||||
for i := 0; i < storeType.NumMethod(); i++ {
|
||||
name := storeType.Method(i).Name
|
||||
if strings.Contains(strings.ToLower(name), "delete") || strings.Contains(strings.ToLower(name), "remove") {
|
||||
t.Fatalf("wormstore.Store darf keine Lösch-Methode besitzen, gefunden: %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS worm_objects;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- RET-04: WORM-Speicher-Garantie. Append-only Metadatentabelle - kein
|
||||
-- UPDATE-Pfad im Code, PRIMARY KEY(storage_key) verhindert eine zweite
|
||||
-- Zeile fuer denselben Schluessel (Akzeptanzkriterium 1, zusaetzlich zur
|
||||
-- Dateisystem-Schreibsperre).
|
||||
CREATE TABLE IF NOT EXISTS worm_objects (
|
||||
storage_key TEXT PRIMARY KEY,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
written_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP TRIGGER IF EXISTS trg_destruction_log_no_delete ON destruction_log;
|
||||
DROP TRIGGER IF EXISTS trg_destruction_log_no_update ON destruction_log;
|
||||
DROP FUNCTION IF EXISTS destruction_log_immutable();
|
||||
DROP TABLE IF EXISTS destruction_log;
|
||||
DROP TABLE IF EXISTS legal_holds;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- RET-03: Löschworkflow & Aufbewahrungssperre (Legal Hold).
|
||||
--
|
||||
-- legal_holds ist historisiert (kein UPDATE der Sperr-Zeile selbst
|
||||
-- ausser released_at/released_by beim Aufheben) — jede Sperre und jede
|
||||
-- Aufhebung bleibt dauerhaft nachvollziehbar (Akzeptanzkriterium 3:
|
||||
-- Aufheben ist selbst protokolliert). Ein Partial-Unique-Index erlaubt
|
||||
-- je Objekt hoechstens EINE AKTIVE Sperre gleichzeitig.
|
||||
CREATE TABLE IF NOT EXISTS legal_holds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL,
|
||||
set_by TEXT NOT NULL,
|
||||
set_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
released_at TIMESTAMPTZ,
|
||||
released_by TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_legal_holds_active
|
||||
ON legal_holds (retention_object_id) WHERE released_at IS NULL;
|
||||
|
||||
-- destruction_log ist APPEND-ONLY, real durchgesetzt ueber Trigger (nicht
|
||||
-- nur Anwendungscode) — Akzeptanzkriterium 3 / Pflichtpruefung 2 verlangt
|
||||
-- ausdruecklich "DB-Constraint oder Trigger".
|
||||
CREATE TABLE IF NOT EXISTS destruction_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE RESTRICT,
|
||||
object_type TEXT NOT NULL,
|
||||
object_reference TEXT NOT NULL,
|
||||
destroyed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
destroyed_by TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION destruction_log_immutable() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'destruction_log ist unveraenderlich (RET-03) - % auf id=% nicht erlaubt', TG_OP, OLD.id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_destruction_log_no_update ON destruction_log;
|
||||
CREATE TRIGGER trg_destruction_log_no_update
|
||||
BEFORE UPDATE ON destruction_log
|
||||
FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_destruction_log_no_delete ON destruction_log;
|
||||
CREATE TRIGGER trg_destruction_log_no_delete
|
||||
BEFORE DELETE ON destruction_log
|
||||
FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable();
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_retention_objects_data_subject_ref;
|
||||
ALTER TABLE retention_objects DROP COLUMN IF EXISTS data_subject_ref;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- CMP-02: DSGVO-Auskunftsberichte brauchen eine Zuordnung Objekt->
|
||||
-- betroffene Person. RET-01s retention_objects kannte bislang nur
|
||||
-- object_type/object_reference (opak, modulübergreifend), keine
|
||||
-- Person-Referenz. Additive, nullable Spalte — bestehende Zeilen und
|
||||
-- Aufrufer von RegisterObject bleiben unverändert gültig: NICHT jedes
|
||||
-- Objekt ist personenbezogen (z. B. Systemkonfiguration), ein leeres
|
||||
-- Feld bedeutet genau das, nicht einen Fehler.
|
||||
ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS data_subject_ref TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_retention_objects_data_subject_ref
|
||||
ON retention_objects (data_subject_ref) WHERE data_subject_ref IS NOT NULL;
|
||||
Reference in New Issue
Block a user