diff --git a/docs/CUT-03-PRUEFPROTOKOLL.md b/docs/CUT-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..367d5379 --- /dev/null +++ b/docs/CUT-03-PRUEFPROTOKOLL.md @@ -0,0 +1,116 @@ +# CUT-03 — Pruefprotokoll: Tenant- und User-Mapping final freigeben + +Datum: 2026-10-15 +Arbeitsverzeichnis (Host 192.168.1.204): siehe Abschlussmeldung des Auftrags +Branch: `feature/cut-03-tenant-user-mapping-freigabe` + +## 1. Ausgangslage + +Vorhanden im `main`-Branch: +- `internal/tenantapproval` (MAP-02): Freigabe-Workflow pro Tenant-Kandidat (`Store.Approve`/`Reject`/`Correct`), Quelle fuer COR-01 ist ausschliesslich `Store.Approved()`. +- `internal/usermigrationapproval` (MAP-04): analoger Freigabe-Workflow pro User-Kandidat, Quelle fuer COR-02. +- `internal/cutoverorchestrator` (CUT-02): Generalprobe gegen synthetische Testkopie, Report-Format in `Report`/`RenderText` (`internal/cutoverorchestrator/protocol.go`). + +CUT-03 verlangt zusaetzlich zu diesen Pro-Eintrag-Freigaben einen **einzigen finalen Gesamt-Freigabe-Datensatz** fuer die komplette Map (Tenant- + User-Ebene), der ab seinem Freigabezeitpunkt weitere Einzel-Entscheidungen sperrt, bis er widerrufen wird. + +## 2. Implementierung + +### 2.1 Neues Paket `internal/finalmigrationapproval` + +Datei: `internal/finalmigrationapproval/store.go` + +- Tabelle `final_migration_approval` (Migration `0026`): genau ein nicht widerrufener Datensatz gleichzeitig (Unique-Index `WHERE revoked_at IS NULL`), mit `approved_by`, `approved_at`, optional `note`, `revoked_by`/`revoked_at`. +- `Store.Approve(ctx, approvedBy, note)`: legt die finale Freigabe an. Schlaegt mit `ErrAlreadyFinal` fehl, solange bereits eine aktive Freigabe existiert. +- `Store.Revoke(ctx, revokedBy, note)`: widerruft die aktive Freigabe (der "erneute Freigabeprozess" aus Akzeptanzkriterium 2). Ohne aktive Freigabe: `ErrNotFinal`. +- `Store.Current(ctx)` / `Store.IsFinalized(ctx)`: lesen den aktuellen Status; `IsFinalized` ist das Guard-Interface, das `tenantapproval`/`usermigrationapproval` konsumieren. +- Tabelle `cutover_generalprobe_snapshot` (Migration `0027`): dokumentiert einen CUT-02-Lauf (Run-Tag, Person, Zeitpunkt, Zaehler je Status, Hash ueber alle Zeilen). +- `ComputeSnapshot`, `RecordGeneralprobeSnapshot`, `LatestGeneralprobeSnapshot`, `SampleCheck`: die Stichprobe aus Pruefung 2 — vergleicht den aktuellen Stand von `tenant_migration_map`/`user_migration_map` (Zaehler je Status + Hash ueber `id:status:approved_by` je Zeile, sortiert) gegen den zuletzt dokumentierten CUT-02-Snapshot und meldet jede Abweichung als Klartext-Differenz. + +### 2.2 Guard in `internal/tenantapproval` und `internal/usermigrationapproval` (minimal-invasiv) + +Beide `Store`-Typen bekommen: +- `ErrFinalized` +- Interface `FinalGuard { IsFinalized(ctx) (bool, error) }` (Duck-Typing, kein Import von `finalmigrationapproval` noetig — keine Zyklusgefahr) +- Feld `finalGuard FinalGuard` + Setter `SetFinalGuard(guard FinalGuard)` +- Pruefung am Anfang von `decide()` (dem gemeinsamen Codepfad von `Approve`/`Reject`): ist eine finale Freigabe aktiv, wird sofort mit `ErrFinalized` abgebrochen, bevor Transaktion/Audit-Log beruehrt werden. + +`Correct()` (nachtraegliche Korrektur bereits freigegebener Tenant-Eintraege) bleibt unveraendert — Akzeptanzkriterium 2 spricht ausdruecklich von "Tenant- oder User-**Zuordnungen approved/rejected**", nicht von Korrekturen; eine Korrektur setzt ohnehin eine vorherige Freigabe voraus und ist bereits durch ihr eigenes Audit-Log nachvollziehbar. + +Migrationen: `migrations/0026_final_migration_approval.{up,down}.sql`, `migrations/0027_cutover_generalprobe_snapshot.{up,down}.sql`. + +## 3. Pruefungen + +### Pruefung 1: Freigabedokument liegt vor und ist auffindbar + +Ziel: eine abfragbare, dauerhafte Dokumentation der finalen Freigabe mit Zeitpunkt und Person. + +Durchgefuehrt: `TestApprove_PersistsTimestampAndApprovingPerson` (`internal/finalmigrationapproval/store_test.go`) legt eine finale Freigabe an und liest sie ueber `Store.Current()` aus derselben (durch `pgxpool` neu geoeffneten) Verbindung wieder — das bestaetigt, dass der Datensatz in der Tabelle `final_migration_approval` persistiert ist und nicht nur im Prozessspeicher existiert. + +Ergebnis: PASS. `ApprovedBy="qa-tester"`, `ApprovedAt` gesetzt (nicht Zero-Value), ueber `Current()` wieder auffindbar. + +### Pruefung 2: Stichprobe bestaetigt — Map entspricht dem Stand der Generalprobe (CUT-02) + +Ziel: ein Mechanismus, der den aktuellen Stand der Map gegen den letzten dokumentierten CUT-02-Lauf abgleicht und Abweichungen meldet. + +Durchgefuehrt: `TestSampleCheck_MatchesRecordedGeneralprobeThenDetectsDrift` +1. Zwei Tenant-Kandidaten angelegt, einer freigegeben → `ComputeSnapshot` + `RecordGeneralprobeSnapshot("cut02-run-1", ...)` (simuliert die Dokumentation eines CUT-02-Laufs). +2. `SampleCheck` unmittelbar danach: `Matches=true`, `AgainstRunTag="cut02-run-1"`, keine Differenzen. Zielwert erreicht. +3. Danach wird der zweite Kandidat freigegeben (Map veraendert sich nach dem dokumentierten Lauf) → `SampleCheck` erkennt `Matches=false` mit mindestens einer dokumentierten Differenz (Status-Zaehler und/oder Hash). Zielwert erreicht. + +Zusaetzlich `TestSampleCheck_WithoutRecordedGeneralprobeFails`: ohne dokumentierten CUT-02-Lauf liefert `SampleCheck` einen Fehler statt eines stillschweigend falschen "passt"-Ergebnisses. + +Ergebnis: PASS (alle drei Faelle). + +### Akzeptanzkriterium 2 (Sperre nach finaler Freigabe) — Guard-Integrationstest + +`TestGuard_BlocksTenantAndUserDecisionsAfterFinalApproval`: +1. Tenant- und User-Kandidat angelegt, Tenant-Freigabe *vor* finaler Gesamt-Freigabe erfolgreich (`tenantStore.Approve` ohne Guard-Blockade). +2. Nach `final.Approve(...)`: `userStore.Approve(...)` liefert `usermigrationapproval.ErrFinalized`, `tenantStore.Reject(...)` liefert `tenantapproval.ErrFinalized`. Keine der beiden Operationen hat den DB-Zustand veraendert (Guard greift vor Transaktionsbeginn). +3. Nach `final.Revoke(...)` (erneuter Freigabeprozess): `userStore.Approve(...)` funktioniert wieder normal. + +Ergebnis: PASS. + +### Zweite-Freigabe-Sperre auf Ebene der Gesamt-Freigabe selbst + +`TestApprove_RejectsSecondApprovalUntilRevoked`: zweiter `Approve`-Aufruf ohne vorherigen `Revoke` liefert `ErrAlreadyFinal`; `Revoke` ohne aktive Freigabe liefert `ErrNotFinal`; nach `Revoke` ist ein erneuter `Approve` moeglich und dokumentiert die neue freigebende Person. + +Ergebnis: PASS. + +## 4. Testlauf-Ausgaben (echte Postgres-Testdatenbank, Host 204) + +``` +=== RUN TestApprove_PersistsTimestampAndApprovingPerson +--- PASS: TestApprove_PersistsTimestampAndApprovingPerson (0.22s) +=== RUN TestApprove_RejectsSecondApprovalUntilRevoked +--- PASS: TestApprove_RejectsSecondApprovalUntilRevoked (0.09s) +=== RUN TestGuard_BlocksTenantAndUserDecisionsAfterFinalApproval +--- PASS: TestGuard_BlocksTenantAndUserDecisionsAfterFinalApproval (0.11s) +=== RUN TestSampleCheck_MatchesRecordedGeneralprobeThenDetectsDrift +--- PASS: TestSampleCheck_MatchesRecordedGeneralprobeThenDetectsDrift (0.11s) +=== RUN TestSampleCheck_WithoutRecordedGeneralprobeFails +--- PASS: TestSampleCheck_WithoutRecordedGeneralprobeFails (0.05s) +PASS +ok gitea.perlbach24.de/scripte/nexarch/internal/finalmigrationapproval 0.588s + +ok gitea.perlbach24.de/scripte/nexarch/internal/tenantapproval 0.502s +ok gitea.perlbach24.de/scripte/nexarch/internal/usermigrationapproval 0.681s +``` + +Zusaetzlich: `go build ./...` sauber; `gofmt -l` ohne Treffer fuer alle selbst angelegten/geaenderten Dateien; `go vet ./internal/finalmigrationapproval/... ./internal/tenantapproval/... ./internal/usermigrationapproval/...` ohne Befunde. + +Migrationen `0026`/`0027` real gegen eine Wegwerf-Datenbank im Test-Cluster (Port 5433) angewendet und wieder zurueckgerollt (`up` → `up` → `down` → `down`), jeweils ohne Fehler. + +Voller Root-Modul-Testlauf (`go test ./...`) ausgefuehrt: alle Pakete gruen ausser `internal/tenant` (9 Fehlschlaege, alle mit `tenant: slug bereits vergeben`) — dieses Paket wurde von CUT-03 nicht angefasst (Diff beschraenkt sich auf `internal/tenantapproval/store.go`, `internal/usermigrationapproval/store.go`, das neue Paket `internal/finalmigrationapproval` und die Migrationen `0026`/`0027`); die Fehlschlaege sind eine vorbestehende Fixture-/Parallelitaets-Kollision mit festen Slugs, keine Regression durch diese Aenderung. + +## 5. DSGVO-Hinweis + +Alle Tests arbeiten ausschliesslich mit synthetischen Fixtures (`Synth...`, `example.test`-Adressen, frei erfundene IDs). Keine Produktivdaten verwendet. + +## 6. Nachtrag — Korrekturen nach unabhaengiger Verifikation + +Eine unabhaengige Verifikation (eigener Fork, eigener Clone auf Host 204) hat vor dem Merge zwei echte Lücken gefunden und begründet als Blocker eingestuft. Beide wurden daraufhin behoben, verifiziert durch reale Testläufe auf Host 204 (TEST_ADMIN_DSN, kein Mocking): + +1. **Guard nie an einer produktiven Aufrufstelle verdrahtet.** `SetFinalGuard` wurde nur im Testcode aufgerufen; der einzige reale (nicht-Test-)Aufrufer von `tenantapproval.Approve/Correct` im gesamten Repository ist `internal/cutoverorchestrator`s `map02`-Schritt (die Simulation der menschlichen MAP-02-Freigabe im Trockenlauf/Generalprobe). Dort wird jetzt `store.SetFinalGuard(finalmigrationapproval.NewStore(d.registry))` gesetzt. `usermigrationapproval.NewStore` hat ausserhalb von Tests **keinen einzigen** produktiven Aufrufer im Repository (MAP-04 selbst hat nie eine Verdrahtung in ein reales Werkzeug bekommen) — das ist eine vorbestehende Lücke aus MAP-04, nicht Teil des CUT-03-Scopes, und bleibt hier bewusst unveraendert, aber ausdrücklich dokumentiert statt verschwiegen. Regressionslauf von `internal/cutoverorchestrator` (`go test ./internal/cutoverorchestrator/... -v`, inkl. beider Generalprobe-Tests) nach der Aenderung: weiterhin alle PASS, keine Regression. +2. **`Correct()` war ungeschuetzt und der Snapshot-Hash war inhaltlich blind.** `tenantapproval.Store.Correct()` prüfte den FinalGuard nicht — nach finaler Freigabe liess sich eine bereits genehmigte Zuordnung (Slug/Name/Tenant-IDs) trotzdem noch aendern. Zusaetzlich hashte `ComputeSnapshot` nur `id:status:approved_by`, sodass eine solche inhaltliche Aenderung von der Stichprobe (`SampleCheck`) gar nicht erkannt worden waere. Behoben: `Correct()` prueft jetzt denselben FinalGuard wie `decide()` (neuer Test `TestGuard_BlocksCorrectionAfterFinalApproval`, PASS); der Hash in `countsAndRowsTenant`/`countsAndRowsUser` schliesst jetzt slug/name bzw. email/name sowie beide Alt-ID-Felder ein (neuer Test `TestSampleCheck_DetectsContentOnlyDriftWithUnchangedCounts`, PASS — belegt, dass eine Correct()-Aenderung ohne Statuswechsel jetzt als Abweichung erkannt wird). + +Erneuter voller Testlauf nach beiden Korrekturen: `internal/finalmigrationapproval` (7/7 PASS), `internal/tenantapproval` (5/5 PASS), `internal/usermigrationapproval` (5/5 PASS), `internal/cutoverorchestrator` (alle PASS, inkl. `TestGeneralprobe_VollstaendigerLaufOhneManuellenEingriff` und `TestGeneralprobe_EingebauterRetentionsfehlerWirdErkannt`). `go build ./...` und `gofmt -l` auf allen geaenderten Dateien sauber. diff --git a/internal/cutoverorchestrator/steps_core.go b/internal/cutoverorchestrator/steps_core.go index ae0a5b4a..e97ce4a5 100644 --- a/internal/cutoverorchestrator/steps_core.go +++ b/internal/cutoverorchestrator/steps_core.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/internal/finalmigrationapproval" "gitea.perlbach24.de/scripte/nexarch/internal/migrate" "gitea.perlbach24.de/scripte/nexarch/internal/migrationaudit" "gitea.perlbach24.de/scripte/nexarch/internal/migrationrole" @@ -66,6 +67,7 @@ func (d *Dryrun) map01(ctx context.Context) (Outcome, error) { // ausfuehren muesste (Core erlaubt nur [a-z0-9_]). func (d *Dryrun) map02(ctx context.Context) (Outcome, error) { store := tenantapproval.NewStore(d.registry) + store.SetFinalGuard(finalmigrationapproval.NewStore(d.registry)) ids, err := store.InsertCandidates(ctx, d.tenantCands) if err != nil { return Outcome{}, err diff --git a/internal/finalmigrationapproval/store.go b/internal/finalmigrationapproval/store.go new file mode 100644 index 00000000..32a28b2b --- /dev/null +++ b/internal/finalmigrationapproval/store.go @@ -0,0 +1,327 @@ +// Package finalmigrationapproval implementiert CUT-03: die finale, +// schriftlich festgehaltene Gesamt-Freigabe der Tenant- und +// User-Migration-Map (ein Datensatz fuer die gesamte Map, nicht pro Eintrag +// wie tenantapproval/usermigrationapproval). Nach der Freigabe sperrt der +// Guard dort jede weitere Approve/Reject-Entscheidung, bis die Freigabe +// widerrufen wird (erneuter Freigabeprozess). +package finalmigrationapproval + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrAlreadyFinal = errors.New("finale freigabe liegt bereits vor, zuerst widerrufen") + ErrNotFinal = errors.New("keine aktive finale freigabe vorhanden") +) + +type Approval struct { + ID int64 + ApprovedBy string + ApprovedAt time.Time + Note string + RevokedBy *string + RevokedAt *time.Time +} + +func (a Approval) Active() bool { return a.RevokedAt == nil } + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Current liefert die aktive (nicht widerrufene) finale Freigabe, oder nil. +func (s *Store) Current(ctx context.Context) (*Approval, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, approved_by, approved_at, coalesce(note, ''), revoked_by, revoked_at + FROM final_migration_approval + WHERE revoked_at IS NULL + `) + var a Approval + if err := row.Scan(&a.ID, &a.ApprovedBy, &a.ApprovedAt, &a.Note, &a.RevokedBy, &a.RevokedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("aktive finale freigabe lesen: %w", err) + } + return &a, nil +} + +// IsFinalized implementiert das FinalGuard-Interface aus +// tenantapproval/usermigrationapproval. +func (s *Store) IsFinalized(ctx context.Context) (bool, error) { + cur, err := s.Current(ctx) + if err != nil { + return false, err + } + return cur != nil, nil +} + +// Approve haelt die finale Gesamt-Freigabe mit Zeitpunkt und freigebender +// Person fest (Akzeptanzkriterium 1). Ein zweiter Approve ohne vorherigen +// Revoke schlaegt fehl (Akzeptanzkriterium 2). +func (s *Store) Approve(ctx context.Context, approvedBy, note string) (Approval, error) { + if approvedBy == "" { + return Approval{}, errors.New("approvedBy darf nicht leer sein") + } + cur, err := s.Current(ctx) + if err != nil { + return Approval{}, err + } + if cur != nil { + return Approval{}, ErrAlreadyFinal + } + + var a Approval + err = s.pool.QueryRow(ctx, ` + INSERT INTO final_migration_approval (approved_by, note) + VALUES ($1, $2) + RETURNING id, approved_by, approved_at, coalesce(note, ''), revoked_by, revoked_at + `, approvedBy, note).Scan(&a.ID, &a.ApprovedBy, &a.ApprovedAt, &a.Note, &a.RevokedBy, &a.RevokedAt) + if err != nil { + return Approval{}, fmt.Errorf("finale freigabe eintragen: %w", err) + } + return a, nil +} + +// Revoke nimmt die aktive finale Freigabe zurueck und oeffnet damit wieder +// einen Freigabeprozess fuer einzelne Tenant-/User-Entscheidungen. +func (s *Store) Revoke(ctx context.Context, revokedBy, note string) error { + if revokedBy == "" { + return errors.New("revokedBy darf nicht leer sein") + } + tag, err := s.pool.Exec(ctx, ` + UPDATE final_migration_approval + SET revoked_by = $1, revoked_at = now(), note = coalesce(note, '') || case when $2 = '' then '' else ' | widerruf: ' || $2 end + WHERE revoked_at IS NULL + `, revokedBy, note) + if err != nil { + return fmt.Errorf("finale freigabe widerrufen: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFinal + } + return nil +} + +type Counts map[string]int + +type Snapshot struct { + TenantCounts Counts + UserCounts Counts + Hash string +} + +// ComputeSnapshot ermittelt Zaehler je Status sowie einen Hash ueber die +// freigegebenen Zuordnungen (id, status, approved_by je Zeile, sortiert) -- +// Vergleichsgrundlage fuer die Stichprobe (Pruefung 2). +func ComputeSnapshot(ctx context.Context, pool *pgxpool.Pool) (Snapshot, error) { + tenantCounts, tenantRows, err := countsAndRowsTenant(ctx, pool) + if err != nil { + return Snapshot{}, err + } + userCounts, userRows, err := countsAndRowsUser(ctx, pool) + if err != nil { + return Snapshot{}, err + } + return Snapshot{ + TenantCounts: tenantCounts, + UserCounts: userCounts, + Hash: hashRows(append(tenantRows, userRows...)), + }, nil +} + +func countsAndRowsTenant(ctx context.Context, pool *pgxpool.Pool) (Counts, []string, error) { + rows, err := pool.Query(ctx, ` + SELECT id, status, coalesce(approved_by, ''), slug, name, + coalesce(archivmail_tenant_id, -1), coalesce(archivdms_tenant_id, -1) + FROM tenant_migration_map + ORDER BY id + `) + if err != nil { + return nil, nil, fmt.Errorf("tenant_migration_map lesen: %w", err) + } + defer rows.Close() + + counts := Counts{} + var lines []string + for rows.Next() { + var id int64 + var status, approvedBy, slug, name string + var archivmailID, archivdmsID int64 + if err := rows.Scan(&id, &status, &approvedBy, &slug, &name, &archivmailID, &archivdmsID); err != nil { + return nil, nil, fmt.Errorf("tenant_migration_map-zeile lesen: %w", err) + } + counts[status]++ + lines = append(lines, fmt.Sprintf("tenant_migration_map:%d:%s:%s:%s:%s:%d:%d", id, status, approvedBy, slug, name, archivmailID, archivdmsID)) + } + return counts, lines, rows.Err() +} + +func countsAndRowsUser(ctx context.Context, pool *pgxpool.Pool) (Counts, []string, error) { + rows, err := pool.Query(ctx, ` + SELECT id, status, coalesce(approved_by, ''), email, name, + coalesce(archivmail_user_id, -1), coalesce(archivdms_user_id, -1) + FROM user_migration_map + ORDER BY id + `) + if err != nil { + return nil, nil, fmt.Errorf("user_migration_map lesen: %w", err) + } + defer rows.Close() + + counts := Counts{} + var lines []string + for rows.Next() { + var id int64 + var status, approvedBy, email, name string + var archivmailID, archivdmsID int64 + if err := rows.Scan(&id, &status, &approvedBy, &email, &name, &archivmailID, &archivdmsID); err != nil { + return nil, nil, fmt.Errorf("user_migration_map-zeile lesen: %w", err) + } + counts[status]++ + lines = append(lines, fmt.Sprintf("user_migration_map:%d:%s:%s:%s:%s:%d:%d", id, status, approvedBy, email, name, archivmailID, archivdmsID)) + } + return counts, lines, rows.Err() +} + +func hashRows(lines []string) string { + sort.Strings(lines) + h := sha256.New() + for _, l := range lines { + h.Write([]byte(l)) + h.Write([]byte{'\n'}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// RecordGeneralprobeSnapshot dokumentiert den Stand eines CUT-02-Laufs, +// gegen den die spaetere Stichprobe vergleicht. +func RecordGeneralprobeSnapshot(ctx context.Context, pool *pgxpool.Pool, runTag, recordedBy string, snap Snapshot) (int64, error) { + tc, err := json.Marshal(snap.TenantCounts) + if err != nil { + return 0, fmt.Errorf("tenant_counts serialisieren: %w", err) + } + uc, err := json.Marshal(snap.UserCounts) + if err != nil { + return 0, fmt.Errorf("user_counts serialisieren: %w", err) + } + var id int64 + err = pool.QueryRow(ctx, ` + INSERT INTO cutover_generalprobe_snapshot (run_tag, recorded_by, tenant_counts, user_counts, map_hash) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, runTag, recordedBy, tc, uc, snap.Hash).Scan(&id) + if err != nil { + return 0, fmt.Errorf("generalprobe-snapshot speichern: %w", err) + } + return id, nil +} + +type RecordedSnapshot struct { + ID int64 + RunTag string + RecordedBy string + RecordedAt time.Time + Snapshot Snapshot +} + +// LatestGeneralprobeSnapshot liefert den zuletzt dokumentierten CUT-02-Lauf. +func LatestGeneralprobeSnapshot(ctx context.Context, pool *pgxpool.Pool) (*RecordedSnapshot, error) { + row := pool.QueryRow(ctx, ` + SELECT id, run_tag, recorded_by, recorded_at, tenant_counts, user_counts, map_hash + FROM cutover_generalprobe_snapshot + ORDER BY recorded_at DESC, id DESC + LIMIT 1 + `) + var rs RecordedSnapshot + var tc, uc []byte + if err := row.Scan(&rs.ID, &rs.RunTag, &rs.RecordedBy, &rs.RecordedAt, &tc, &uc, &rs.Snapshot.Hash); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("generalprobe-snapshot lesen: %w", err) + } + if err := json.Unmarshal(tc, &rs.Snapshot.TenantCounts); err != nil { + return nil, fmt.Errorf("tenant_counts lesen: %w", err) + } + if err := json.Unmarshal(uc, &rs.Snapshot.UserCounts); err != nil { + return nil, fmt.Errorf("user_counts lesen: %w", err) + } + return &rs, nil +} + +type SampleCheckResult struct { + Matches bool + AgainstRunTag string + AgainstAt time.Time + Current Snapshot + Recorded Snapshot + Differences []string +} + +// SampleCheck fuehrt die Stichprobe durch (Pruefung 2): aktueller Stand von +// tenant_migration_map/user_migration_map gegen den zuletzt dokumentierten +// CUT-02-Generalprobe-Lauf. +func SampleCheck(ctx context.Context, pool *pgxpool.Pool) (SampleCheckResult, error) { + recorded, err := LatestGeneralprobeSnapshot(ctx, pool) + if err != nil { + return SampleCheckResult{}, err + } + if recorded == nil { + return SampleCheckResult{}, errors.New("keine CUT-02-Generalprobe dokumentiert, Stichprobe nicht moeglich") + } + current, err := ComputeSnapshot(ctx, pool) + if err != nil { + return SampleCheckResult{}, err + } + + var diffs []string + for status, want := range recorded.Snapshot.TenantCounts { + if got := current.TenantCounts[status]; got != want { + diffs = append(diffs, fmt.Sprintf("tenant_migration_map status=%s: erwartet %d, aktuell %d", status, want, got)) + } + } + for status, got := range current.TenantCounts { + if _, ok := recorded.Snapshot.TenantCounts[status]; !ok { + diffs = append(diffs, fmt.Sprintf("tenant_migration_map status=%s: unerwartet, aktuell %d (generalprobe kannte diesen status nicht)", status, got)) + } + } + for status, want := range recorded.Snapshot.UserCounts { + if got := current.UserCounts[status]; got != want { + diffs = append(diffs, fmt.Sprintf("user_migration_map status=%s: erwartet %d, aktuell %d", status, want, got)) + } + } + for status, got := range current.UserCounts { + if _, ok := recorded.Snapshot.UserCounts[status]; !ok { + diffs = append(diffs, fmt.Sprintf("user_migration_map status=%s: unerwartet, aktuell %d (generalprobe kannte diesen status nicht)", status, got)) + } + } + if current.Hash != recorded.Snapshot.Hash { + diffs = append(diffs, fmt.Sprintf("map-hash weicht ab: generalprobe=%s aktuell=%s", recorded.Snapshot.Hash, current.Hash)) + } + sort.Strings(diffs) + + return SampleCheckResult{ + Matches: len(diffs) == 0, + AgainstRunTag: recorded.RunTag, + AgainstAt: recorded.RecordedAt, + Current: current, + Recorded: recorded.Snapshot, + Differences: diffs, + }, nil +} diff --git a/internal/finalmigrationapproval/store_test.go b/internal/finalmigrationapproval/store_test.go new file mode 100644 index 00000000..a112cf63 --- /dev/null +++ b/internal/finalmigrationapproval/store_test.go @@ -0,0 +1,378 @@ +package finalmigrationapproval + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenantapproval" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch" + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" + "gitea.perlbach24.de/scripte/nexarch/internal/usermatch" + "gitea.perlbach24.de/scripte/nexarch/internal/usermigrationapproval" +) + +func setupSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { + t.Helper() + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + + CREATE TABLE tenant_migration_map ( + id BIGSERIAL PRIMARY KEY, + archivmail_tenant_id BIGINT, + archivdms_tenant_id BIGINT, + nexarch_tenant_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + slug TEXT NOT NULL, + name TEXT NOT NULL, + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL DEFAULT 'pending', + rejected_by TEXT, + rejected_at TIMESTAMPTZ, + CONSTRAINT tenant_migration_map_source_present + CHECK (archivmail_tenant_id IS NOT NULL OR archivdms_tenant_id IS NOT NULL) + ); + CREATE TABLE tenant_migration_map_audit ( + id BIGSERIAL PRIMARY KEY, + map_id BIGINT NOT NULL REFERENCES tenant_migration_map(id), + action TEXT NOT NULL, + performed_by TEXT NOT NULL, + performed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT, + previous_archivmail_tenant_id BIGINT, + previous_archivdms_tenant_id BIGINT, + previous_slug TEXT, + previous_name TEXT + ); + + CREATE TABLE user_migration_map ( + id BIGSERIAL PRIMARY KEY, + archivmail_user_id BIGINT, + archivdms_user_id BIGINT, + nexarch_tenant_id UUID NOT NULL, + nexarch_user_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE, + email TEXT NOT NULL, + name TEXT NOT NULL, + password_hash TEXT, + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL DEFAULT 'pending', + rejected_by TEXT, + rejected_at TIMESTAMPTZ, + CONSTRAINT user_migration_map_source_present + CHECK (archivmail_user_id IS NOT NULL OR archivdms_user_id IS NOT NULL) + ); + CREATE TABLE user_migration_map_audit ( + id BIGSERIAL PRIMARY KEY, + map_id BIGINT NOT NULL REFERENCES user_migration_map(id), + action TEXT NOT NULL, + performed_by TEXT NOT NULL, + performed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT + ); + + CREATE TABLE final_migration_approval ( + id BIGSERIAL PRIMARY KEY, + approved_by TEXT NOT NULL, + approved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT, + revoked_by TEXT, + revoked_at TIMESTAMPTZ, + CONSTRAINT final_migration_approval_revoked_consistent + CHECK ((revoked_by IS NULL) = (revoked_at IS NULL)) + ); + CREATE UNIQUE INDEX final_migration_approval_one_active_idx + ON final_migration_approval ((true)) WHERE revoked_at IS NULL; + + CREATE TABLE cutover_generalprobe_snapshot ( + id BIGSERIAL PRIMARY KEY, + run_tag TEXT NOT NULL, + recorded_by TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + tenant_counts JSONB NOT NULL, + user_counts JSONB NOT NULL, + map_hash TEXT NOT NULL + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } +} + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + pool := testdb.Pool(t, adminDSN) + setupSchema(t, context.Background(), pool) + return pool +} + +func mustID(n int64) *int64 { return &n } + +// AC1: finale Freigabe ist schriftlich dokumentiert mit Zeitpunkt und +// freigebender Person. +func TestApprove_PersistsTimestampAndApprovingPerson(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + + cur, err := store.Current(ctx) + if err != nil { + t.Fatalf("Current: %v", err) + } + if cur != nil { + t.Fatalf("erwarte keine aktive freigabe vor dem test, habe %+v", cur) + } + + a, err := store.Approve(ctx, "qa-tester", "CUT-03 finale freigabe") + if err != nil { + t.Fatalf("Approve: %v", err) + } + if a.ApprovedBy != "qa-tester" || a.ApprovedAt.IsZero() { + t.Fatalf("freigabe unvollstaendig dokumentiert: %+v", a) + } + + cur, err = store.Current(ctx) + if err != nil { + t.Fatalf("Current nach Approve: %v", err) + } + if cur == nil || cur.ApprovedBy != "qa-tester" { + t.Fatalf("Current liefert die dokumentierte freigabe nicht: %+v", cur) + } +} + +// AC2: nach der finalen Freigabe werden keine weiteren Aenderungen an der Map +// ohne erneuten Freigabeprozess vorgenommen -- hier auf Ebene des +// final_migration_approval-Datensatzes selbst (kein zweiter Approve ohne +// Revoke). +func TestApprove_RejectsSecondApprovalUntilRevoked(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + store := NewStore(pool) + + if _, err := store.Approve(ctx, "qa-tester", ""); err != nil { + t.Fatalf("erste Approve: %v", err) + } + if _, err := store.Approve(ctx, "qa-tester-2", ""); err == nil { + t.Fatalf("zweite Approve haette an ErrAlreadyFinal scheitern muessen") + } else if err != ErrAlreadyFinal { + t.Fatalf("erwarte ErrAlreadyFinal, habe %v", err) + } + + if err := store.Revoke(ctx, "qa-tester", "erneuter freigabeprozess noetig"); err != nil { + t.Fatalf("Revoke: %v", err) + } + if err := store.Revoke(ctx, "qa-tester", ""); err != ErrNotFinal { + t.Fatalf("zweiter Revoke ohne aktive freigabe sollte ErrNotFinal liefern, habe %v", err) + } + + a, err := store.Approve(ctx, "qa-tester-2", "erneute freigabe nach korrektur") + if err != nil { + t.Fatalf("erneute Approve nach Revoke: %v", err) + } + if a.ApprovedBy != "qa-tester-2" { + t.Fatalf("erneute freigabe dokumentiert falsche person: %+v", a) + } +} + +// AC2 (Guard): sobald final freigegeben, blockieren tenantapproval und +// usermigrationapproval neue Approve/Reject-Entscheidungen; nach Widerruf +// funktionieren sie wieder. +func TestGuard_BlocksTenantAndUserDecisionsAfterFinalApproval(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + final := NewStore(pool) + + tenantStore := tenantapproval.NewStore(pool) + tenantStore.SetFinalGuard(final) + userStore := usermigrationapproval.NewStore(pool) + userStore.SetFinalGuard(final) + + tenantIDs, err := tenantStore.InsertCandidates(ctx, []tenantmatch.Candidate{ + {ArchivmailTenantID: mustID(1), ArchivdmsTenantID: mustID(2), Slug: testdb.Slug("synth"), Name: "Synthetische GmbH"}, + }) + if err != nil { + t.Fatalf("tenant InsertCandidates: %v", err) + } + tenantID := tenantIDs[0] + + tenantEntry, err := tenantStore.Get(ctx, tenantID) + if err != nil { + t.Fatalf("tenant Get: %v", err) + } + + userIDs, err := userStore.InsertCandidates(ctx, []usermatch.Candidate{ + {ArchivmailUserID: mustID(10), NexarchTenantID: tenantEntry.NexarchTenantID, Email: "synth@example.test", Name: "Synth Nutzer"}, + }) + if err != nil { + t.Fatalf("user InsertCandidates: %v", err) + } + userID := userIDs[0] + + if err := tenantStore.Approve(ctx, tenantID, "qa-tester", "vor finaler freigabe"); err != nil { + t.Fatalf("tenant Approve vor finaler freigabe: %v", err) + } + + if _, err := final.Approve(ctx, "qa-lead", "CUT-03 gesamtfreigabe"); err != nil { + t.Fatalf("finale Approve: %v", err) + } + + if err := userStore.Approve(ctx, userID, "qa-tester", "nach finaler freigabe"); err != usermigrationapproval.ErrFinalized { + t.Fatalf("user Approve nach finaler freigabe haette ErrFinalized liefern muessen, habe %v", err) + } + if err := tenantStore.Reject(ctx, tenantID, "qa-tester", "nach finaler freigabe"); err != tenantapproval.ErrFinalized { + t.Fatalf("tenant Reject nach finaler freigabe haette ErrFinalized liefern muessen, habe %v", err) + } + + if err := final.Revoke(ctx, "qa-lead", "korrektur noetig"); err != nil { + t.Fatalf("Revoke: %v", err) + } + if err := userStore.Approve(ctx, userID, "qa-tester", "nach widerruf"); err != nil { + t.Fatalf("user Approve nach widerruf haette wieder moeglich sein muessen: %v", err) + } +} + +func TestGuard_BlocksCorrectionAfterFinalApproval(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + final := NewStore(pool) + + tenantStore := tenantapproval.NewStore(pool) + tenantStore.SetFinalGuard(final) + + tenantIDs, err := tenantStore.InsertCandidates(ctx, []tenantmatch.Candidate{ + {ArchivmailTenantID: mustID(1), ArchivdmsTenantID: mustID(2), Slug: testdb.Slug("synth"), Name: "Synthetische GmbH"}, + }) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + tenantID := tenantIDs[0] + if err := tenantStore.Approve(ctx, tenantID, "qa-tester", "vor finaler freigabe"); err != nil { + t.Fatalf("Approve vor finaler freigabe: %v", err) + } + if _, err := final.Approve(ctx, "qa-lead", "CUT-03 gesamtfreigabe"); err != nil { + t.Fatalf("finale Approve: %v", err) + } + + newSlug := testdb.Slug("veraendert") + if err := tenantStore.Correct(ctx, tenantID, "qa-tester", "nach finaler freigabe", tenantapproval.Correction{Slug: &newSlug}); err != tenantapproval.ErrFinalized { + t.Fatalf("Correct nach finaler freigabe haette ErrFinalized liefern muessen, habe %v", err) + } + + if err := final.Revoke(ctx, "qa-lead", "korrektur noetig"); err != nil { + t.Fatalf("Revoke: %v", err) + } + if err := tenantStore.Correct(ctx, tenantID, "qa-tester", "nach widerruf", tenantapproval.Correction{Slug: &newSlug}); err != nil { + t.Fatalf("Correct nach widerruf haette wieder moeglich sein muessen: %v", err) + } +} + +// Pruefung 2: Stichprobe bestaetigt, dass die Map dem Stand der +// CUT-02-Generalprobe entspricht -- und erkennt eine Abweichung danach. +func TestSampleCheck_MatchesRecordedGeneralprobeThenDetectsDrift(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + tenantStore := tenantapproval.NewStore(pool) + + ids, err := tenantStore.InsertCandidates(ctx, []tenantmatch.Candidate{ + {ArchivmailTenantID: mustID(1), ArchivdmsTenantID: mustID(2), Slug: testdb.Slug("synth1"), Name: "Synth Eins"}, + {ArchivmailTenantID: mustID(3), Slug: testdb.Slug("synth2"), Name: "Synth Zwei"}, + }) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + if err := tenantStore.Approve(ctx, ids[0], "qa-tester", "generalprobe CUT-02"); err != nil { + t.Fatalf("Approve: %v", err) + } + + snap, err := ComputeSnapshot(ctx, pool) + if err != nil { + t.Fatalf("ComputeSnapshot: %v", err) + } + if _, err := RecordGeneralprobeSnapshot(ctx, pool, "cut02-run-1", "qa-tester", snap); err != nil { + t.Fatalf("RecordGeneralprobeSnapshot: %v", err) + } + + res, err := SampleCheck(ctx, pool) + if err != nil { + t.Fatalf("SampleCheck: %v", err) + } + if !res.Matches { + t.Fatalf("stichprobe haette unmittelbar nach dem snapshot uebereinstimmen muessen, differenzen: %v", res.Differences) + } + if res.AgainstRunTag != "cut02-run-1" { + t.Fatalf("erwarte vergleich gegen cut02-run-1, habe %q", res.AgainstRunTag) + } + + if err := tenantStore.Approve(ctx, ids[1], "qa-tester", "nach der generalprobe approved"); err != nil { + t.Fatalf("Approve zweiter kandidat: %v", err) + } + + res2, err := SampleCheck(ctx, pool) + if err != nil { + t.Fatalf("SampleCheck nach Abweichung: %v", err) + } + if res2.Matches { + t.Fatalf("stichprobe haette die abweichende freigabe erkennen muessen") + } + if len(res2.Differences) == 0 { + t.Fatalf("erwarte dokumentierte differenzen") + } +} + +// TestSampleCheck_DetectsContentOnlyDriftWithUnchangedCounts belegt, dass +// der Snapshot-Hash inhaltliche Felder (slug/name/archivmail_tenant_id/ +// archivdms_tenant_id) einschliesst -- eine Correct() nach der Generalprobe +// aendert weder Status noch Zaehler, muss aber trotzdem als Abweichung +// erkannt werden. +func TestSampleCheck_DetectsContentOnlyDriftWithUnchangedCounts(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + tenantStore := tenantapproval.NewStore(pool) + + ids, err := tenantStore.InsertCandidates(ctx, []tenantmatch.Candidate{ + {ArchivmailTenantID: mustID(1), ArchivdmsTenantID: mustID(2), Slug: testdb.Slug("synth1"), Name: "Synth Eins"}, + }) + if err != nil { + t.Fatalf("InsertCandidates: %v", err) + } + if err := tenantStore.Approve(ctx, ids[0], "qa-tester", "generalprobe CUT-02"); err != nil { + t.Fatalf("Approve: %v", err) + } + + snap, err := ComputeSnapshot(ctx, pool) + if err != nil { + t.Fatalf("ComputeSnapshot: %v", err) + } + if _, err := RecordGeneralprobeSnapshot(ctx, pool, "cut02-run-content", "qa-tester", snap); err != nil { + t.Fatalf("RecordGeneralprobeSnapshot: %v", err) + } + + newSlug := testdb.Slug("verfaelscht") + if err := tenantStore.Correct(ctx, ids[0], "qa-tester", "inhaltliche aenderung ohne statuswechsel", tenantapproval.Correction{Slug: &newSlug}); err != nil { + t.Fatalf("Correct: %v", err) + } + + res, err := SampleCheck(ctx, pool) + if err != nil { + t.Fatalf("SampleCheck: %v", err) + } + if res.Matches { + t.Fatalf("stichprobe haette inhaltliche aenderung bei gleichbleibendem status/zaehler erkennen muessen") + } +} + +func TestSampleCheck_WithoutRecordedGeneralprobeFails(t *testing.T) { + ctx := context.Background() + pool := testPool(t) + + if _, err := SampleCheck(ctx, pool); err == nil { + t.Fatalf("Stichprobe ohne dokumentierten CUT-02-Lauf haette fehlschlagen muessen") + } +} diff --git a/internal/tenantapproval/store.go b/internal/tenantapproval/store.go index d01024b3..b50c9b39 100644 --- a/internal/tenantapproval/store.go +++ b/internal/tenantapproval/store.go @@ -20,8 +20,14 @@ var ( ErrNotFound = errors.New("tenant-map-eintrag nicht gefunden") ErrAlreadyDecided = errors.New("tenant-map-eintrag ist bereits freigegeben oder abgelehnt") ErrNotApproved = errors.New("tenant-map-eintrag ist nicht freigegeben, keine korrektur moeglich") + ErrFinalized = errors.New("tenant-map ist final freigegeben (CUT-03), erst widerrufen vor weiteren entscheidungen") ) +// FinalGuard wird von internal/finalmigrationapproval.Store erfuellt. +type FinalGuard interface { + IsFinalized(ctx context.Context) (bool, error) +} + type Status string const ( @@ -48,24 +54,31 @@ type Entry struct { // AuditEntry protokolliert eine einzelne Freigabe-Entscheidung oder // nachtraegliche Korrektur. type AuditEntry struct { - ID int64 - MapID int64 - Action string - PerformedBy string - PerformedAt time.Time - Note string - PreviousSlug string - PreviousName string + ID int64 + MapID int64 + Action string + PerformedBy string + PerformedAt time.Time + Note string + PreviousSlug string + PreviousName string } type Store struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + finalGuard FinalGuard } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } +// SetFinalGuard verknuepft den Store mit der CUT-03-Gesamt-Freigabe: solange +// diese aktiv ist, lehnen Approve/Reject mit ErrFinalized ab. +func (s *Store) SetFinalGuard(guard FinalGuard) { + s.finalGuard = guard +} + // InsertCandidates legt MAP-01-Kandidaten als unbestaetigte (status=pending) // tenant_migration_map-Zeilen an. Einseitige Kandidaten (nur in einem // Altsystem) werden ebenfalls angelegt: die tenant_migration_map ist die @@ -171,6 +184,16 @@ func (s *Store) Reject(ctx context.Context, id int64, rejectedBy, note string) e } func (s *Store) decide(ctx context.Context, id int64, action, actor, note, sql string) error { + if s.finalGuard != nil { + finalized, err := s.finalGuard.IsFinalized(ctx) + if err != nil { + return fmt.Errorf("final-freigabe-status pruefen: %w", err) + } + if finalized { + return ErrFinalized + } + } + tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("transaktion beginnen: %w", err) @@ -214,6 +237,15 @@ func (s *Store) Correct(ctx context.Context, id int64, correctedBy, note string, if correctedBy == "" { return errors.New("correctedBy darf nicht leer sein") } + if s.finalGuard != nil { + finalized, err := s.finalGuard.IsFinalized(ctx) + if err != nil { + return fmt.Errorf("final-freigabe-status pruefen: %w", err) + } + if finalized { + return ErrFinalized + } + } tx, err := s.pool.Begin(ctx) if err != nil { diff --git a/internal/usermigrationapproval/store.go b/internal/usermigrationapproval/store.go index 13fea7ef..0097d4d7 100644 --- a/internal/usermigrationapproval/store.go +++ b/internal/usermigrationapproval/store.go @@ -20,8 +20,14 @@ import ( var ( ErrNotFound = errors.New("user-map-eintrag nicht gefunden") ErrAlreadyDecided = errors.New("user-map-eintrag ist bereits freigegeben oder abgelehnt") + ErrFinalized = errors.New("user-map ist final freigegeben (CUT-03), erst widerrufen vor weiteren entscheidungen") ) +// FinalGuard wird von internal/finalmigrationapproval.Store erfuellt. +type FinalGuard interface { + IsFinalized(ctx context.Context) (bool, error) +} + type Status string const ( @@ -57,13 +63,20 @@ type AuditEntry struct { } type Store struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + finalGuard FinalGuard } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } +// SetFinalGuard verknuepft den Store mit der CUT-03-Gesamt-Freigabe: solange +// diese aktiv ist, lehnen Approve/Reject mit ErrFinalized ab. +func (s *Store) SetFinalGuard(guard FinalGuard) { + s.finalGuard = guard +} + // InsertCandidates legt MAP-03-Kandidaten als unbestaetigte (status=pending) // user_migration_map-Zeilen an. password_hash wird mituebernommen, aber nie // zurueckgeliefert (Entry enthaelt ihn bewusst nicht) -- er bleibt ausserhalb @@ -169,6 +182,16 @@ func (s *Store) Reject(ctx context.Context, id int64, rejectedBy, note string) e } func (s *Store) decide(ctx context.Context, id int64, action, actor, note, sql string) error { + if s.finalGuard != nil { + finalized, err := s.finalGuard.IsFinalized(ctx) + if err != nil { + return fmt.Errorf("final-freigabe-status pruefen: %w", err) + } + if finalized { + return ErrFinalized + } + } + tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("transaktion beginnen: %w", err) diff --git a/migrations/0026_final_migration_approval.down.sql b/migrations/0026_final_migration_approval.down.sql new file mode 100644 index 00000000..78f098f2 --- /dev/null +++ b/migrations/0026_final_migration_approval.down.sql @@ -0,0 +1 @@ +DROP TABLE final_migration_approval; diff --git a/migrations/0026_final_migration_approval.up.sql b/migrations/0026_final_migration_approval.up.sql new file mode 100644 index 00000000..27c182ef --- /dev/null +++ b/migrations/0026_final_migration_approval.up.sql @@ -0,0 +1,17 @@ +-- CUT-03: finale Gesamt-Freigabe fuer tenant_migration_map + user_migration_map. +-- Ein nicht-widerrufener Datensatz sperrt weitere Approve/Reject-Entscheidungen +-- in tenantapproval/usermigrationapproval (Guard, siehe dort). +CREATE TABLE final_migration_approval ( + id BIGSERIAL PRIMARY KEY, + approved_by TEXT NOT NULL, + approved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT, + revoked_by TEXT, + revoked_at TIMESTAMPTZ, + CONSTRAINT final_migration_approval_revoked_consistent + CHECK ((revoked_by IS NULL) = (revoked_at IS NULL)) +); + +CREATE UNIQUE INDEX final_migration_approval_one_active_idx + ON final_migration_approval ((true)) + WHERE revoked_at IS NULL; diff --git a/migrations/0027_cutover_generalprobe_snapshot.down.sql b/migrations/0027_cutover_generalprobe_snapshot.down.sql new file mode 100644 index 00000000..878ca297 --- /dev/null +++ b/migrations/0027_cutover_generalprobe_snapshot.down.sql @@ -0,0 +1 @@ +DROP TABLE cutover_generalprobe_snapshot; diff --git a/migrations/0027_cutover_generalprobe_snapshot.up.sql b/migrations/0027_cutover_generalprobe_snapshot.up.sql new file mode 100644 index 00000000..2dbb1970 --- /dev/null +++ b/migrations/0027_cutover_generalprobe_snapshot.up.sql @@ -0,0 +1,11 @@ +-- CUT-03: dokumentierter Stand der CUT-02-Generalprobe, als Vergleichsbasis +-- fuer die Stichprobe vor der finalen Freigabe. +CREATE TABLE cutover_generalprobe_snapshot ( + id BIGSERIAL PRIMARY KEY, + run_tag TEXT NOT NULL, + recorded_by TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + tenant_counts JSONB NOT NULL, + user_counts JSONB NOT NULL, + map_hash TEXT NOT NULL +);