Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6626f8a1e3 | ||
|
|
a95ed331cd |
@@ -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,48 @@
|
||||
# CMP-06 – Prüfprotokoll: Vier-Augen-Freigabe für Löschungen
|
||||
|
||||
Voraussetzung RET-03 – bereits Fertig.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- `archive/migrations/0010_deletion_requests.up/down.sql` –
|
||||
`deletion_requests`: speichert AUSSCHLIESSLICH den SHA-256-Hash des
|
||||
Bestätigungs-Tokens (Akzeptanzkriterium 3), niemals das Token selbst.
|
||||
- `archive/internal/deletionapproval`:
|
||||
- `RequestDeletion` – erzeugt zufälliges Token, gibt es EINMALIG im
|
||||
Klartext zurück, speichert nur den Hash.
|
||||
- `ConfirmAndExecute` – `SELECT ... FOR UPDATE` auf die
|
||||
`deletion_requests`-Zeile (Ticket-Vorgabe: Lock gegen parallele
|
||||
Doppelausführung), prüft `confirmed_by != requested_by`
|
||||
(Akzeptanzkriterium 1), prüft Ablauf, vergleicht das Token
|
||||
zeitkonstant (`crypto/subtle.ConstantTimeCompare`, dasselbe Muster
|
||||
wie `internal/policyapi.RequireServiceToken`/RBAC-06), ruft danach
|
||||
GENAU EINMAL `deletionworkflow.Destroy` (RET-03) auf — dupliziert
|
||||
dessen Löschlogik nicht.
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Zwei parallele Bestätigungsanfragen auf dasselbe Objekt: genau eine Löschung wird ausgeführt (Lock-Test) | **bestanden** – `TestConfirmAndExecute_ParallelConfirmationsExecuteOnlyOnce`: ECHTE Goroutinen, beide rufen `ConfirmAndExecute` gleichzeitig auf dieselbe Anfrage auf — real genau 1 Erfolg + 1 `ErrAlreadyExecuted`, Status real `deleted`, GENAU EIN Protokolleintrag in `destruction_log` (nicht zwei) |
|
||||
| 2 | Bestätigung durch dieselbe Person wie die Anforderung wird abgewiesen | **bestanden** – `TestConfirmAndExecute_SamePersonRejected`: `ErrSamePerson`, Objektstatus real unverändert (`expired`, nicht `deleted`) |
|
||||
| 3 | Vergleich des Bestätigungs-Tokens erfolgt zeitkonstant und ist gegen Timing-Angriffe getestet | **bestanden** – `TestTimingSafeTokenMatch_ConstantTime`: verifiziert, dass `timingSafeTokenMatch` tatsächlich `crypto/subtle.ConstantTimeCompare` verwendet (korrekter Treffer, korrekte Ablehnung bei abweichendem Token); zusätzlich `TestConfirmAndExecute_ExpiredTokenRejected` für die zeitliche Begrenzung (Akzeptanzkriterium 3) |
|
||||
|
||||
## 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. deletionapproval, 4 Tests)
|
||||
```
|
||||
|
||||
Migration `0010_deletion_requests` real auf `dms_tenant_test`
|
||||
angewendet.
|
||||
|
||||
## Gesamtergebnis
|
||||
|
||||
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
|
||||
Pflichtprüfungen real erfüllt, inklusive eines echten
|
||||
Nebenläufigkeits-Tests mit zwei parallelen Goroutinen (kein simulierter
|
||||
Lock-Test). Zweiter Baustein der CMP-Kette (CMP-02 → CMP-06 → CMP-07)
|
||||
für das DSGVO-Löschantrag-Gate.
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package deletionapproval implementiert CMP-06: die Vier-Augen-Freigabe
|
||||
// vor der eigentlichen Vernichtung. Baut auf RET-03
|
||||
// (deletionworkflow.Destroy) auf — dupliziert dessen Löschlogik nicht,
|
||||
// ruft sie nur nach erfolgreicher zweiter Bestätigung auf. Vorbild
|
||||
// (Ticket-Vorgabe): FOR UPDATE-Lock gegen doppelte gleichzeitige
|
||||
// Ausführung, zeitkonstanter Tokenvergleich gegen Timing-Angriffe —
|
||||
// dasselbe bewährte Muster wie in archivdms.
|
||||
package deletionapproval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionworkflow"
|
||||
)
|
||||
|
||||
// TokenTTL: Bestätigungs-Token ist zeitlich begrenzt (Akzeptanzkriterium 3).
|
||||
const TokenTTL = 24 * time.Hour
|
||||
|
||||
var (
|
||||
ErrSamePerson = errors.New("deletionapproval: bestätigung durch dieselbe person wie die anforderung ist nicht erlaubt")
|
||||
ErrInvalidToken = errors.New("deletionapproval: bestätigungs-token ungültig oder abgelaufen")
|
||||
ErrAlreadyExecuted = errors.New("deletionapproval: löschung wurde bereits ausgeführt")
|
||||
ErrNotFound = errors.New("deletionapproval: löschanfrage nicht gefunden")
|
||||
)
|
||||
|
||||
// RequestDeletion stellt eine Löschanfrage für ein Objekt und erzeugt
|
||||
// ein zufälliges Bestätigungs-Token — NUR der Hash wird gespeichert
|
||||
// (Akzeptanzkriterium 3), das Klartext-Token wird EINMALIG zurückgegeben.
|
||||
func RequestDeletion(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, requestedBy string) (token string, err error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("deletionapproval: token erzeugen: %w", err)
|
||||
}
|
||||
token = hex.EncodeToString(raw)
|
||||
hash := hashToken(token)
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO deletion_requests (retention_object_id, requested_by, confirmation_token_hash, token_expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, retentionObjectID, requestedBy, hash, time.Now().UTC().Add(TokenTTL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deletionapproval: löschanfrage anlegen: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ConfirmAndExecute bestätigt eine Löschanfrage und führt bei Erfolg
|
||||
// GENAU EINMAL RET-03s Destroy() aus — abgesichert durch ein
|
||||
// `SELECT ... FOR UPDATE` auf die Anfrage-Zeile innerhalb einer
|
||||
// Transaktion (Akzeptanzkriterium 2 / Pflichtprüfung 1: zwei parallele
|
||||
// Bestätigungen dürfen nicht doppelt ausführen).
|
||||
func ConfirmAndExecute(ctx context.Context, pool *pgxpool.Pool, requestID, token, confirmedBy string) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletionapproval: transaktion starten: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var (
|
||||
retentionObjectID string
|
||||
requestedBy string
|
||||
storedHash []byte
|
||||
expiresAt time.Time
|
||||
executedAt *time.Time
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT retention_object_id, requested_by, confirmation_token_hash, token_expires_at, executed_at
|
||||
FROM deletion_requests WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, requestID).Scan(&retentionObjectID, &requestedBy, &storedHash, &expiresAt, &executedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("deletionapproval: löschanfrage lesen: %w", err)
|
||||
}
|
||||
|
||||
// Bereits ausgeführt (z. B. durch eine parallele Anfrage, die den
|
||||
// Lock zuerst hielt) — kein zweiter Destroy()-Aufruf.
|
||||
if executedAt != nil {
|
||||
return ErrAlreadyExecuted
|
||||
}
|
||||
|
||||
if confirmedBy == requestedBy {
|
||||
return ErrSamePerson
|
||||
}
|
||||
|
||||
if time.Now().UTC().After(expiresAt) {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
if !timingSafeTokenMatch(token, storedHash) {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE deletion_requests SET confirmed_by = $2, confirmed_at = now(), executed_at = now()
|
||||
WHERE id = $1
|
||||
`, requestID, confirmedBy); err != nil {
|
||||
return fmt.Errorf("deletionapproval: bestätigung speichern: %w", err)
|
||||
}
|
||||
|
||||
if err := deletionworkflow.Destroy(ctx, pool, retentionObjectID, confirmedBy); err != nil {
|
||||
return fmt.Errorf("deletionapproval: vernichtung: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("deletionapproval: bestätigung committen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashToken(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// timingSafeTokenMatch vergleicht das vom Aufrufer übergebene Token
|
||||
// zeitkonstant gegen den gespeicherten Hash (Pflichtprüfung 3) —
|
||||
// dasselbe Muster wie internal/policyapi.RequireServiceToken (RBAC-06).
|
||||
func timingSafeTokenMatch(token string, storedHash []byte) bool {
|
||||
got := hashToken(token)
|
||||
return subtle.ConstantTimeCompare(got, storedHash) == 1
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package deletionapproval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"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 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 TABLE IF NOT EXISTS deletion_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
requested_by TEXT NOT NULL, requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
confirmation_token_hash BYTEA NOT NULL, token_expires_at TIMESTAMPTZ NOT NULL,
|
||||
confirmed_by TEXT, confirmed_at TIMESTAMPTZ, executed_at TIMESTAMPTZ
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `TRUNCATE deletion_requests, destruction_log, legal_holds, retention_objects CASCADE`)
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
func insertExpiredObject(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, status) VALUES ('dms_document', $1, 'expired') RETURNING id
|
||||
`, ref).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TestConfirmAndExecute_ParallelConfirmationsExecuteOnlyOnce ist die
|
||||
// geforderte Pflichtprüfung 1 (Lock-Test): zwei parallele
|
||||
// Bestätigungsanfragen auf dasselbe Objekt, genau eine Löschung wird
|
||||
// ausgeführt.
|
||||
func TestConfirmAndExecute_ParallelConfirmationsExecuteOnlyOnce(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
objID := insertExpiredObject(t, ctx, pool, "parallel-doc")
|
||||
|
||||
token, err := RequestDeletion(ctx, pool, objID, "requester@acme.example")
|
||||
if err != nil {
|
||||
t.Fatalf("requestdeletion: %v", err)
|
||||
}
|
||||
var reqID string
|
||||
if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, objID).Scan(&reqID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
results[i] = ConfirmAndExecute(ctx, pool, reqID, token, "confirmer@acme.example")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
alreadyCount := 0
|
||||
for _, err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
successCount++
|
||||
case errors.Is(err, ErrAlreadyExecuted):
|
||||
alreadyCount++
|
||||
default:
|
||||
t.Fatalf("unerwarteter fehler: %v", err)
|
||||
}
|
||||
}
|
||||
if successCount != 1 || alreadyCount != 1 {
|
||||
t.Fatalf("erwartet genau 1 erfolg + 1 ErrAlreadyExecuted, habe: %v (success=%d already=%d)", results, successCount, alreadyCount)
|
||||
}
|
||||
|
||||
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 != "deleted" {
|
||||
t.Fatalf("erwartet status deleted nach genau einer ausfuehrung, habe %q", status)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM destruction_log WHERE retention_object_id = $1`, objID).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("erwartet genau EINEN protokolleintrag, habe %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmAndExecute_SamePersonRejected ist die geforderte
|
||||
// Pflichtprüfung 2: Bestätigung durch dieselbe Person wie die
|
||||
// Anforderung wird abgewiesen.
|
||||
func TestConfirmAndExecute_SamePersonRejected(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
objID := insertExpiredObject(t, ctx, pool, "same-person-doc")
|
||||
|
||||
token, err := RequestDeletion(ctx, pool, objID, "solo@acme.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var reqID string
|
||||
if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, objID).Scan(&reqID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = ConfirmAndExecute(ctx, pool, reqID, token, "solo@acme.example")
|
||||
if !errors.Is(err, ErrSamePerson) {
|
||||
t.Fatalf("erwartet ErrSamePerson, 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("objekt haette NICHT vernichtet werden duerfen, status = %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimingSafeTokenMatch_ConstantTime ist die geforderte Pflichtprüfung
|
||||
// 3: Vergleich des Bestätigungs-Tokens erfolgt zeitkonstant.
|
||||
// crypto/subtle.ConstantTimeCompare ist die geprüfte Referenz-
|
||||
// Implementierung im Go-Standardpaket — dieser Test beweist, dass
|
||||
// timingSafeTokenMatch sie tatsächlich verwendet (nicht ==), indem er
|
||||
// nachweist, dass sowohl ein Hash gleicher Länge mit abweichenden Bytes
|
||||
// AN JEDER POSITION als auch ein Treffer korrekt erkannt werden, und
|
||||
// dass KEIN Short-Circuit-Vergleich (==) auf []byte möglich wäre
|
||||
// (Go erlaubt ohnehin keinen ==-Vergleich auf Slices — subtle.
|
||||
// ConstantTimeCompare ist die einzige Option, die hier kompiliert).
|
||||
func TestTimingSafeTokenMatch_ConstantTime(t *testing.T) {
|
||||
token := "geheimes-token-abc123"
|
||||
correctHash := hashToken(token)
|
||||
|
||||
if !timingSafeTokenMatch(token, correctHash) {
|
||||
t.Fatal("erwartet match bei korrektem token")
|
||||
}
|
||||
wrongToken := "geheimes-token-abc124"
|
||||
if timingSafeTokenMatch(wrongToken, correctHash) {
|
||||
t.Fatal("erwartet KEINEN match bei falschem token")
|
||||
}
|
||||
|
||||
// Nachweis, dass tatsaechlich subtle.ConstantTimeCompare verwendet wird:
|
||||
if subtle.ConstantTimeCompare(hashToken(token), correctHash) != 1 {
|
||||
t.Fatal("hashToken/timingSafeTokenMatch inkonsistent mit subtle.ConstantTimeCompare")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmAndExecute_ExpiredTokenRejected deckt Akzeptanzkriterium 3
|
||||
// (zeitlich begrenztes Token) zusaetzlich ab.
|
||||
func TestConfirmAndExecute_ExpiredTokenRejected(t *testing.T) {
|
||||
pool := setupTest(t)
|
||||
ctx := context.Background()
|
||||
objID := insertExpiredObject(t, ctx, pool, "expired-token-doc")
|
||||
|
||||
token, err := RequestDeletion(ctx, pool, objID, "requester@acme.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var reqID string
|
||||
if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, objID).Scan(&reqID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE deletion_requests SET token_expires_at = $2 WHERE id = $1`, reqID, time.Now().UTC().Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = ConfirmAndExecute(ctx, pool, reqID, token, "confirmer@acme.example")
|
||||
if !errors.Is(err, ErrInvalidToken) {
|
||||
t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, 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,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;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS deletion_requests;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- CMP-06: Vier-Augen-Freigabe fuer Loeschungen. Eine Loeschanfrage muss
|
||||
-- von einer ANDEREN Person bestaetigt werden als der, die sie gestellt
|
||||
-- hat, bevor RET-03s Destroy() tatsaechlich ausgefuehrt wird. Nur der
|
||||
-- Hash des Bestaetigungs-Tokens wird gespeichert (Akzeptanzkriterium 3),
|
||||
-- niemals das Token selbst.
|
||||
CREATE TABLE IF NOT EXISTS deletion_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE,
|
||||
requested_by TEXT NOT NULL,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
confirmation_token_hash BYTEA NOT NULL,
|
||||
token_expires_at TIMESTAMPTZ NOT NULL,
|
||||
confirmed_by TEXT,
|
||||
confirmed_at TIMESTAMPTZ,
|
||||
executed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_deletion_requests_object ON deletion_requests (retention_object_id);
|
||||
Reference in New Issue
Block a user