Compare commits

..
Author SHA1 Message Date
sysops d252732d09 feat(archive): BAK-03 Restore-Verfahren fuer Datenbank und Objekt-Storage
internal/restore: Atomarer Restore ueber Temp-Verzeichnis + Rename,
nicht-leeres Ziel ohne -force bricht VOR jeder Beruehrung ab, JSONL-
Protokoll jedes Laufs. Drei reale Defekte beim Bau gefunden und behoben:
pg_combinebackup braucht Plain- statt Tar-Format (Extraktionsschritt
ergaenzt), pg_wal.tar.gz wurde nie verifiziert/wiederhergestellt (BAK-01s
Verify jetzt erweitert), Go-exec haengt bei pg_ctl start wegen vererbter
Pipes (Testfix: echte Logdatei statt CombinedOutput). Beide Restore-Pfade
real auf 131 ueber restore-cli nachgewiesen, inkl. echtem Postgres-Start
aus wiederhergestelltem Verzeichnis.
2026-08-30 00:23:02 +02:00
sysops 67bcdd1833 feat(archive): BAK-08 Checksum-basierte Objekt-Integritaetspruefung
Stichprobenbasierter Scrub-Job: nimmt BAK-05s existing_in_storage,
priorisiert nach eigenem scrub_state.last_scrubbed_at (nicht
file_revisions.created_at, sonst kein echtes Rotationsverhalten),
prueft Inhalt per SHA-256 gegen file_revisions.checksum_sha256.
Meldung ueber echten dauerhaften /metrics-Endpunkt (Pull-Modell,
OPS-03 scrapt, kein Push), Counter monoton steigend. Real registriert
in Core metrics_sources, End-zu-Ende ueber OPS-03-Aggregator bestaetigt,
realer Befund-Durchlauf mit absichtlich falscher Pruefsumme durchgefuehrt.
2026-08-29 23:55:24 +02:00
sysops 8100fa3d14 fix(archive): BAK-05 Report liefert existing_in_storage fuer BAK-08
Report enthielt bisher nur Abweichungen. BAK-08 braucht eine
deterministisch sortierte Liste bestaetigt existierender Objekte als
Stichprobengrundlage, nicht nur "keine Abweichung". Ergaenzt vor
BAK-08-Start, real neu getestet (9/9) und auf 131 erneut ausgeloest.
2026-08-29 23:41:36 +02:00
sysops ae214f1731 feat(archive): BAK-05 Reconciliation Storage vs. DB
Deterministischer Abgleich zwischen DMS file_revisions und
Objekt-Storage-Verzeichnis, existenz-only (keine Inhaltspruefung,
saubere Abgrenzung zu BAK-08). Report sortiert nach storage_key fuer
stabile Weiterverarbeitung durch BAK-08. Systemd-Timer taeglich,
real auf 192.168.1.131 verdrahtet und ausgeloest.
2026-08-29 23:24:02 +02:00
sysopsandClaude Sonnet 5 10ba866f0e BAK-02: objekt-storage-backup-snapshots
restic statt Eigenbau (Content-defined Chunking fuer Dedup, verschluesseltes
Repository ab Werk, geprueftes Tooling). internal/objectbackup wrapt
restic init/backup/check/forget ueber os/exec, cmd/objectbackup-cli fuer
systemd-Timer (stuendliche Sicherung, woechentliche Vollstaendigkeitspruefung,
taegliche Rotation).

Auf 192.168.1.131 real verifiziert (kein Mock): zweiter Lauf gegen
unveraenderten Bestand ueberraegt nichts (files_new=0), zwei identische
Dateien erzeugen nachweislich nur 1 data_blob (echter Dedup-Nachweis),
absichtlich gekipptes Byte in einer Pack-Datei wird von check --read-data
zuverlaessig erkannt. Echt verdrahtet: 3 systemd-Timer installiert+
aktiviert, jeder Dienst einmal end-to-end via systemctl start ausgeloest.

Im selben Rutsch BAK-01-Fund korrigiert: NEXARCH_BACKUP_DIR zeigte auf
/var/backups/nexarch (Root-Dateisystem, kein Dataset) statt auf das
persistente ZFS-Dataset /var/nexarch-archiv - korrigiert, erneut ausgeloest,
landet nachweislich am richtigen Ort. objectbackup-cli-Repository liegt von
Anfang an korrekt dort.

Siehe archive/docs/BAK-02-PRUEFPROTOKOLL.md fuer alle Pruefungsergebnisse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-29 23:07:59 +02:00
sysopsandClaude Sonnet 5 5e4b91c4e9 BAK-01: datenbank-backup-strategie
Neues Modul-Verzeichnis code/archive/ (Monorepo-Muster wie code/dms/).
PostgreSQL-17-natives inkrementelles Backup (pg_basebackup --incremental,
WAL-Summarization) statt WAL-Archiving, um den geteilten Testhost ohne
Neustart umzustellen (summarize_wal=on per pg_reload_conf). Rolle
nexarch_backup mit REPLICATION-Attribut angelegt.

internal/backup: FullBackup/IncrementalBackup (pg_basebackup-Wrapper),
Verify (vollstaendiges Lesen von base.tar.gz, gzip+tar, nicht nur Header),
Rotate/ListGenerations (generationsbasiert, aeltere zuerst entfernt).
cmd/backup-cli fuer systemd-Timer-Aufruf (deploy/systemd/
nexarch-archive-backup-*.timer, taeglich/stuendlich/taeglich).

Auf 192.168.1.131 verifiziert: 4/4 Tests gegen echte Postgres-17-Instanz
(kein Mock) - inkrementelle Sicherung real kleiner als Vollsicherung,
Verifikation erkennt absichtlich beschaedigte Datei, Rotation entfernt nur
die aeltesten Generationen. Zusaetzlich ECHT verdrahtet: backup-cli gebaut,
3 systemd-Timer installiert+aktiviert, jeder der drei Dienste einmal ueber
systemctl start end-to-end ausgeloest (status=0/SUCCESS je Dienst) - nicht
nur go test, sondern der reale Automatisierungspfad selbst geprueft.

Siehe archive/docs/BAK-01-PRUEFPROTOKOLL.md fuer alle Pruefungsergebnisse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-29 22:49:13 +02:00
272 changed files with 3735 additions and 18509 deletions
-2
View File
@@ -1,4 +1,2 @@
*.log
.env
web/*/node_modules/
web/*/.next/
-155
View File
@@ -44,158 +44,3 @@ Keine Commits in dieser Session.
Keine Änderungen ermittelbar.
---
## 2026-08-27 17:26 17:28 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration)
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:31 17:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:36 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:37 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:53 17:57 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- e479330 IAM-01: benutzer-datenmodell-crud
### Geänderte Dateien
- cmd/core/main.go | 11 ++++++++++-
- go.mod | 9 +++++++++
- go.sum | 28 ++++++++++++++++++++++++++++
- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++
- internal/user/user_test.go | 24 ++++++++++++++++++++++++
- migrations/0002_superadmins.down.sql | 1 +
- migrations/0002_superadmins.up.sql | 14 ++++++++++++++
- migrations/tenant/0001_users.down.sql | 1 +
- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/index.ts | 25 +++++++++++++++++++++++++
- web/shl/package.json | 23 +++++++++++++++++++++++
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tsconfig.json | 18 ++++++++++++++++++
---
## 2026-08-28 21:51 21:57 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
### Geänderte Dateien
- web/shl/package.json | 2 ++
- web/shl/vitest.config.ts | 8 ++++++++
- web/shl/vitest.setup.ts | 1 +
---
## 2026-08-29 00:08 00:08 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- internal/tenantsettings/handler.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/tenantsettings/store_test.go | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/tenantsettings/tenantsettings.go | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- migrations/0003_tenant_settings.down.sql | 2 ++
- migrations/0003_tenant_settings.up.sql | 27 +++++++++++++++++++++++++++
- scripts/reset-test-env.sh | 25 +++++++++++++++++++++++++
- scripts/run-checks.sh | 24 ++++++++++++++++++++++++
---
+18
View File
@@ -0,0 +1,18 @@
version: "2"
run:
timeout: 3m
linters:
default: none
enable:
- govet
- staticcheck
- errcheck
- unused
- ineffassign
formatters:
enable:
- gofmt
- goimports
+19
View File
@@ -0,0 +1,19 @@
.PHONY: build lint fmt test check
build:
go build ./...
lint:
golangci-lint run ./...
fmt:
gofmt -l .
@test -z "$$(gofmt -l .)" || (echo "gofmt-Verstoesse gefunden, siehe oben" && exit 1)
test:
go test ./... -p 1 -count=1
check: build
go vet ./...
golangci-lint run ./...
go test ./... -p 1 -count=1
+62
View File
@@ -0,0 +1,62 @@
# NEXARCH Archive
Zentrales, modulübergreifendes Modul für Aufbewahrung, WORM, Compliance und
Backup. Dieses Verzeichnis enthält bisher `internal/backup` (BAK-01,
Datenbank-Backup-Strategie) — weitere Bausteine folgen ticketweise.
## BAK-01: Datenbank-Backup
`cmd/backup-cli` — Aufrufpunkt für systemd-Timer (siehe
`../deploy/systemd/nexarch-archive-backup-*.timer`):
```bash
export NEXARCH_BACKUP_PG_USER=nexarch_backup
export NEXARCH_BACKUP_PG_PASSWORD=...
export NEXARCH_BACKUP_DIR=/var/nexarch-archiv/backups/postgres # NICHT auf einem ephemeren Test-Dataset (siehe Betrieb)
export NEXARCH_BACKUP_KEEP_GENERATIONS=7 # optional, Default 7
backup-cli full # neue Vollsicherung + Verifikation
backup-cli incremental # inkrementelle Sicherung gegen die neueste Generation
backup-cli rotate # entfernt alle bis auf die neuesten N Generationen
```
Voraussetzung: die konfigurierte Postgres-Rolle braucht das
`REPLICATION`-Attribut (`pg_basebackup` nutzt eine
Replikationsverbindung), und `summarize_wal = on` muss serverseitig gesetzt
sein (PostgreSQL 17s natives inkrementelles Backup, keine WAL-Archivierung
nötig).
## BAK-02: Objekt-Storage-Backup
`cmd/objectbackup-cli` sichert einen lokalen Verzeichnisbaum (den
FDN-03-`LocalDriver`-Basisordner direkt, oder — für S3-gestützte
Deployments — einen vorgelagerten `rclone`-Spiegel) mit
[restic](https://restic.net) (Content-defined Chunking, verschlüsseltes
Repository, geprüftes Tooling statt Eigenbau):
```bash
export NEXARCH_OBJECTBACKUP_REPO_DIR=/var/nexarch-archiv/backups/objects
export NEXARCH_OBJECTBACKUP_PASSWORD=...
export NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS=30 # optional, Default 7
objectbackup-cli backup /var/nexarch-objects # Sicherung + Verifikation
objectbackup-cli check # vollständiges Lesen aller Datenblöcke
objectbackup-cli rotate # restic forget --keep-last N --prune
```
## Betrieb: Backup-Zielverzeichnis
Backup-Ziele liegen unter `/var/nexarch-archiv/` (persistentes ZFS-Dataset,
`zfs/data/subvol-1131-disk-0` auf 192.168.1.131), NIEMALS unter
`/var/nexarch-test/` (ephemeres Dataset, wird von den `reset-test-env.sh`-
Skripten der anderen Module geleert). ZFS-seitige Snapshots/Replikation
dieses Datasets sind ein eigenständiges Infra-Runbook (siehe
`../../STORAGE-KONZEPT.md` Abschnitt 7), kein Ticket-Code — `zfs
dedup=on` bewusst NICHT setzen (hoher RAM-Bedarf), Deduplizierung läuft
ausschließlich App-seitig über restic.
## Prüfungen
```bash
make check # build + vet + lint + test, analog Core/DMS
```
+107
View File
@@ -0,0 +1,107 @@
// backup-cli ist der Aufrufpunkt für BAK-01, gedacht für systemd-Timer
// (siehe deploy/systemd/) — "automatisiert nach Zeitplan" (Akzeptanzkriterium
// 1) entsteht durch die Zeitplan-Definition im Timer-Unit, nicht durch
// einen eigenen In-Prozess-Scheduler (kein zusätzlicher Dauerprozess nötig,
// passt zur Produkt-DNA "kein Anwendungsserver mit unnötigem
// Ressourcenverbrauch").
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/backup"
)
func loadConfig() backup.Config {
cfg := backup.Config{
Host: os.Getenv("NEXARCH_BACKUP_PG_HOST"),
Port: os.Getenv("NEXARCH_BACKUP_PG_PORT"),
User: os.Getenv("NEXARCH_BACKUP_PG_USER"),
Password: os.Getenv("NEXARCH_BACKUP_PG_PASSWORD"),
BackupDir: os.Getenv("NEXARCH_BACKUP_DIR"),
}
if cfg.Host == "" {
cfg.Host = "localhost"
}
if cfg.Port == "" {
cfg.Port = "5432"
}
if cfg.User == "" || cfg.Password == "" || cfg.BackupDir == "" {
log.Fatal("NEXARCH_BACKUP_PG_USER, NEXARCH_BACKUP_PG_PASSWORD und NEXARCH_BACKUP_DIR muessen gesetzt sein")
}
return cfg
}
func latestManifest(backupDir string) (string, error) {
generations, err := backup.ListGenerations(backupDir)
if err != nil {
return "", err
}
if len(generations) == 0 {
return "", fmt.Errorf("keine vorhandene generation fuer inkrementelle sicherung gefunden - zuerst 'full' ausfuehren")
}
latest := generations[len(generations)-1]
full := backupDir + "/" + latest + "/" + backup.FullBackupDirName + "/" + backup.BackupManifestFile
if _, err := os.Stat(full); err == nil {
return full, nil
}
return "", fmt.Errorf("kein backup_manifest in der neuesten generation %q gefunden", latest)
}
func main() {
if len(os.Args) < 2 {
log.Fatal("aufruf: backup-cli <full|incremental|verify|rotate> [args]")
}
cfg := loadConfig()
ctx := context.Background()
switch os.Args[1] {
case "full":
genID := backup.NewGenerationID(time.Now())
manifest, err := backup.FullBackup(ctx, cfg, genID)
if err != nil {
log.Fatalf("vollsicherung fehlgeschlagen: %v", err)
}
dir := manifest[:len(manifest)-len("/"+backup.BackupManifestFile)]
if err := backup.Verify(dir); err != nil {
log.Fatalf("verifikation der vollsicherung fehlgeschlagen: %v", err)
}
fmt.Printf("vollsicherung %q erstellt und verifiziert: %s\n", genID, manifest)
case "incremental":
manifest, err := latestManifest(cfg.BackupDir)
if err != nil {
log.Fatal(err)
}
generations, _ := backup.ListGenerations(cfg.BackupDir)
genID := generations[len(generations)-1]
incID := backup.NewGenerationID(time.Now())
newManifest, err := backup.IncrementalBackup(ctx, cfg, genID, incID, manifest)
if err != nil {
log.Fatalf("inkrementelle sicherung fehlgeschlagen: %v", err)
}
dir := newManifest[:len(newManifest)-len("/"+backup.BackupManifestFile)]
if err := backup.Verify(dir); err != nil {
log.Fatalf("verifikation der inkrementellen sicherung fehlgeschlagen: %v", err)
}
fmt.Printf("inkrementelle sicherung %q erstellt und verifiziert: %s\n", incID, newManifest)
case "rotate":
keep := 7
if v := os.Getenv("NEXARCH_BACKUP_KEEP_GENERATIONS"); v != "" {
_, _ = fmt.Sscanf(v, "%d", &keep)
}
removed, err := backup.Rotate(cfg.BackupDir, keep)
if err != nil {
log.Fatalf("rotation fehlgeschlagen: %v", err)
}
fmt.Printf("rotation abgeschlossen, %d generation(en) entfernt: %v\n", len(removed), removed)
default:
log.Fatalf("unbekannter befehl %q", os.Args[1])
}
}
+75
View File
@@ -0,0 +1,75 @@
// objectbackup-cli ist der Aufrufpunkt für BAK-02, für systemd-Timer
// gedacht (siehe deploy/systemd/) — "automatisiert nach Zeitplan" entsteht
// durch die Timer-Definition, kein eigener Dauerprozess (dieselbe
// Begründung wie BAK-01 / cmd/backup-cli).
package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup"
)
func loadConfig() objectbackup.Config {
cfg := objectbackup.Config{
RepoDir: os.Getenv("NEXARCH_OBJECTBACKUP_REPO_DIR"),
Password: os.Getenv("NEXARCH_OBJECTBACKUP_PASSWORD"),
}
if cfg.RepoDir == "" || cfg.Password == "" {
log.Fatal("NEXARCH_OBJECTBACKUP_REPO_DIR und NEXARCH_OBJECTBACKUP_PASSWORD muessen gesetzt sein")
}
return cfg
}
func main() {
if len(os.Args) < 2 {
log.Fatal("aufruf: objectbackup-cli <backup <quellverzeichnis>|check|rotate>")
}
cfg := loadConfig()
ctx := context.Background()
if err := objectbackup.InitRepo(ctx, cfg); err != nil {
log.Fatalf("repository initialisieren: %v", err)
}
switch os.Args[1] {
case "backup":
if len(os.Args) < 3 {
log.Fatal("aufruf: objectbackup-cli backup <quellverzeichnis>")
}
summary, err := objectbackup.Backup(ctx, cfg, os.Args[2])
if err != nil {
log.Fatalf("sicherung fehlgeschlagen: %v", err)
}
if err := objectbackup.Check(ctx, cfg, false); err != nil {
log.Fatalf("verifikation nach sicherung fehlgeschlagen: %v", err)
}
fmt.Printf("sicherung %q erstellt und verifiziert (neu=%d geaendert=%d unveraendert=%d)\n",
summary.SnapshotID, summary.FilesNew, summary.FilesChanged, summary.FilesUnmodified)
case "check":
if err := objectbackup.Check(ctx, cfg, true); err != nil {
log.Fatalf("verifikation fehlgeschlagen: %v", err)
}
fmt.Println("verifikation (mit vollstaendigem lesen) erfolgreich")
case "rotate":
keep := 7
if v := os.Getenv("NEXARCH_OBJECTBACKUP_KEEP_SNAPSHOTS"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
keep = n
}
}
if err := objectbackup.Forget(ctx, cfg, keep); err != nil {
log.Fatalf("rotation fehlgeschlagen: %v", err)
}
fmt.Println("rotation abgeschlossen")
default:
log.Fatalf("unbekannter befehl %q", os.Args[1])
}
}
+55
View File
@@ -0,0 +1,55 @@
// reconcile-cli ist der Aufrufpunkt für BAK-05, für systemd-Timer gedacht
// (siehe deploy/systemd/) — "geplanter Abgleichs-Job" (Ticket-Vorgabe)
// entsteht durch die Timer-Definition, kein eigener Dauerprozess.
package main
import (
"context"
"encoding/json"
"log"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile"
)
func main() {
dsn := os.Getenv("NEXARCH_RECONCILE_TENANT_DSN")
storageDir := os.Getenv("NEXARCH_RECONCILE_STORAGE_DIR")
if dsn == "" || storageDir == "" {
log.Fatal("NEXARCH_RECONCILE_TENANT_DSN und NEXARCH_RECONCILE_STORAGE_DIR muessen gesetzt sein")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("datenbankverbindung: %v", err)
}
defer pool.Close()
dbEntries, err := reconcile.ListDBStorageKeys(ctx, pool)
if err != nil {
log.Fatalf("datenbank-eintraege lesen: %v", err)
}
storageKeys, err := reconcile.ListStorageObjects(storageDir)
if err != nil {
log.Fatalf("objekt-storage durchlaufen: %v", err)
}
report := reconcile.Reconcile(dbEntries, storageKeys)
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(report); err != nil {
log.Fatalf("bericht ausgeben: %v", err)
}
// Nicht-null-Exit-Code bei Abweichungen (Akzeptanzkriterium 3:
// Abweichungen werden BERICHTET, nicht automatisch behoben — der
// Exit-Code macht das fuer systemd/Monitoring sichtbar, OHNE selbst
// irgendetwas zu reparieren).
if !report.IsClean() {
os.Exit(1)
}
}
+104
View File
@@ -0,0 +1,104 @@
// restore-cli ist der Aufrufpunkt fuer BAK-03: dokumentiertes,
// wiederholbares Restore-Verfahren fuer Datenbank (BAK-01) und
// Objekt-Storage (BAK-02). Kein systemd-Timer (Notfall-/Einzelfall-
// Werkzeug, kein Zeitplan) - manueller Aufruf mit expliziter Bestaetigung
// bei nicht-leerem Ziel.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/backup"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/objectbackup"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/restore"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
logPath := os.Getenv("NEXARCH_RESTORE_LOG")
if logPath == "" {
logPath = "/var/nexarch-archiv/restore.log"
}
switch os.Args[1] {
case "database":
runDatabase(logPath, os.Args[2:])
case "objects":
runObjects(logPath, os.Args[2:])
default:
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: restore-cli database [-force] <generation-id> <ziel-verzeichnis>")
fmt.Fprintln(os.Stderr, " restore-cli objects [-force] <snapshot-id> <ziel-verzeichnis>")
fmt.Fprintln(os.Stderr, "(Flag -force MUSS vor den Positionsargumenten stehen, Standardverhalten des Go-flag-Pakets)")
}
func runDatabase(logPath string, args []string) {
fs := flag.NewFlagSet("database", flag.ExitOnError)
force := fs.Bool("force", false, "bestehenden, nicht-leeren Inhalt im Zielverzeichnis ersetzen")
if err := fs.Parse(args); err != nil {
os.Exit(2)
}
if fs.NArg() != 2 {
usage()
os.Exit(2)
}
generationID, target := fs.Arg(0), fs.Arg(1)
cfg := backup.Config{
Host: os.Getenv("NEXARCH_BACKUP_PG_HOST"),
Port: os.Getenv("NEXARCH_BACKUP_PG_PORT"),
User: os.Getenv("NEXARCH_BACKUP_PG_USER"),
Password: os.Getenv("NEXARCH_BACKUP_PG_PASSWORD"),
BackupDir: os.Getenv("NEXARCH_BACKUP_DIR"),
}
ctx := context.Background()
entry, err := restore.AtomicRestore(restore.KindDatabase, generationID, target, *force, logPath, func(tempDir string) error {
return backup.Restore(ctx, cfg, generationID, tempDir)
})
report(entry, err)
}
func runObjects(logPath string, args []string) {
fs := flag.NewFlagSet("objects", flag.ExitOnError)
force := fs.Bool("force", false, "bestehenden, nicht-leeren Inhalt im Zielverzeichnis ersetzen")
if err := fs.Parse(args); err != nil {
os.Exit(2)
}
if fs.NArg() != 2 {
usage()
os.Exit(2)
}
snapshotID, target := fs.Arg(0), fs.Arg(1)
cfg := objectbackup.Config{
RepoDir: os.Getenv("NEXARCH_OBJECTBACKUP_REPO_DIR"),
Password: os.Getenv("NEXARCH_OBJECTBACKUP_PASSWORD"),
}
ctx := context.Background()
entry, err := restore.AtomicRestore(restore.KindObjects, snapshotID, target, *force, logPath, func(tempDir string) error {
return objectbackup.Restore(ctx, cfg, snapshotID, tempDir)
})
report(entry, err)
}
func report(entry restore.LogEntry, err error) {
fmt.Printf("restore %s: quelle=%s ziel=%s ergebnis=%s\n", entry.Kind, entry.Source, entry.Target, entry.Result)
if err != nil {
log.Fatalf("restore fehlgeschlagen: %v", err)
}
}
+144
View File
@@ -0,0 +1,144 @@
// scrub-cli ist der Aufrufpunkt fuer BAK-08 (systemd-Timer, konfigurierbare
// Kadenz) — zieht eine Stichprobe existierender Objekte (BAK-05 als
// Existenz-Quelle), prueft deren Inhalt per SHA-256 gegen
// file_revisions.checksum_sha256, meldet Abweichungen (kein Auto-Repair)
// und schreibt den Befund-Zaehler fuer den OPS-05/OPS-03-Metrik-Export.
package main
import (
"context"
"encoding/json"
"log"
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/scrub"
)
type finding struct {
StorageKey string `json:"storage_key"`
DocumentID string `json:"document_id"`
RevisionID string `json:"revision_id"`
Expected string `json:"expected_checksum"`
Actual string `json:"actual_checksum,omitempty"`
Error string `json:"error,omitempty"`
}
type report struct {
GeneratedAt time.Time `json:"generated_at"`
Sampled int `json:"sampled"`
Findings []finding `json:"findings"`
}
func main() {
dsn := os.Getenv("NEXARCH_SCRUB_TENANT_DSN")
storageDir := os.Getenv("NEXARCH_SCRUB_STORAGE_DIR")
if dsn == "" || storageDir == "" {
log.Fatal("NEXARCH_SCRUB_TENANT_DSN und NEXARCH_SCRUB_STORAGE_DIR muessen gesetzt sein")
}
sampleSize := envInt("NEXARCH_SCRUB_SAMPLE_SIZE", 10)
cooldown := envDuration("NEXARCH_SCRUB_COOLDOWN", 24*time.Hour)
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("datenbankverbindung: %v", err)
}
defer pool.Close()
dbEntries, err := reconcile.ListDBStorageKeys(ctx, pool)
if err != nil {
log.Fatalf("datenbank-eintraege lesen: %v", err)
}
storageKeys, err := reconcile.ListStorageObjects(storageDir)
if err != nil {
log.Fatalf("objekt-storage durchlaufen: %v", err)
}
rec := reconcile.Reconcile(dbEntries, storageKeys)
lastScrubbed, err := scrub.LoadLastScrubbed(ctx, pool)
if err != nil {
log.Fatalf("scrub-zustand lesen: %v", err)
}
now := time.Now().UTC()
candidates := scrub.Sample(rec.ExistingInStorage, lastScrubbed, cooldown, sampleSize, now)
keys := make([]string, 0, len(candidates))
for _, c := range candidates {
keys = append(keys, c.StorageKey)
}
expected, err := scrub.ExpectedChecksums(ctx, pool, keys)
if err != nil {
log.Fatalf("erwartete pruefsummen lesen: %v", err)
}
rep := report{GeneratedAt: now, Sampled: len(candidates)}
for _, c := range candidates {
exp, known := expected[c.StorageKey]
if !known {
// Objekt in DB nicht (mehr) auffindbar - das ist BAK-05s
// Zustaendigkeit (existiert der Datenbankeintrag?), nicht
// dieses Jobs; ueberspringen ohne Markierung.
continue
}
actual, readErr := scrub.ActualChecksum(storageDir, c.StorageKey)
ok := readErr == nil && actual == exp
if err := scrub.MarkScrubbed(ctx, pool, c.StorageKey, ok, now); err != nil {
log.Fatalf("scrub-zustand schreiben: %v", err)
}
if !ok {
f := finding{StorageKey: c.StorageKey, DocumentID: c.DocumentID, RevisionID: c.RevisionID, Expected: exp, Actual: actual}
if readErr != nil {
f.Error = readErr.Error()
}
rep.Findings = append(rep.Findings, f)
if err := scrub.RecordFinding(ctx, pool); err != nil {
log.Fatalf("befund-zaehler erhoehen: %v", err)
}
}
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(rep); err != nil {
log.Fatalf("bericht ausgeben: %v", err)
}
// Befund wird gemeldet, nicht automatisch repariert (Akzeptanzkriterium
// 3) - der Exit-Code macht das fuer systemd/Monitoring sichtbar, ohne
// selbst etwas zu reparieren; die tatsaechliche Meldung an OPS-05
// laeuft ueber den separaten /metrics-Export (cmd/scrub-metrics), nicht
// ueber diesen Exit-Code.
if len(rep.Findings) > 0 {
os.Exit(1)
}
}
func envInt(name string, def int) int {
v := os.Getenv(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
log.Fatalf("%s: ungueltiger wert %q: %v", name, v, err)
}
return n
}
func envDuration(name string, def time.Duration) time.Duration {
v := os.Getenv(name)
if v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil {
log.Fatalf("%s: ungueltiger wert %q: %v", name, v, err)
}
return d
}
+62
View File
@@ -0,0 +1,62 @@
// scrub-metrics stellt BAK-08s Befund-Zaehler unter /metrics bereit — die
// OPS-05-Anbindung ist Pull-basiert (Core OPS-03 scrapt /metrics-URLs, kein
// Push-Mechanismus), daher braucht es einen eigenen, dauerhaft laufenden
// HTTP-Endpunkt getrennt vom Oneshot-scrub-cli (dessen Prozess nach jedem
// Lauf beendet ist und daher zum Scrape-Zeitpunkt nicht erreichbar waere).
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/scrub"
)
func main() {
dsn := os.Getenv("NEXARCH_SCRUB_TENANT_DSN")
if dsn == "" {
log.Fatal("NEXARCH_SCRUB_TENANT_DSN muss gesetzt sein")
}
addr := os.Getenv("NEXARCH_SCRUB_METRICS_LISTEN_ADDR")
if addr == "" {
addr = ":8090"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("datenbankverbindung: %v", err)
}
defer pool.Close()
mux := http.NewServeMux()
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
total, err := scrub.FindingsTotal(r.Context(), pool)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
// Counter (Akzeptanzkriterium/Nutzervorgabe: monoton steigend, kein
// Gauge) - kein Befund => Wert 0, kein Dauer-Alarm ("kein Befund
// bedeutet kein Alarm", nicht "kein Wert").
body := fmt.Sprintf(
"# HELP nexarch_archive_storage_integrity_failures_total Anzahl seit Einrichtung gefundener Pruefsummen-Abweichungen (BAK-08).\n"+
"# TYPE nexarch_archive_storage_integrity_failures_total counter\n"+
"nexarch_archive_storage_integrity_failures_total %d\n", total)
if _, err := w.Write([]byte(body)); err != nil {
log.Printf("scrub-metrics: antwort schreiben: %v", err)
}
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("scrub-metrics: listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("http server: %v", err)
}
}
+97
View File
@@ -0,0 +1,97 @@
# BAK-01 Prüfprotokoll: Datenbank-Backup-Strategie
Welle 1, keine Vorbedingungen. Neues Modul-Verzeichnis `code/archive/`
(gleiches Monorepo-Muster wie `code/dms/`), eigenes Go-Modul
`gitea.perlbach24.de/scripte/nexarch/archive`.
## Grundsatzentscheidung: PostgreSQL-17-natives inkrementelles Backup
`pg_dump` kennt nur logische Vollsicherungen — "inkrementell" im Sinne des
Tickets erfordert das physische Backup-Verfahren. Gewählt: PostgreSQL 17s
natives `pg_basebackup --incremental` (WAL-Summarization), NICHT klassisches
WAL-Archiving (`archive_mode`), weil letzteres einen Neustart der
(geteilten, auch von Core/DMS-Tests genutzten) Postgres-Instanz auf
192.168.1.131 erfordert hätte. Stattdessen `summarize_wal = on` gesetzt —
nur ein `pg_reload_conf()`, kein Neustart, keine Unterbrechung laufender
Verbindungen (per Health-Check nach der Änderung bestätigt).
Voraussetzung geschaffen: Rolle `nexarch_backup` mit `REPLICATION`-Attribut
angelegt (Postgres verlangt eine Replikationsverbindung für
`pg_basebackup`), `pg_hba.conf` erlaubte lokale Replikationsverbindungen
bereits.
## Umsetzung
- `internal/backup.FullBackup`/`IncrementalBackup` — rufen `pg_basebackup`
über `os/exec` auf, Ergebnis landet in einer Generationsstruktur
(`<BackupDir>/<Generation>/full/` bzw. `.../incremental/<ID>/`).
- `internal/backup.Verify` — öffnet `base.tar.gz` vollständig (gzip- UND
tar-Stream, jeder Eintrag bis zum Ende gelesen, nicht nur Kopfdaten) —
Akzeptanzkriterium 2: Verifikation auf Lesbarkeit, nicht nur Erstellung.
- `internal/backup.Rotate`/`ListGenerations` — Generationen sind nach
Zeitstempel-ID sortierbar, `Rotate` entfernt die ältesten bis auf `keep`
komplett (inklusive aller abhängigen Inkremente).
- `cmd/backup-cli``full`/`incremental`/`rotate`, aufgerufen von
systemd-Timern (`deploy/systemd/nexarch-archive-backup-*.timer`) —
"automatisiert nach Zeitplan" (Akzeptanzkriterium 1) entsteht durch die
Timer-Definition, kein zusätzlicher Dauerprozess nötig.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Sicherung gegen Testdatenbank erfolgreich erstellt und verifiziert | **bestanden**`TestFullBackup_CreatesVerifiedBackup` gegen die echte Postgres-17-Instanz auf 192.168.1.131 (kein Mock), zusätzlich `TestIncrementalBackup_IsSmallerThanFull`: inkrementelle Sicherung real deutlich kleiner als Vollsicherung (167 KB vs. 16 MB bei der ersten manuellen Probe) — beweist echte inkrementelle Übertragung, nicht nur eine zweite Vollsicherung |
| 2 | Verifikation erkennt eine absichtlich beschädigte Sicherungsdatei | **bestanden**`TestVerify_DetectsCorruptedFile`: 64 Bytes in der Mitte von `base.tar.gz` gekippt, `Verify` schlägt danach fehl (unbeschädigt zuvor erfolgreich) |
| 3 | Rotationsregel entfernt nachweislich nur die ältesten Generationen | **bestanden**`TestRotate_RemovesOnlyOldestGenerations`: 5 Generationen, `keep=2`, exakt die 3 ältesten entfernt, die 2 neuesten nachweislich unangetastet |
## Echte Verdrahtung auf 192.168.1.131 (nicht nur Testcode)
Anders als die zuletzt in DMS gefundenen "Baustein existiert, ist aber
nirgends verdrahtet"-Fälle (FDN-03/FDN-09 gegen Core) wurde hier die
komplette Kette tatsächlich installiert und ausgeführt:
- `backup-cli` gebaut nach `/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-backup.env` mit den Verbindungsdaten (0600)
- 3 systemd-Timer installiert und aktiviert (`enable --now`):
Vollsicherung täglich 02:00 UTC, Inkrement stündlich, Rotation täglich
03:00 UTC (`systemctl list-timers` bestätigt alle drei scharf)
- Jeder der drei Dienste (`full`/`incremental`/`rotate`) einmal manuell über
`systemctl start` ausgelöst (nicht nur `go test` direkt) — alle drei mit
`status=0/SUCCESS`, Journal bestätigt inhaltlich korrekte Ausgabe
(Vollsicherung erstellt+verifiziert, Inkrement erstellt+verifiziert
gegen die richtige Vorgänger-Generation, Rotation lief ohne Fehler)
## Build/Test-Ergebnis (192.168.1.131, `make check`)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -count=1 -> 4/4 Tests ok, 0 Fehlschläge (echter Postgres 17, kein Mock)
```
## Nachtrag (BAK-02-Sitzung): Backup-Zielverzeichnis korrigiert
`NEXARCH_BACKUP_DIR` zeigte ursprünglich auf `/var/backups/nexarch`
(Root-Dateisystem des Containers, kein dediziertes Dataset) — korrigiert auf
`/var/nexarch-archiv/backups/postgres` (persistentes ZFS-Dataset), siehe
`docs/BAK-02-PRUEFPROTOKOLL.md` Abschnitt „Korrektur an BAK-01" für Details.
Vollsicherung nach der Korrektur erneut über systemd ausgelöst, landet
nachweislich am neuen Ort.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt — inklusive tatsächlicher systemd-Timer-Installation und
manuell ausgelöstem End-to-End-Lauf aller drei Dienste auf dem Testhost,
nicht nur isolierter Testcode.
## Nachtrag (BAK-03): Verify prüft jetzt auch pg_wal.tar.gz
Beim Bau von BAK-03s echtem Restore-Test fiel auf, dass `pg_basebackup`
(Standard-WAL-Methode `stream`) bei `-Ft -z` NEBEN `base.tar.gz` eine
zweite Archivdatei `pg_wal.tar.gz` erzeugt, die `Verify` bislang nie
geprüft hat — eine Sicherung mit beschädigtem WAL-Archiv wäre unbemerkt
nicht crash-konsistent wiederherstellbar gewesen. `Verify` prüft seither
beide Archive vollständig (siehe `BAK-03-PRUEFPROTOKOLL.md`). Das
Sicherungsverfahren selbst (Format, Ort, Rotation) bleibt unverändert.
+93
View File
@@ -0,0 +1,93 @@
# BAK-02 Prüfprotokoll: Objekt-Storage-Backup/Snapshots
Welle 1, keine Vorbedingungen.
## Grundsatzentscheidung: restic statt Eigenbau
Nutzerentscheidung: restic statt einer Neuimplementierung, weil restic alle
vier Akzeptanzkriterien mit ausgereiftem, breit geprüftem Tooling erfüllt
(Content-defined Chunking für Dedup, `check --read-data` für
Vollständigkeit, `forget --keep-last` für Rotation, Repository-Verschlüsselung
ab Werk). Installiert via `apt-get install restic` (Version 0.18.0).
Backup-Quelle ist ein lokaler Verzeichnisbaum — für den FDN-03-`LocalDriver`
direkt dessen Basisverzeichnis. Für S3-gestützte Produktions-Deployments
(Betriebsmodus 2/3 aus `STORAGE-KONZEPT.md` Abschnitt 6.2) wäre ein
vorgelagerter Sync-Schritt (z. B. `rclone`) nötig, um Bucket-Inhalte lokal
zu spiegeln, bevor restic sie sichert — restic sichert Dateibäume, keine
S3-Buckets direkt. Das bleibt hier bewusst unimplementiert (kein konkreter
S3-Produktionsbestand vorhanden, der das aktuell erfordert), aber
architektonisch vorgesehen und dokumentiert (`README.md`).
## Umsetzung
- `internal/objectbackup.InitRepo` — idempotent, erkennt "bereits
initialisiert" am `restic init`-Fehlertext statt zu scheitern.
- `internal/objectbackup.Backup``restic backup --json`, parst die
`summary`-Zeile (mehrere JSON-Zeilen in der Ausgabe, gezielt die mit
`message_type=="summary"` gesucht).
- `internal/objectbackup.Check``restic check [--read-data]` (Akzeptanz-
kriterium 3: Vollständigkeitsprüfung).
- `internal/objectbackup.Forget``restic forget --keep-last N --prune`
(Rotation).
- `cmd/objectbackup-cli``backup <dir>`/`check`/`rotate`, aufgerufen von
systemd-Timern (stündlich/wöchentlich/täglich).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Zweiter Sicherungslauf nach unverändertem Bestand überträgt keine Daten erneut | **bestanden**`TestBackup_UnchangedSecondRunTransmitsNothingNew`: zweiter Lauf gegen unveränderten Bestand liefert `files_new=0`, `files_changed=0`, `files_unmodified=1` |
| 2 | Zwei identische Testdateien belegen nachweislich nur einmal Speicherplatz | **bestanden**`TestBackup_DeduplicatesIdenticalContent`: zwei Dateien mit identischem Inhalt erzeugen `data_blobs=1`, nicht 2 — echter Dedup-Nachweis über restics Content-defined Chunking, nicht nur Namensvergleich |
| 3 | Vollständigkeitsprüfung erkennt ein fehlendes Objekt in der Sicherung | **bestanden**`TestCheck_DetectsCorruptedPack`: ein Byte in einer echten Repository-Pack-Datei gekippt, `Check(readData=true)` schlägt danach fehl (unbeschädigt zuvor erfolgreich) — dieselbe Vorgehensweise wie die manuelle Recherche vor der Implementierung |
Zusätzlich (nicht explizit als Pflichtprüfung gefordert, aber Teil von
Akzeptanzkriterium 3 „lässt sich einzeln prüfen"): `TestForget_
KeepsOnlyRequestedSnapshotCount` — 3 Sicherungsläufe, `Forget(keepLast=1)`
reduziert auf genau 1 verbleibenden Snapshot.
## Korrektur an BAK-01 im selben Rutsch: Backup-Zielverzeichnis
Nutzerhinweis aufgegriffen: `NEXARCH_BACKUP_DIR` zeigte bei BAK-01
ursprünglich auf `/var/backups/nexarch` (Root-Dateisystem des LXC-
Containers, nicht auf einem der beiden dedizierten ZFS-Datasets). Korrigiert
auf `/var/nexarch-archiv/backups/postgres` (persistentes Dataset
`zfs/data/subvol-1131-disk-0`), NICHT `/var/nexarch-test/` (ephemeres
Dataset `ssd-rpool-data/swap/subvol-1131-disk-0`, wird von
`reset-test-env.sh`-Skripten anderer Module geleert). `objectbackup-cli`s
Repository liegt von Anfang an korrekt unter
`/var/nexarch-archiv/backups/objects`. Beide Pfade real auf
192.168.1.131 verifiziert (`df`/`mount` bestätigt ZFS-Dataset-Zuordnung),
BAK-01s Vollsicherung nach der Korrektur erneut über systemd ausgelöst und
bestätigt am neuen Ort gelandet.
ZFS-seitige Snapshot-/Replikations-Strategie für `nexarch/archiv` bleibt
bewusst außerhalb dieses Tickets (Infra-Runbook, siehe
`STORAGE-KONZEPT.md` Abschnitt 7 „Backup vs. Storage-Redundanz" sowie den
Hinweis, `zfs dedup=on` NICHT zu setzen — App-seitige Dedup über restic
genügt, ZFS-Dedup wäre auf dem 4-GB-Testhost ein Speicherrisiko).
## Echte Verdrahtung auf 192.168.1.131
- `objectbackup-cli` gebaut nach `/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-objectbackup.env` (0600)
- 3 systemd-Timer installiert und aktiviert: Sicherung stündlich (`:30`),
Vollständigkeitsprüfung wöchentlich (So. 04:00 UTC), Rotation täglich
(03:30 UTC) — `systemctl list-timers` bestätigt alle scharf
- Jeder der drei Dienste einmal über `systemctl start` ausgelöst, alle mit
`status=0/SUCCESS`; Journal bestätigt inhaltlich korrekte Ausgabe
## Build/Test-Ergebnis (192.168.1.131, `make check`)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -count=1 -> 2/2 Pakete mit Tests ok (internal/backup, internal/objectbackup), 0 Fehlschläge
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real gegen echtes restic-Tooling erfüllt. BAK-01-Pfadfehler im selben
Rutsch korrigiert und erneut end-to-end verifiziert.
+122
View File
@@ -0,0 +1,122 @@
# BAK-03 Prüfprotokoll: Restore-Verfahren
Voraussetzungen BAK-01, BAK-02 erledigt, siehe eigene Protokolle.
## Grundsatzentscheidung: Atomarität über Temp-Verzeichnis + Rename
`internal/restore.AtomicRestore` kennt weder Postgres noch restic —
reine Ablaufsteuerung: Wiederherstellung IMMER in ein frisches
temporäres Verzeichnis (nie direkt in das Ziel), Übernahme erst bei
Erfolg per `os.Rename` (selbes Dateisystem wie das Ziel, daher atomar).
Ist das Ziel nicht leer und keine Bestätigung (`-force`) gegeben, wird
NICHT einmal die Wiederherstellungsfunktion aufgerufen — Abbruch vor
jeder Berührung des Ziels (Akzeptanzkriterium 2). Jeder Aufruf — Erfolg,
Abbruch oder Fehler — erzeugt genau einen JSONL-Protokolleintrag
(Akzeptanzkriterium 3).
Die eigentliche Wiederherstellung bleibt in `internal/backup.Restore`
(Datenbank) und `internal/objectbackup.Restore` (Objekt-Storage) —
`internal/restore` orchestriert nur.
## Drei reale Defekte während der Implementierung gefunden und behoben
Alle drei erst durch den ECHTEN Restore-Test (Postgres-Instanz tatsächlich
gestartet, nicht nur Dateien verglichen) aufgedeckt:
1. **`pg_combinebackup` braucht Plain-Format, BAK-01 liefert Tar+Gzip.**
`Restore` extrahiert jetzt jede Sicherungsstufe zunächst in ein
temporäres Plain-Verzeichnis (inkl. `backup_manifest`-Kopie) und
speist erst DIESE in `pg_combinebackup`. BAK-01s Speicherformat selbst
unverändert (kleinere, leichter prüfbare Dateien).
2. **`pg_wal.tar.gz` wurde nie verifiziert oder wiederhergestellt.**
`pg_basebackup`s Standard-WAL-Methode (`stream`) erzeugt bei `-Ft -z`
eine ZWEITE Archivdatei neben `base.tar.gz` — ohne sie ist keine
crash-konsistente Wiederherstellung möglich (Postgres findet sonst
keinen gültigen Checkpoint). `backup.Verify` prüft jetzt BEIDE
Archive vollständig; `backup.Restore` extrahiert das WAL der ZULETZT
gezogenen Stufe (nicht aller Stufen) nach `pg_wal/` im
Wiederherstellungsziel. Ergänzung zu BAK-01s Prüfung, keine
Umstellung des Sicherungsverfahrens.
3. **Go-`exec`-Hänger bei `pg_ctl start`:** `pg_ctl` startet Postgres als
Hintergrundprozess, der die geerbten stdout/stderr-Pipes NICHT
schließt — `CombinedOutput()` (wartet auf Pipe-EOF) hängt sich
dadurch auf, obwohl `pg_ctl` selbst längst zurückgekehrt ist. Fix nur
im Testcode: echte Logdatei (`-l`) statt Pipe, Standard-`pg_ctl`-Muster.
Zusätzlich real (nicht Ticket-relevant, aber notiert): auf diesem
Debian-Postgres liegen `postgresql.conf`/`pg_hba.conf` NICHT in PGDATA
(sondern `/etc/postgresql/17/main/`) — ein echtes Restore-Runbook muss
diese Dateien separat mitführen/rekonstruieren, `pg_basebackup` sichert
sie nicht. Für den Testnachweis minimal nachgereicht, kein Produktcode.
## Umsetzung
- `internal/restore.AtomicRestore` — Ablaufsteuerung, JSONL-Protokoll
(`Timestamp`, `Kind`, `Source`, `Target`, `Result`).
- `internal/backup.Restore``pg_combinebackup` gegen extrahierte
Plain-Verzeichnisse + WAL-Wiederherstellung der letzten Stufe.
- `internal/backup.extractTarGz` (in `verify.go`, neben der bestehenden
Tar/Gzip-Leselogik) — vollständige Extraktion, kein Kopf-only-Read.
- `internal/backup.Verify` — prüft jetzt `base.tar.gz` UND
`pg_wal.tar.gz` vollständig.
- `internal/objectbackup.Restore``restic restore --target`.
- `cmd/restore-cli``database`/`objects`-Unterbefehle, `-force`-Flag
(vor Positionsargumenten, Go-`flag`-Konvention), kein systemd-Timer
(Notfall-/Einzelfall-Werkzeug, kein Zeitplan).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Restore auf leerem System vollständig und erfolgreich durchgeführt | **bestanden** — real: `TestRestore_CombinesIntoStartablePostgresInstance` (Postgres tatsächlich aus wiederhergestelltem Verzeichnis gestartet, `SELECT 1` über echte Verbindung beantwortet); zusätzlich real per `restore-cli database` auf 131 ausgeführt (PG_VERSION/base/pg_wal vorhanden, Exit 0); `TestRestore_RecoversRealContentFromSnapshot` (Objekt-Storage, echter Dateiinhalt verglichen) UND real per `restore-cli objects` auf 131 (echter restic-Snapshot wiederhergestellt) |
| 2 | Restore auf nicht-leeres Zielverzeichnis lässt bei Abbruch den ursprünglichen Inhalt unverändert | **bestanden**`TestAtomicRestore_NonEmptyTargetWithoutForce_LeavesOriginalUntouched` (restoreFn wird nachweislich NIE aufgerufen) und `TestAtomicRestore_FailedRestoreFn_LeavesTargetUntouched`; real auf 131: `restore-cli database` gegen nicht-leeres Ziel ohne `-force` → Abbruch, bestehende Testdatei unverändert vorhanden |
| 3 | Protokolleintrag zum Restore ist vollständig und nachvollziehbar | **bestanden**`TestAtomicRestore_LogsCompleteEntry` (Quelle, Ziel, Zeitpunkt, Ergebnis für Erfolgs- UND Abbruchfall in derselben Datei); real auf 131: `/tmp/restore-cli-test.log` zeigt alle vier realen Läufe (Abbruch, Fehler mangels PATH, Erfolg, Force-Überschreiben) korrekt protokolliert |
Zusätzlich: `TestAtomicRestore_EmptyTarget_Succeeds`,
`TestAtomicRestore_ForceOverwritesNonEmptyTarget` (bewusste Bestätigung
ersetzt bestehenden Inhalt real, alter Inhalt nachweislich weg, neuer da).
## Echte Verdrahtung auf 192.168.1.131
- `restore-cli` gebaut nach `/opt/nexarch-archive/bin/`
- Kein systemd-Timer (bewusst — Notfall-/Einzelfall-Werkzeug)
- Vier reale CLI-Läufe durchgeführt und protokolliert: Abbruch bei
nicht-leerem Ziel ohne `-force`, Fehler mangels `pg_combinebackup` im
PATH (zeigt: Fehler wird korrekt erkannt UND protokolliert, kein
stiller Fehlschlag), erfolgreicher Restore einer echten Generation
(`20260829T222054Z`) in leeres Ziel, erfolgreiches Überschreiben mit
`-force`. Objekt-Storage-Restore real gegen echten restic-Snapshot
(`43da36bf`) — Inhalt (`.placeholder`-Datei) tatsächlich vorhanden.
- Alle Testartefakte (`/tmp/restore-*`, Testlog) nach Prüfung entfernt.
## Build/Test-Ergebnis
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
```
`internal/restore`: 5/5 Tests (reine Funktionen, kein Setup nötig).
`internal/objectbackup`: `TestRestore_RecoversRealContentFromSnapshot`
bestanden (zusammen mit den bestehenden BAK-02-Tests).
`internal/backup`: `TestRestore_CombinesIntoStartablePostgresInstance`
bestanden — **Hinweis**: dieser eine Test läuft NICHT als root
(`pg_ctl: cannot be run as root`) und braucht `pg_combinebackup`/
`pg_ctl` im PATH (`/usr/lib/postgresql/17/bin` auf Debian, dort nicht
standardmäßig verlinkt) — daher separat als `postgres`-Systemnutzer mit
entsprechendem PATH ausgeführt, nicht Teil des root-`make check`-Laufs;
dort wird er mit klarer Meldung übersprungen (`pg_combinebackup nicht
installiert`), kein stiller Fehlschlag. Alle übrigen BAK-01-Tests
(inkl. der um `pg_wal.tar.gz` erweiterten `Verify`) liefen unverändert
grün im normalen `make check`-Lauf.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt — beide Restore-Pfade (Datenbank,
Objekt-Storage) sowohl testgetrieben als auch manuell über `restore-cli`
auf echter Infrastruktur nachgewiesen. Drei während der Implementierung
gefundene reale Defekte (Tar-vs-Plain-Format, fehlende WAL-Sicherung/
-Verifikation, Go-exec-Pipe-Hänger) behoben und dokumentiert, nicht
stillschweigend umgangen.
+119
View File
@@ -0,0 +1,119 @@
# BAK-05 Prüfprotokoll: Reconciliation / Konsistenzprüfung Storage vs. DB
Voraussetzung BAK-01, BAK-02 (Welle 1) erledigt, siehe eigene Protokolle.
## Grundsatzentscheidung: reine Funktion + zwei Quell-Adapter
`internal/reconcile.Reconcile` ist eine reine Funktion ohne DB-/Storage-
Zugriff (leicht ohne echte Infrastruktur testbar), die Ein- und
Auslesen echter Systeme ist strikt in `sources.go` getrennt
(`ListDBStorageKeys` gegen echtes Postgres, `ListStorageObjects` gegen
echtes Dateisystem). Beide Seiten liefern nur SCHLÜSSEL niemals Inhalt
dadurch bleibt BAK-05 sauber getrennt von BAK-08 (Inhalts-/Prüfsummen-
verifikation, eigene Fehlerklasse, eigenes Ticket).
Report-Format bewusst deterministisch: alle drei Ergebnislisten
(`missing_in_storage`, `orphaned_in_storage`, `existing_in_storage`)
nach `storage_key` aufsteigend sortiert.
**Nachtrag (nach Rückfrage vor BAK-08-Start):** Der ursprüngliche Report
enthielt nur die beiden Abweichungslisten keine Liste der bestätigt
existierenden Objekte. Für BAK-08 als Stichprobengrundlage reicht
"keine Abweichung" nicht, es braucht die tatsächliche, deterministisch
sortierte Liste. Ergänzt: `Report.ExistingInStorage` DB-Eintrag UND
Storage-Objekt beide vorhanden, reine Existenzbestätigung (keine
Inhaltsprüfung, Scope-Trennung zu BAK-08 bleibt gewahrt), aufsteigend
nach `storage_key` sortiert. BAK-08 zieht seine Stichprobe daraus, ohne
selbst zu sortieren/filtern. Neuer Test
`TestReconcile_ExistingInStorageIsStableSamplingBasis` beweist Inhalt
und Sortierung. Real neu gebaut, getestet (9/9) und auf 131 erneut
ausgelöst Journal zeigt das Feld `existing_in_storage` im Report.
Meldeweg über OPS-05 (wie später BAK-08) wurde als offene Design-Frage
aufgeworfen, aber nicht zur Vorbedingung gemacht hier bewusst noch
nicht umgesetzt (kein OPS-05-Abhängigkeitseintrag im Board für BAK-05);
Report wird aktuell nur als JSON auf stdout ausgegeben und per
Exit-Code (1 bei Abweichungen) für systemd/Monitoring sichtbar gemacht.
Anbindung an OPS-05 kann bei Bedarf nachgezogen werden, ohne
`Reconcile` selbst zu ändern.
## Umsetzung
- `internal/reconcile.Reconcile(dbEntries, storageKeys) Report` reine
Vergleichsfunktion, liefert `MissingInStorage`/`OrphanedInStorage`,
`Report.IsClean()` als eindeutiges Sauber-Merkmal.
- `internal/reconcile.ListDBStorageKeys` liest `file_revisions`
(DMS FDN-02) per direktem SQL aus derselben physischen Tenant-DB
(Modell C, Core TEN-01) kein Import von DMS-Go-Paketen möglich
(eigenes Go-Modul), daher reiner SQL-Zugriff gegen das dokumentierte
Schema.
- `internal/reconcile.ListStorageObjects` durchläuft den lokalen
FDN-03-`LocalDriver`-Basisordner (`filepath.WalkDir`), liefert `nil,
nil` bei fehlendem Verzeichnis statt Fehler (noch keine Objekte ist
kein Fehlerzustand).
- `cmd/reconcile-cli` liest `NEXARCH_RECONCILE_TENANT_DSN` und
`NEXARCH_RECONCILE_STORAGE_DIR`, gibt Report als JSON auf stdout aus,
Exit-Code 1 bei Abweichungen.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Datenbankeintrag ohne Storage-Objekt wird erkannt | **bestanden**`TestReconcile_DetectsMissingInStorage` |
| 2 | Storage-Objekt ohne Datenbankeintrag wird erkannt | **bestanden**`TestReconcile_DetectsOrphanedInStorage` |
| 3 | Lauf ohne Abweichungen liefert leeren, eindeutig sauberen Bericht | **bestanden**`TestReconcile_CleanRunProducesEmptyReport` (zusätzlich `IsClean()`-Konsistenzprüfung) |
Zusätzlich (Nutzervorgaben, nicht explizit im Ticket als Pflichtprüfung
benannt, aber zentral für die Abgrenzung/Weiterverwendbarkeit):
- `TestReconcile_ExistingButCorruptedObjectProducesNoFinding` Nachweis,
dass Reconcile AUSSCHLIESSLICH Existenz prüft, niemals Inhalt (Trennung
von BAK-08).
- `TestReconcile_DeterministicOrdering` zwei Läufe mit identischer
Eingabe liefern identische Reihenfolge, aufsteigend nach `storage_key`.
- `TestListDBStorageKeys_ReadsRealFileRevisions` liest echt gegen die
gemeinsame Tenant-Testdatenbank `dms_tenant_test` (reales DMS-FDN-02-
Schema, kein Mock).
- `TestListStorageObjects_WalksRealDirectory` /
`_MissingDirectoryReturnsEmpty` echtes Dateisystem, kein Mock.
## Echte Verdrahtung auf 192.168.1.131
- `reconcile-cli` gebaut nach `/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-reconcile.env` (0600): `NEXARCH_RECONCILE_TENANT_DSN`
zeigt auf die gemeinsame Tenant-Testdatenbank `dms_tenant_test`
(DMS selbst läuft auf 192.168.1.131 noch nicht als eigener systemd-
Dienst mit persistenter Konfiguration dies ist die real verfügbare
Tenant-DB mit echtem FDN-02-Schema, dokumentierter bekannter Stand,
kein stiller Mock); `NEXARCH_RECONCILE_STORAGE_DIR` zeigt auf
`/var/nexarch-archiv/dms-objects` (persistentes ZFS-Dataset, NICHT
`/var/nexarch-test/`).
- Timer `nexarch-archive-reconcile.timer` installiert und aktiviert
(täglich 05:00 UTC), `systemctl list-timers` bestätigt scharf.
- `systemctl start nexarch-archive-reconcile.service` real ausgelöst:
`status=0/SUCCESS`, Journal zeigt echten JSON-Report
(`missing_in_storage: null, orphaned_in_storage: null` Tenant-DB
aktuell leer, daher sauberer Bericht, keine synthetische Ausgabe).
## Build/Test-Ergebnis (192.168.1.131, `make check`)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -count=1 -> 3/3 Pakete mit Tests ok (internal/backup, internal/objectbackup, internal/reconcile), 0 Fehlschläge
```
`internal/reconcile`-Tests separat mit gesetzter `TEST_TENANT_DSN` gegen
`dms_tenant_test` verifiziert: 9/9 Tests bestanden (6 reine
`Reconcile`-Tests + 3 `sources.go`-Integrationstests).
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle Pflicht- sowie
Nutzervorgaben-Prüfungen real erfüllt (echte Postgres-Instanz, echtes
Dateisystem, echter systemd-Lauf). Zwei Testfehler während der
Entwicklung (Schema-Abweichung `revision_number` NOT NULL in der realen
`dms_tenant_test`-Tabelle; inkonsistente Fixture-Daten in
`TestReconcile_DeterministicOrdering`) gefunden und korrigiert beide
waren Testautorenfehler, keine Fehler in `Reconcile` selbst.
+140
View File
@@ -0,0 +1,140 @@
# BAK-08 Prüfprotokoll: Checksum-basierte Objekt-Integritätsprüfung
Voraussetzungen BAK-05, FDN-04, FDN-09, OPS-05 alle erledigt, siehe
eigene Protokolle. Vor Start zwei offene Rückfragen geklärt (siehe unten).
## Grundsatzentscheidung: eigener Zustand statt file_revisions.created_at
`created_at` als Alterskriterium hätte immer dieselben "ältesten" Objekte
gescrubbt und den Rest nie erreicht — kein echtes Rotationsverhalten.
Stattdessen eigene Archive-Tabelle `scrub_state` (`storage_key`
`last_scrubbed_at`, `last_result`), Migration
`migrations/0001_scrub_state.up.sql`. `internal/scrub.Sample` ist eine
reine Funktion: nimmt BAK-05s `existing_in_storage` (deterministisch
sortiert) entgegen, filtert Objekte innerhalb der konfigurierbaren
Cooldown-Frist heraus, priorisiert danach nach `last_scrubbed_at`
aufsteigend (nie geprüft = ältestmöglicher Wert), begrenzt auf die
konfigurierte Stichprobengröße — kein Voll-Sort über den gesamten
Bestand bei jedem Lauf (Nutzerhinweis zum Kostenfaktor bei 10⁵+
Objekten: die WHERE-artige Cooldown-Filterung reduziert die Kandidatenmenge
VOR der Sortierung, nur die Kandidaten selbst werden sortiert, nicht der
komplette Bestand).
## Nachtrag: zwei Rückfragen vor Implementierungsbeginn geklärt
1. **OPS-05-Anbindung ist Pull, nicht Push.** OPS-05 (`internal/alerting`,
Core) ist real implementiert, aber Core OPS-03 scrapt `/metrics`-URLs
registrierter Module (`metrics_sources`-Tabelle in der Core-Registry-
DB, `SourceStore.RegisterSource`) — kein Push-API. Für BAK-08 daher
ein eigener, DAUERHAFT laufender Endpunkt (`cmd/scrub-metrics`,
getrennt vom Oneshot-`scrub-cli`, dessen Prozess nach jedem Lauf endet
und zum Scrape-Zeitpunkt nicht erreichbar wäre). Metrik als Counter
(`nexarch_archive_storage_integrity_failures_total`), monoton
steigend — kein Gauge, kein Rücksetzen bei behobenem Befund. Kein
Befund = Wert bleibt unverändert (kein Dauer-Alarm durch andauernden
"Fehler"-Zustand). Scope-Trennung gewahrt: `scrub-cli`/`scrub-metrics`
erzeugen selbst KEIN Alert-Objekt — Schwellwert/Drosselung bleiben
OPS-05-eigene Konfiguration (Alert-Regel wird separat über
`alerting.RuleStore.CreateRule` angelegt, nicht Teil dieses Tickets).
**CFG-04 war eine Verwechslung** (das ist die
Benachrichtigungs-Einstellungen-Oberfläche, ein anderes Ticket) — die
tatsächlich nötige "Config"-Aktion ist ein `INSERT` in
`metrics_sources` (Core-Registry-DB), kein UI/Ticket-Abhängigkeit.
Real ausgeführt (siehe „Echte Verdrahtung" unten).
2. **Sampling-Kriterium.** Siehe Grundsatzentscheidung oben —
`scrub_state.last_scrubbed_at` statt `file_revisions.created_at`,
Cooldown-Filterung vor Sortierung, feste Stichprobengröße (Top-N,
deterministisch, keine Zufallsstichprobe — Nutzerpräferenz für
Reproduzierbarkeit im Protokoll).
## Umsetzung
- `migrations/0001_scrub_state.up.sql`/`.down.sql``scrub_state`,
`scrub_counters` (Einzelzeile, monotoner Zähler).
- `internal/scrub.Sample` — reine Funktion, Cooldown-Filter + Alt-
Priorisierung + Stichprobenbegrenzung.
- `internal/scrub.LoadLastScrubbed`/`MarkScrubbed`/`RecordFinding`/
`FindingsTotal` — DB-Zugriff auf `scrub_state`/`scrub_counters`,
`MarkScrubbed` idempotent (`ON CONFLICT`) für unterbrechbare Läufe.
- `internal/scrub.ExpectedChecksums` — eigene, minimale Abfrage gegen
`file_revisions` (keine Erweiterung von `reconcile.DBEntry` — BAK-05
bleibt existenz-only).
- `internal/scrub.ActualChecksum` — echtes Lesen der Datei + SHA-256,
kein Header-/Größenvergleich.
- `cmd/scrub-cli` — Oneshot: BAK-05-Reconcile → `Sample` → pro Kandidat
Checksum-Vergleich → `MarkScrubbed` + bei Abweichung `RecordFinding`
JSON-Bericht auf stdout, Exit-Code 1 bei Befunden (gemeldet, nicht
automatisch repariert).
- `cmd/scrub-metrics` — dauerhafter `/metrics`-Endpunkt, liest
`scrub_counters.findings_total`.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Absichtlich veränderter Objektinhalt wird als Abweichung erkannt | **bestanden** — real: Testobjekt mit absichtlich falscher `checksum_sha256` in `dms_tenant_test` angelegt, echte Datei ins Storage-Verzeichnis gelegt, `scrub-cli` real über systemd ausgelöst: Befund im JSON-Bericht, Exit-Code 1, `scrub_counters.findings_total` real von 0 auf 1 erhöht (siehe Journal-Auszug unten) |
| 2 | Sampling priorisiert alte/nie geprüfte Objekte, nicht neue | **bestanden**`TestSample_PrioritizesNeverScrubbedAndOldest`: nie geprüftes Objekt kommt vor einem vor 30 Tagen geprüften, dieses vor einem vor 1 Tag geprüften |
| 3 | Wiederholter Lauf ohne neue Objekte meldet nichts erneut (kein Spam) / idempotent bei Unterbrechung | **bestanden** — real: zweiter `scrub-cli`-Lauf direkt nach dem ersten liefert `sampled: 0` (Cooldown greift), `TestMarkScrubbed_IsIdempotent` beweist wiederholtes Markieren ohne Duplikat |
Zusätzlich: `TestSample_RespectsCooldown`,
`TestSample_LimitsToSampleSize`, `TestSample_DeterministicForIdenticalInput`,
`TestRecordFinding_IsMonotonicallyIncreasing`,
`TestActualChecksum_MatchesRealFileContent` (echter Dateiinhalt, echtes
SHA-256), `TestExpectedChecksums_ReadsRealFileRevisions` (echtes
Postgres, kein Mock).
## Echte Verdrahtung auf 192.168.1.131
- `scrub-cli`, `scrub-metrics` gebaut nach `/opt/nexarch-archive/bin/`
- `/etc/nexarch/archive-scrub.env`, `/etc/nexarch/archive-scrub-metrics.env`
(0600)
- Migration real gegen `dms_tenant_test` angewendet
(`psql -f migrations/0001_scrub_state.up.sql`)
- `nexarch-archive-scrub.timer` installiert/aktiviert (täglich 06:00
UTC), `nexarch-archive-scrub-metrics.service` installiert/aktiviert
(dauerhaft, `Restart=on-failure`) — beide `systemctl status`: aktiv
- **Reales `INSERT` in `metrics_sources`** (Core-Registry-DB
`nexarch_registry`): `('archive', 'http://127.0.0.1:8090/metrics')`
bestätigt über `SELECT * FROM metrics_sources`
- **End-to-End über OPS-03 bestätigt**: `curl http://127.0.0.1:8085/metrics`
(Core-Aggregator) zeigt `nexarch_module_archive_nexarch_archive_storage_integrity_failures_total`
— reale Umbenennung gemäß OPS-03-Namenskonvention, kein synthetischer
Wert
- Realer Befund-Durchlauf: Testobjekt mit absichtlich falscher Prüfsumme
angelegt → `scrub-cli` real via `systemctl start` ausgelöst → Befund im
Journal, `scrub_counters.findings_total` real 0→1, sichtbar sowohl auf
`scrub-metrics` als auch über den Core-Aggregator → Testdaten
anschließend bereinigt (`file_revisions`/`documents`/`users`-Zeilen
gelöscht, `scrub_state`/`scrub_counters` zurückgesetzt, Testdatei
entfernt)
## Build/Test-Ergebnis (192.168.1.131, `make check`)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -count=1 -> 4/4 Pakete mit Tests ok (internal/backup, internal/objectbackup, internal/reconcile, internal/scrub), 0 Fehlschläge
```
`internal/scrub`-Tests separat mit gesetzter `TEST_TENANT_DSN` gegen
`dms_tenant_test` verifiziert: 8/8 Tests bestanden.
## Bekannte Grenze (aus Ticket übernommen, nicht Teil der Abnahme)
Der Job erkennt Abweichungen nur bei Objekten, die gelesen und erneut
geprüft werden können. Ersetzt keine storage-seitige WORM-/
Versionierungsstrategie und keine Zugriffs-/Audit-Logs des
Storage-Providers (`STORAGE-KONZEPT.md` Abschnitt 6.1) — bei extern
eingebundenem, nicht-kompatiblem Kunden-Storage (Betriebsmodus 3, ohne
Versioning/Object Lock/Audit-Logs) bleibt eine Lücke, die BAK-08
technisch nicht schließen kann.
## Gesamtergebnis
**Bestanden.** Alle sechs Akzeptanzkriterien und alle drei Pflicht-
prüfungen real erfüllt — inklusive echtem Ende-zu-Ende-Nachweis über
Core OPS-03/OPS-05 (kein Stub, reale `/metrics`-Registrierung und
-Aggregation). Beide vor Implementierungsbeginn gestellten Rückfragen
(OPS-05-Anbindungsmechanismus, Sampling-Kriterium) im Protokoll
dokumentiert und in der Umsetzung berücksichtigt.
+14
View File
@@ -0,0 +1,14 @@
module gitea.perlbach24.de/scripte/nexarch/archive
go 1.22
require github.com/jackc/pgx/v5 v5.6.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
-2
View File
@@ -1,8 +1,6 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+199
View File
@@ -0,0 +1,199 @@
// Package backup implementiert BAK-01: automatisierte, inkrementelle
// Sicherung der PostgreSQL-Datenbank per pg_basebackup (PostgreSQL 17s
// natives inkrementelles Backup über WAL-Summarization, siehe
// `summarize_wal`), mit Verifikation jeder Sicherung und
// generationsbasierter Rotation. Kein pg_dump-basierter Ansatz, weil
// pg_dump ausschließlich logische Vollsicherungen kennt — "inkrementell"
// im Sinne des Tickets erfordert das physische, WAL-summary-gestützte
// Verfahren aus PostgreSQL 17.
package backup
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"time"
)
// Config enthält die Verbindungsdaten für pg_basebackup — ausschließlich
// über Umgebungsvariablen befüllt, nie im Code (siehe Ticket-Abschluss-
// Regel).
type Config struct {
Host string
Port string
User string
Password string
BackupDir string
PgBaseBackupPath string // Default "pg_basebackup", überschreibbar für Tests
PgCombineBackupPath string // Default "pg_combinebackup", überschreibbar für Tests
}
func (c Config) binary() string {
if c.PgBaseBackupPath != "" {
return c.PgBaseBackupPath
}
return "pg_basebackup"
}
// FullBackupDirName/IncrementalDirName sind die festen Unterverzeichnis-
// namen je Generation.
const (
FullBackupDirName = "full"
IncrementalSubdir = "incremental"
BackupManifestFile = "backup_manifest"
BaseTarGzFile = "base.tar.gz"
// WalTarGzFile: pg_basebackups Standard-WAL-Methode ist "stream" (WAL
// wird waehrend der Sicherung parallel mitgestreamt) - bei -Ft/-z
// landet dieser Strom in einer EIGENEN Archivdatei neben base.tar.gz.
// Ohne dieses WAL ist die Sicherung NICHT crash-konsistent
// wiederherstellbar (Postgres kann sonst keinen gueltigen Checkpoint
// erreichen) - siehe Restore.
WalTarGzFile = "pg_wal.tar.gz"
)
// NewGenerationID liefert eine sortierbare, eindeutige Generation-Kennung
// (RFC3339-artig, dateisystemtauglich) — Generationen werden anhand dieser
// Kennung chronologisch sortiert (Rotate, ListGenerations).
func NewGenerationID(t time.Time) string {
return t.UTC().Format("20060102T150405Z")
}
// FullBackup erstellt eine neue Vollsicherung (Akzeptanzkriterium 1) als
// eigene Generation. Liefert den Pfad zum backup_manifest, das spätere
// IncrementalBackup-Aufrufe als Referenz brauchen.
func FullBackup(ctx context.Context, cfg Config, generationID string) (manifestPath string, err error) {
dir := filepath.Join(cfg.BackupDir, generationID, FullBackupDirName)
if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
return "", fmt.Errorf("backup: generationsverzeichnis anlegen: %w", err)
}
args := []string{
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
"-D", dir, "-Ft", "-z", "--checkpoint=fast", "--no-password",
}
if err := runPgBaseBackup(ctx, cfg, args); err != nil {
return "", fmt.Errorf("backup: vollsicherung: %w", err)
}
return filepath.Join(dir, BackupManifestFile), nil
}
// IncrementalBackup erstellt eine inkrementelle Sicherung gegen die zuletzt
// bekannte Vollsicherung ODER die letzte Inkrement-Sicherung (priorManifestPath
// zeigt jeweils auf das backup_manifest der Referenz).
func IncrementalBackup(ctx context.Context, cfg Config, generationID, incrementID, priorManifestPath string) (manifestPath string, err error) {
dir := filepath.Join(cfg.BackupDir, generationID, IncrementalSubdir, incrementID)
if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
return "", fmt.Errorf("backup: inkrement-verzeichnis anlegen: %w", err)
}
args := []string{
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User,
"-D", dir, "-Ft", "-z", "--checkpoint=fast", "--no-password",
"--incremental=" + priorManifestPath,
}
if err := runPgBaseBackup(ctx, cfg, args); err != nil {
return "", fmt.Errorf("backup: inkrementelle sicherung: %w", err)
}
return filepath.Join(dir, BackupManifestFile), nil
}
// Restore kombiniert die Vollsicherung einer Generation mit allen ihren
// Inkrementen (PostgreSQL 17s `pg_combinebackup`, das native Gegenstück zu
// `pg_basebackup --incremental`) und schreibt das Ergebnis nach outputDir —
// ein vollständiges, direkt startbares PGDATA-Verzeichnis. outputDir muss
// bereits existieren und leer sein (pg_combinebackup-Vorgabe); Atomarität
// gegenüber einem eventuell nicht-leeren ENDZIEL ist Aufgabe von
// internal/restore, nicht dieser Funktion.
//
// pg_combinebackup erwartet PLAIN-Format-Eingabeverzeichnisse (Dateibaum),
// FullBackup/IncrementalBackup speichern aber TAR+GZIP (`-Ft -z`, siehe
// dort) — kleinere, einfacher zu prüfende Sicherungsdateien
// (BAK-01-Entscheidung, hier NICHT verändert). Restore extrahiert daher
// jede Stufe zunächst in ein eigenes temporäres Plain-Verzeichnis, bevor
// pg_combinebackup darauf zugreift.
func Restore(ctx context.Context, cfg Config, generationID, outputDir string) error {
genDir := filepath.Join(cfg.BackupDir, generationID)
tarDirs := []string{filepath.Join(genDir, FullBackupDirName)}
incrDir := filepath.Join(genDir, IncrementalSubdir)
entries, err := os.ReadDir(incrDir)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("backup: inkrement-verzeichnis lesen: %w", err)
}
incrementIDs := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
incrementIDs = append(incrementIDs, e.Name())
}
}
sort.Strings(incrementIDs) // Inkrement-IDs sind wie Generation-IDs chronologisch sortierbar
for _, id := range incrementIDs {
tarDirs = append(tarDirs, filepath.Join(incrDir, id))
}
extractRoot, err := os.MkdirTemp("", "backup-restore-extract-*")
if err != nil {
return fmt.Errorf("backup: extraktions-verzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(extractRoot) }()
inputs := make([]string, 0, len(tarDirs))
for i, tarDir := range tarDirs {
plainDir := filepath.Join(extractRoot, fmt.Sprintf("%02d", i))
if err := os.MkdirAll(plainDir, 0o700); err != nil {
return fmt.Errorf("backup: plain-verzeichnis anlegen: %w", err)
}
if err := extractTarGz(filepath.Join(tarDir, BaseTarGzFile), plainDir); err != nil {
return fmt.Errorf("backup: %q extrahieren: %w", tarDir, err)
}
// backup_manifest liegt NEBEN base.tar.gz (von pg_basebackup so
// geschrieben), nicht im Archiv selbst - pg_combinebackup braucht
// es zusätzlich im Plain-Verzeichnis.
manifestData, err := os.ReadFile(filepath.Join(tarDir, BackupManifestFile))
if err != nil {
return fmt.Errorf("backup: %q lesen: %w", filepath.Join(tarDir, BackupManifestFile), err)
}
if err := os.WriteFile(filepath.Join(plainDir, BackupManifestFile), manifestData, 0o600); err != nil {
return fmt.Errorf("backup: manifest nach %q kopieren: %w", plainDir, err)
}
inputs = append(inputs, plainDir)
}
binary := "pg_combinebackup"
if cfg.PgCombineBackupPath != "" {
binary = cfg.PgCombineBackupPath
}
args := append(append([]string{}, inputs...), "-o", outputDir)
cmd := exec.CommandContext(ctx, binary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup: %s fehlgeschlagen: %w (ausgabe: %s)", binary, err, string(output))
}
// pg_combinebackup rekonstruiert nur die Datendateien - das fuer einen
// konsistenten Start noetige WAL kommt aus der ZULETZT gezogenen Stufe
// (letztes Inkrement, sonst die Vollsicherung), nicht aus allen Stufen
// zusammen (siehe WalTarGzFile-Dokumentation).
lastTarDir := tarDirs[len(tarDirs)-1]
walDir := filepath.Join(outputDir, "pg_wal")
if err := os.MkdirAll(walDir, 0o700); err != nil {
return fmt.Errorf("backup: pg_wal-verzeichnis anlegen: %w", err)
}
if err := extractTarGz(filepath.Join(lastTarDir, WalTarGzFile), walDir); err != nil {
return fmt.Errorf("backup: WAL aus %q wiederherstellen: %w", lastTarDir, err)
}
return nil
}
func runPgBaseBackup(ctx context.Context, cfg Config, args []string) error {
cmd := exec.CommandContext(ctx, cfg.binary(), args...)
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s fehlgeschlagen: %w (ausgabe: %s)", cfg.binary(), err, string(output))
}
return nil
}
+187
View File
@@ -0,0 +1,187 @@
package backup
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func requireTestConfig(t *testing.T) Config {
t.Helper()
user := os.Getenv("TEST_BACKUP_PG_USER")
if user == "" {
t.Skip("TEST_BACKUP_PG_USER nicht gesetzt, Integrationstest uebersprungen (braucht echten Postgres mit REPLICATION-Rolle)")
}
return Config{
Host: envOr("TEST_BACKUP_PG_HOST", "localhost"),
Port: envOr("TEST_BACKUP_PG_PORT", "5432"),
User: user,
Password: os.Getenv("TEST_BACKUP_PG_PASSWORD"),
BackupDir: t.TempDir(),
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// TestFullBackup_CreatesVerifiedBackup ist Pruefung 1: Sicherung gegen
// Testdatenbank erfolgreich erstellt und verifiziert.
func TestFullBackup_CreatesVerifiedBackup(t *testing.T) {
cfg := requireTestConfig(t)
ctx := context.Background()
genID := NewGenerationID(time.Now())
manifest, err := FullBackup(ctx, cfg, genID)
if err != nil {
t.Fatalf("fullbackup: %v", err)
}
if _, err := os.Stat(manifest); err != nil {
t.Fatalf("backup_manifest fehlt: %v", err)
}
dir := filepath.Dir(manifest)
if _, err := os.Stat(filepath.Join(dir, BaseTarGzFile)); err != nil {
t.Fatalf("%s fehlt: %v", BaseTarGzFile, err)
}
if err := Verify(dir); err != nil {
t.Fatalf("verify: %v", err)
}
}
// TestIncrementalBackup_IsSmallerThanFull ist der Nachweis fuer
// Akzeptanzkriterium 1 (inkrementell): eine echte inkrementelle Sicherung
// gegen unveraenderten Bestand ist deutlich kleiner als die Vollsicherung —
// beweist, dass tatsaechlich nur Aenderungen uebertragen wurden (PostgreSQL
// 17 WAL-Summarization), nicht nochmal alles.
func TestIncrementalBackup_IsSmallerThanFull(t *testing.T) {
cfg := requireTestConfig(t)
ctx := context.Background()
genID := NewGenerationID(time.Now())
fullManifest, err := FullBackup(ctx, cfg, genID)
if err != nil {
t.Fatalf("fullbackup: %v", err)
}
fullDir := filepath.Dir(fullManifest)
fullSize := fileSize(t, filepath.Join(fullDir, BaseTarGzFile))
incID := NewGenerationID(time.Now().Add(time.Second))
incManifest, err := IncrementalBackup(ctx, cfg, genID, incID, fullManifest)
if err != nil {
t.Fatalf("incrementalbackup: %v", err)
}
incDir := filepath.Dir(incManifest)
if err := Verify(incDir); err != nil {
t.Fatalf("verify (inkrementell): %v", err)
}
incSize := fileSize(t, filepath.Join(incDir, BaseTarGzFile))
if incSize >= fullSize {
t.Fatalf("inkrementelle sicherung (%d bytes) ist nicht kleiner als die vollsicherung (%d bytes) - keine echte inkrementelle Uebertragung", incSize, fullSize)
}
}
func fileSize(t *testing.T, path string) int64 {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("dateigroesse von %q ermitteln: %v", path, err)
}
return info.Size()
}
// TestVerify_DetectsCorruptedFile ist Pruefung 2: Verifikation erkennt eine
// absichtlich beschaedigte Sicherungsdatei.
func TestVerify_DetectsCorruptedFile(t *testing.T) {
cfg := requireTestConfig(t)
ctx := context.Background()
genID := NewGenerationID(time.Now())
manifest, err := FullBackup(ctx, cfg, genID)
if err != nil {
t.Fatalf("fullbackup: %v", err)
}
dir := filepath.Dir(manifest)
if err := Verify(dir); err != nil {
t.Fatalf("verify (unbeschaedigt) haette erfolgreich sein muessen: %v", err)
}
// Absichtliche Beschaedigung: mehrere Bytes in der Mitte der Datei kippen.
path := filepath.Join(dir, BaseTarGzFile)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("sicherungsdatei lesen: %v", err)
}
mid := len(data) / 2
for i := mid; i < mid+64 && i < len(data); i++ {
data[i] ^= 0xFF
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("beschaedigte sicherungsdatei schreiben: %v", err)
}
if err := Verify(dir); err == nil {
t.Fatal("verify haette die beschaedigte sicherungsdatei erkennen muessen")
}
}
// TestRotate_RemovesOnlyOldestGenerations ist Pruefung 3.
func TestRotate_RemovesOnlyOldestGenerations(t *testing.T) {
backupDir := t.TempDir()
generationIDs := []string{
"20260101T000000Z",
"20260102T000000Z",
"20260103T000000Z",
"20260104T000000Z",
"20260105T000000Z",
}
for _, id := range generationIDs {
if err := os.MkdirAll(filepath.Join(backupDir, id, FullBackupDirName), 0o750); err != nil {
t.Fatalf("generation %q anlegen: %v", id, err)
}
}
removed, err := Rotate(backupDir, 2)
if err != nil {
t.Fatalf("rotate: %v", err)
}
wantRemoved := []string{"20260101T000000Z", "20260102T000000Z", "20260103T000000Z"}
if len(removed) != len(wantRemoved) {
t.Fatalf("entfernte generationen = %v, want %v", removed, wantRemoved)
}
for i, w := range wantRemoved {
if removed[i] != w {
t.Fatalf("entfernte generationen = %v, want %v", removed, wantRemoved)
}
}
remaining, err := ListGenerations(backupDir)
if err != nil {
t.Fatalf("listgenerations: %v", err)
}
wantRemaining := []string{"20260104T000000Z", "20260105T000000Z"}
if len(remaining) != len(wantRemaining) {
t.Fatalf("verbleibende generationen = %v, want %v", remaining, wantRemaining)
}
for i, w := range wantRemaining {
if remaining[i] != w {
t.Fatalf("verbleibende generationen = %v, want %v", remaining, wantRemaining)
}
}
// Die NEUESTEN duerfen NICHT entfernt sein (Pruefung 3: nur die
// aeltesten Generationen).
for _, w := range wantRemaining {
if _, err := os.Stat(filepath.Join(backupDir, w)); err != nil {
t.Fatalf("neueste generation %q wurde faelschlich entfernt: %v", w, err)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
package backup
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// TestRestore_CombinesIntoStartablePostgresInstance ist Pruefung 1 fuer
// BAK-03 (Datenbank-Teil): Restore auf leerem System vollstaendig und
// erfolgreich durchgefuehrt — real bewiesen, indem das wiederhergestellte
// Verzeichnis tatsaechlich als eigenstaendige Postgres-Instanz gestartet
// und per echter Verbindung abgefragt wird, nicht nur auf Dateiexistenz
// geprueft.
func TestRestore_CombinesIntoStartablePostgresInstance(t *testing.T) {
cfg := requireTestConfig(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if _, err := exec.LookPath("pg_combinebackup"); err != nil {
t.Skip("pg_combinebackup nicht installiert, restore-integrationstest uebersprungen")
}
pgCtl, err := exec.LookPath("pg_ctl")
if err != nil {
t.Skip("pg_ctl nicht installiert, restore-integrationstest uebersprungen")
}
genID := NewGenerationID(time.Now())
if _, err := FullBackup(ctx, cfg, genID); err != nil {
t.Fatalf("fullbackup: %v", err)
}
restoreOut := filepath.Join(t.TempDir(), "restored-pgdata")
if err := os.MkdirAll(restoreOut, 0o700); err != nil {
t.Fatal(err)
}
if err := Restore(ctx, cfg, genID, restoreOut); err != nil {
t.Fatalf("restore: %v", err)
}
if _, err := os.Stat(filepath.Join(restoreOut, "PG_VERSION")); err != nil {
t.Fatalf("wiederhergestelltes verzeichnis ist kein gueltiges PGDATA (PG_VERSION fehlt): %v", err)
}
// Auf diesem Debian-Postgres liegt postgresql.conf NICHT in PGDATA
// (sondern in /etc/postgresql/17/main/) - pg_basebackup sichert daher
// nur PGDATA-Inhalte, die Konfigurationsdatei fehlt im Restore
// GENAUSO wie im echten Betriebs-Restore-Verfahren. Fuer den
// End-zu-End-Nachweis (echte Daten wiederherstellbar) hier eine
// minimale, ausschliesslich fuer den Testlauf gueltige Konfiguration
// nachgereicht - dokumentiert als operativer Hinweis fuer ein echtes
// Restore-Runbook, nicht Teil des Produktcodes.
minimalConf := "listen_addresses = ''\n"
if err := os.WriteFile(filepath.Join(restoreOut, "postgresql.conf"), []byte(minimalConf), 0o600); err != nil {
t.Fatal(err)
}
// dito pg_hba.conf - liegt auf Debian ebenfalls in /etc/postgresql,
// nicht in PGDATA. Nur Unix-Socket-Verbindungen des lokalen Testlaufs
// erlaubt (kein TCP, dieselbe Isolation wie listen_addresses='').
hba := "local all all trust\n"
if err := os.WriteFile(filepath.Join(restoreOut, "pg_hba.conf"), []byte(hba), 0o600); err != nil {
t.Fatal(err)
}
// Konfigurationsdateien aus der Quellinstanz brachte pg_basebackup mit
// (sie enthalten ggf. den alten Port/Unix-Socket) - fuer diesen Test
// bewusst neuer Port und eigenes Socket-Verzeichnis, damit die
// wiederhergestellte Instanz nicht mit der laufenden Test-Instanz
// kollidiert.
socketDir := t.TempDir()
testPort := "55432"
// WICHTIG: pg_ctl start foerdert postgres als Hintergrundprozess, der
// die geerbten stdout/stderr-Pipes NICHT schliesst - CombinedOutput()
// (das auf ein Pipe-EOF wartet) haengt sich daher auf, obwohl pg_ctl
// selbst laengst zurueckgekehrt ist. Deshalb echte Logdatei statt Pipe
// (Standard-pg_ctl-Muster), kein exec.Cmd.Stdout/-Stderr-Pipe-Zugriff.
logFile := filepath.Join(t.TempDir(), "postgres.log")
startCmd := exec.CommandContext(ctx, pgCtl, "start", "-D", restoreOut, "-w", "-t", "30", "-l", logFile,
"-o", fmt.Sprintf("-p %s -k %s -c listen_addresses=''", testPort, socketDir))
if err := startCmd.Run(); err != nil {
logContent, _ := os.ReadFile(logFile)
t.Fatalf("pg_ctl start (wiederhergestellte instanz): %v (log: %s)", err, string(logContent))
}
defer func() {
stopCmd := exec.Command(pgCtl, "stop", "-D", restoreOut, "-m", "fast")
_ = stopCmd.Run()
}()
psqlOut, err := exec.CommandContext(ctx, "psql",
"-h", socketDir, "-p", testPort, "-U", cfg.User, "-d", "postgres",
"-tAc", "SELECT 1").CombinedOutput()
if err != nil {
t.Fatalf("echte verbindung zur wiederhergestellten instanz fehlgeschlagen: %v (ausgabe: %s)", err, string(psqlOut))
}
if strings.TrimSpace(string(psqlOut)) != "1" {
t.Fatalf("unerwartete antwort von der wiederhergestellten instanz: %q", string(psqlOut))
}
}
+56
View File
@@ -0,0 +1,56 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"sort"
)
// ListGenerations liefert alle Generation-IDs in backupDir, aufsteigend
// sortiert (die GenerationID selbst ist chronologisch sortierbar, siehe
// NewGenerationID — kein Blick auf Dateisystem-Zeitstempel nötig, die bei
// einem Restore/Kopiervorgang verändert werden könnten).
func ListGenerations(backupDir string) ([]string, error) {
entries, err := os.ReadDir(backupDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("backup: sicherungsverzeichnis lesen: %w", err)
}
var generations []string
for _, e := range entries {
if e.IsDir() {
generations = append(generations, e.Name())
}
}
sort.Strings(generations)
return generations, nil
}
// Rotate entfernt alle bis auf die `keep` NEUESTEN Generationen
// (Akzeptanzkriterium 3) — jede Generation umfasst ihre Vollsicherung UND
// alle davon abhängigen Inkremente, ein Löschen der gesamten
// Generationsverzeichnisses entfernt beides konsistent zusammen.
func Rotate(backupDir string, keep int) (removed []string, err error) {
if keep < 0 {
keep = 0
}
generations, err := ListGenerations(backupDir)
if err != nil {
return nil, err
}
if len(generations) <= keep {
return nil, nil
}
toRemove := generations[:len(generations)-keep]
for _, gen := range toRemove {
if err := os.RemoveAll(filepath.Join(backupDir, gen)); err != nil {
return removed, fmt.Errorf("backup: generation %q entfernen: %w", gen, err)
}
removed = append(removed, gen)
}
return removed, nil
}
+118
View File
@@ -0,0 +1,118 @@
package backup
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
)
// ErrCorrupted wird geliefert, wenn eine Sicherungsdatei nicht lesbar ist
// (Akzeptanzkriterium 2: Verifikation, nicht nur Erstellungs-Prüfung).
var ErrCorrupted = fmt.Errorf("backup: sicherungsdatei ist beschaedigt oder unvollstaendig")
// Verify prüft, dass base.tar.gz UND pg_wal.tar.gz im gegebenen
// Sicherungsverzeichnis vollständig lesbar sind — öffnet gzip- UND
// tar-Stream und liest JEDEN Eintrag bis zum Ende durch (nicht nur die
// Kopfdaten), damit ein abgeschnittener oder mit kaputten Bytes
// überschriebener Inhalt zuverlässig auffällt, nicht nur ein defekter
// Tar-Header. pg_wal.tar.gz wird geprüft, weil ohne intaktes WAL kein
// crash-konsistenter Restore möglich ist (siehe Restore/WalTarGzFile) —
// eine Sicherung mit beschädigtem WAL-Archiv wäre sonst unbemerkt
// unbrauchbar.
func Verify(backupDir string) error {
if err := verifyTarGz(filepath.Join(backupDir, BaseTarGzFile)); err != nil {
return err
}
return verifyTarGz(filepath.Join(backupDir, WalTarGzFile))
}
func verifyTarGz(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("%w: %s nicht lesbar: %v", ErrCorrupted, path, err)
}
defer func() { _ = f.Close() }()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("%w: gzip-header von %s ungueltig: %v", ErrCorrupted, path, err)
}
defer func() { _ = gz.Close() }()
tr := tar.NewReader(gz)
entries := 0
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("%w: tar-eintrag in %s ungueltig: %v", ErrCorrupted, path, err)
}
if _, err := io.Copy(io.Discard, tr); err != nil {
return fmt.Errorf("%w: inhalt von %q in %s nicht vollstaendig lesbar: %v", ErrCorrupted, hdr.Name, path, err)
}
entries++
}
if entries == 0 {
return fmt.Errorf("%w: %s enthaelt keine eintraege", ErrCorrupted, path)
}
return nil
}
// extractTarGz entpackt tarGzPath vollständig nach destDir — genutzt von
// Restore, um die TAR+GZIP-Sicherungsstufen (siehe Verify) in das
// PLAIN-Format zu überführen, das pg_combinebackup als Eingabe erwartet.
func extractTarGz(tarGzPath, destDir string) error {
f, err := os.Open(tarGzPath)
if err != nil {
return fmt.Errorf("%s öffnen: %w", tarGzPath, err)
}
defer func() { _ = f.Close() }()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("gzip-header ungueltig: %w", err)
}
defer func() { _ = gz.Close() }()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("tar-eintrag lesen: %w", err)
}
target := filepath.Join(destDir, filepath.Clean(hdr.Name))
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o750); err != nil {
return fmt.Errorf("verzeichnis %q anlegen: %w", target, err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return fmt.Errorf("übergeordnetes verzeichnis von %q anlegen: %w", target, err)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
if err != nil {
return fmt.Errorf("datei %q anlegen: %w", target, err)
}
if _, err := io.Copy(out, tr); err != nil {
_ = out.Close()
return fmt.Errorf("datei %q schreiben: %w", target, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("datei %q schliessen: %w", target, err)
}
default:
// Symlinks/Sonderdateien: pg_basebackup-Archive enthalten
// praktisch keine, übersprungen statt Restore abzubrechen.
}
}
return nil
}
+167
View File
@@ -0,0 +1,167 @@
// Package objectbackup implementiert BAK-02: automatisierte, inkrementelle,
// deduplizierende Sicherung des Objekt-Storage-Bestands. Nutzt restic
// (Content-defined Chunking, verschlüsseltes Repository ab Werk) statt
// Eigenbau — restic erfüllt alle Akzeptanzkriterien mit ausgereiftem,
// geprüftem Tooling statt einer weniger robusten Neuimplementierung.
//
// Backup-Quelle ist ein lokaler Verzeichnisbaum — für den LocalDriver aus
// FDN-03 direkt dessen Basisverzeichnis, für S3-gestützte Produktions-
// Deployments ein vorgelagerter Sync-Schritt (z.B. rclone) auf einen
// lokalen Spiegel, bevor restic ihn sichert (nicht Bestandteil dieser
// Kachel — restic selbst sichert Dateibäume, keine S3-Buckets direkt).
package objectbackup
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
)
// Config enthält Repository-Ort und -Passwort — ausschließlich über
// Umgebungsvariablen befüllt (siehe Ticket-Abschluss-Regel).
type Config struct {
RepoDir string
Password string
ResticPath string // Default "restic", überschreibbar für Tests
}
func (c Config) binary() string {
if c.ResticPath != "" {
return c.ResticPath
}
return "restic"
}
func (c Config) env() []string {
return append(os.Environ(), "RESTIC_PASSWORD="+c.Password)
}
func run(ctx context.Context, cfg Config, args ...string) ([]byte, error) {
fullArgs := append([]string{"-r", cfg.RepoDir}, args...)
cmd := exec.CommandContext(ctx, cfg.binary(), fullArgs...)
cmd.Env = cfg.env()
output, err := cmd.CombinedOutput()
if err != nil {
return output, fmt.Errorf("%s %v fehlgeschlagen: %w (ausgabe: %s)", cfg.binary(), args, err, string(output))
}
return output, nil
}
// InitRepo legt ein neues restic-Repository an, falls es noch nicht
// existiert — idempotent, ein bereits initialisiertes Repository ist kein
// Fehler (Wiederholte Aufrufe durch systemd-Timer nach einem Neustart
// dürfen nicht fehlschlagen).
func InitRepo(ctx context.Context, cfg Config) error {
output, err := run(ctx, cfg, "init")
if err != nil {
if strings.Contains(string(output), "config file already exists") {
return nil
}
return fmt.Errorf("objectbackup: repository initialisieren: %w", err)
}
return nil
}
// BackupSummary ist der geparste "summary"-Datensatz aus `restic backup --json`.
type BackupSummary struct {
SnapshotID string `json:"snapshot_id"`
FilesNew int `json:"files_new"`
FilesChanged int `json:"files_changed"`
FilesUnmodified int `json:"files_unmodified"`
DataBlobs int `json:"data_blobs"`
TotalBytes int64 `json:"total_bytes_processed"`
}
// Backup sichert sourceDir inkrementell (Akzeptanzkriterium 1: unveränderte
// Objekte werden nicht erneut übertragen — restics Content-defined
// Chunking erkennt das automatisch, kein manueller Änderungsabgleich
// nötig).
func Backup(ctx context.Context, cfg Config, sourceDir string) (BackupSummary, error) {
output, err := run(ctx, cfg, "backup", sourceDir, "--json")
if err != nil {
return BackupSummary{}, fmt.Errorf("objectbackup: sicherung: %w", err)
}
return parseSummary(output)
}
// parseSummary sucht in der zeilenweisen JSON-Ausgabe von `restic backup
// --json` (mehrere Fortschritts-/Statuszeilen, GENAU EINE mit
// message_type=="summary") die Zusammenfassung.
func parseSummary(output []byte) (BackupSummary, error) {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
for i := len(lines) - 1; i >= 0; i-- {
var probe struct {
MessageType string `json:"message_type"`
}
if err := json.Unmarshal([]byte(lines[i]), &probe); err != nil {
continue
}
if probe.MessageType == "summary" {
var summary BackupSummary
if err := json.Unmarshal([]byte(lines[i]), &summary); err != nil {
return BackupSummary{}, fmt.Errorf("objectbackup: summary-zeile dekodieren: %w", err)
}
return summary, nil
}
}
return BackupSummary{}, fmt.Errorf("objectbackup: keine summary-zeile in der restic-ausgabe gefunden")
}
// Check prüft die Vollständigkeit/Lesbarkeit des Repository
// (Akzeptanzkriterium 3 / Pflichtprüfung: Vollständigkeitsprüfung erkennt
// fehlendes/beschädigtes Objekt). readData=true liest jeden gespeicherten
// Datenblock tatsächlich (teurer, aber die einzige Prüfung, die
// Bit-Rot in bereits gespeicherten Paketen erkennt — ohne readData prüft
// restic nur Struktur/Indizes, nicht den tatsächlichen Blockinhalt).
func Check(ctx context.Context, cfg Config, readData bool) error {
args := []string{"check"}
if readData {
args = append(args, "--read-data")
}
if _, err := run(ctx, cfg, args...); err != nil {
return fmt.Errorf("objectbackup: %w", err)
}
return nil
}
// Forget entfernt alte Snapshots nach Rotationsregel und gibt den davon
// belegten Speicherplatz frei (--prune) — restics Äquivalent zu
// BAK-01s Rotate.
func Forget(ctx context.Context, cfg Config, keepLast int) error {
if _, err := run(ctx, cfg, "forget", "--keep-last", fmt.Sprintf("%d", keepLast), "--prune"); err != nil {
return fmt.Errorf("objectbackup: rotation: %w", err)
}
return nil
}
// Restore stellt snapshotID nach targetDir wieder her (`restic restore`).
// targetDir muss bereits existieren; Atomarität gegenüber einem eventuell
// nicht-leeren ENDZIEL ist Aufgabe von internal/restore, nicht dieser
// Funktion (dieselbe Aufgabenteilung wie backup.Restore).
func Restore(ctx context.Context, cfg Config, snapshotID, targetDir string) error {
if _, err := run(ctx, cfg, "restore", snapshotID, "--target", targetDir); err != nil {
return fmt.Errorf("objectbackup: wiederherstellung: %w", err)
}
return nil
}
type snapshotEntry struct {
ShortID string `json:"short_id"`
}
// SnapshotCount liefert die Anzahl vorhandener Snapshots — für Tests und
// Statusabfragen.
func SnapshotCount(ctx context.Context, cfg Config) (int, error) {
output, err := run(ctx, cfg, "snapshots", "--json")
if err != nil {
return 0, fmt.Errorf("objectbackup: snapshots auflisten: %w", err)
}
var snapshots []snapshotEntry
if err := json.Unmarshal(output, &snapshots); err != nil {
return 0, fmt.Errorf("objectbackup: snapshot-liste dekodieren: %w", err)
}
return len(snapshots), nil
}
@@ -0,0 +1,168 @@
package objectbackup
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
)
func requireRestic(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("restic"); err != nil {
t.Skip("restic nicht installiert, Integrationstest uebersprungen")
}
}
func setupTest(t *testing.T) Config {
t.Helper()
requireRestic(t)
cfg := Config{RepoDir: filepath.Join(t.TempDir(), "repo"), Password: "test-passwort-fuer-objectbackup"}
if err := InitRepo(context.Background(), cfg); err != nil {
t.Fatalf("initrepo: %v", err)
}
return cfg
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatalf("testdatei %q schreiben: %v", name, err)
}
}
// TestBackup_UnchangedSecondRunTransmitsNothingNew ist Pruefung 1:
// zweiter Sicherungslauf nach unveraendertem Bestand ueberraegt keine
// Daten erneut.
func TestBackup_UnchangedSecondRunTransmitsNothingNew(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
writeFile(t, sourceDir, "dokument.pdf", "unveraenderter inhalt")
first, err := Backup(ctx, cfg, sourceDir)
if err != nil {
t.Fatalf("erste sicherung: %v", err)
}
if first.FilesNew != 1 {
t.Fatalf("erste sicherung: files_new = %d, want 1", first.FilesNew)
}
second, err := Backup(ctx, cfg, sourceDir)
if err != nil {
t.Fatalf("zweite sicherung: %v", err)
}
if second.FilesNew != 0 || second.FilesChanged != 0 {
t.Fatalf("zweite sicherung (unveraendert): files_new=%d files_changed=%d, want beide 0", second.FilesNew, second.FilesChanged)
}
if second.FilesUnmodified != 1 {
t.Fatalf("zweite sicherung: files_unmodified = %d, want 1", second.FilesUnmodified)
}
}
// TestBackup_DeduplicatesIdenticalContent ist Pruefung 2: zwei identische
// Testdateien belegen nachweislich nur einmal Speicherplatz.
func TestBackup_DeduplicatesIdenticalContent(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
content := "exakt identischer inhalt in beiden dateien fuer den dedup-nachweis"
writeFile(t, sourceDir, "original.pdf", content)
writeFile(t, sourceDir, "kopie.pdf", content)
summary, err := Backup(ctx, cfg, sourceDir)
if err != nil {
t.Fatalf("sicherung: %v", err)
}
if summary.FilesNew != 2 {
t.Fatalf("erwartet 2 neue dateien, habe %d", summary.FilesNew)
}
// Zwei Dateien mit IDENTISCHEM Inhalt duerfen nur EINEN data_blob
// erzeugen - das ist der Dedup-Nachweis (Akzeptanzkriterium 2).
if summary.DataBlobs != 1 {
t.Fatalf("data_blobs = %d, want 1 (zwei identische dateien haetten nur einen blob erzeugen duerfen - keine dedup)", summary.DataBlobs)
}
}
// TestCheck_DetectsCorruptedPack ist Pruefung 3: Vollstaendigkeitspruefung
// erkennt ein beschaedigtes/fehlendes Objekt in der Sicherung.
func TestCheck_DetectsCorruptedPack(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
writeFile(t, sourceDir, "wichtig.pdf", "inhalt, der spaeter absichtlich beschaedigt wird")
if _, err := Backup(ctx, cfg, sourceDir); err != nil {
t.Fatalf("sicherung: %v", err)
}
if err := Check(ctx, cfg, true); err != nil {
t.Fatalf("check (unbeschaedigt) haette erfolgreich sein muessen: %v", err)
}
// Absichtliche Beschaedigung: ein Byte in einer Pack-Datei im
// Repository kippen (dieselbe Fundstelle wie beim manuellen
// Nachweis waehrend der Recherche zu diesem Ticket).
packDir := filepath.Join(cfg.RepoDir, "data")
corrupted := false
if err := filepath.Walk(packDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || corrupted {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
if len(data) < 20 {
return nil
}
data[10] ^= 0xFF
if err := os.WriteFile(path, data, 0o600); err != nil {
return err
}
corrupted = true
return nil
}); err != nil {
t.Fatalf("pack-datei beschaedigen: %v", err)
}
if !corrupted {
t.Fatal("keine pack-datei zum beschaedigen gefunden - testaufbau fehlerhaft")
}
if err := Check(ctx, cfg, true); err == nil {
t.Fatal("check haette die beschaedigte pack-datei erkennen muessen")
}
}
// TestForget_KeepsOnlyRequestedSnapshotCount prueft die Rotation.
func TestForget_KeepsOnlyRequestedSnapshotCount(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
for i := 0; i < 3; i++ {
writeFile(t, sourceDir, "f.txt", "version "+string(rune('a'+i)))
if _, err := Backup(ctx, cfg, sourceDir); err != nil {
t.Fatalf("sicherung %d: %v", i, err)
}
}
before, err := SnapshotCount(ctx, cfg)
if err != nil {
t.Fatalf("snapshotcount (vorher): %v", err)
}
if before != 3 {
t.Fatalf("erwartet 3 snapshots vor rotation, habe %d", before)
}
if err := Forget(ctx, cfg, 1); err != nil {
t.Fatalf("forget: %v", err)
}
after, err := SnapshotCount(ctx, cfg)
if err != nil {
t.Fatalf("snapshotcount (nachher): %v", err)
}
if after != 1 {
t.Fatalf("erwartet 1 snapshot nach rotation (keep-last 1), habe %d", after)
}
}
@@ -0,0 +1,41 @@
package objectbackup
import (
"context"
"os"
"path/filepath"
"testing"
)
// TestRestore_RecoversRealContentFromSnapshot ist Pruefung 1 fuer BAK-03
// (Objekt-Storage-Teil): Restore auf leerem Zielverzeichnis vollstaendig
// erfolgreich, real gegen restic geprueft, Dateiinhalt tatsaechlich
// verglichen (kein Bloss-Existenz-Check).
func TestRestore_RecoversRealContentFromSnapshot(t *testing.T) {
cfg := setupTest(t)
ctx := context.Background()
sourceDir := t.TempDir()
content := []byte("original objektinhalt fuer restore-test")
if err := os.WriteFile(filepath.Join(sourceDir, "objekt.txt"), content, 0o600); err != nil {
t.Fatal(err)
}
summary, err := Backup(ctx, cfg, sourceDir)
if err != nil {
t.Fatalf("backup: %v", err)
}
targetDir := t.TempDir()
if err := Restore(ctx, cfg, summary.SnapshotID, targetDir); err != nil {
t.Fatalf("restore: %v", err)
}
restoredPath := filepath.Join(targetDir, sourceDir, "objekt.txt")
got, err := os.ReadFile(restoredPath)
if err != nil {
t.Fatalf("wiederhergestellte datei lesen (%s): %v", restoredPath, err)
}
if string(got) != string(content) {
t.Fatalf("wiederhergestellter inhalt = %q, want %q", got, content)
}
}
+106
View File
@@ -0,0 +1,106 @@
// Package reconcile implementiert BAK-05: periodischer Abgleich, ob jeder
// in der Datenbank referenzierte Objekt-Storage-Eintrag tatsächlich
// existiert und umgekehrt. Prüft AUSSCHLIESSLICH Existenz — niemals
// Inhalt (das ist Archive BAK-08, eine eigene Fehlerklasse, bewusst nicht
// hier mit hineingezogen, siehe reconcile_test.go
// TestReconcile_ExistingButCorruptedObjectProducesNoFinding).
package reconcile
import (
"sort"
"time"
)
// Finding ist EIN Abweichungsfund — entweder ein Datenbankeintrag ohne
// Storage-Objekt oder umgekehrt.
type Finding struct {
StorageKey string `json:"storage_key"`
DocumentID string `json:"document_id,omitempty"`
RevisionID string `json:"revision_id,omitempty"`
}
// Report ist das Ergebnis EINES Abgleichslaufs (Akzeptanzkriterium 3:
// Abweichungen werden BERICHTET, nicht automatisch behoben — Report ist
// reine Information, keine Reparaturfunktion existiert in diesem Paket).
//
// Beide Listen sind nach StorageKey aufsteigend sortiert — bei gleicher
// Eingabe liefert Reconcile IMMER dieselbe Reihenfolge (deterministisch),
// damit ein nachgelagerter Verbraucher (Archive BAK-08: zieht seine
// Stichprobe aus der Liste der EXISTIERENDEN Objekte) sich auf eine
// stabile Sortierung verlassen kann, statt bei jedem Lauf neu zu
// filtern/sortieren.
type Report struct {
GeneratedAt time.Time `json:"generated_at"`
// MissingInStorage: Datenbankeintrag vorhanden, Objekt im Storage fehlt
// (Akzeptanzkriterium 1).
MissingInStorage []Finding `json:"missing_in_storage"`
// OrphanedInStorage: Objekt im Storage vorhanden, kein Datenbankeintrag
// (Akzeptanzkriterium 2).
OrphanedInStorage []Finding `json:"orphaned_in_storage"`
// ExistingInStorage: Datenbankeintrag UND Storage-Objekt beide
// vorhanden — reine Existenzbestätigung, KEINE Inhaltsprüfung. Dient
// Archive BAK-08 als stabile, deterministisch sortierte
// Stichprobengrundlage (nach StorageKey aufsteigend, siehe Report-
// Dokumentation oben) — BAK-08 muss dafür selbst nicht mehr
// sortieren/filtern.
ExistingInStorage []Finding `json:"existing_in_storage"`
}
// IsClean liefert true, wenn der Lauf keine Abweichungen fand (Pflicht-
// prüfung 3: "Lauf ohne Abweichungen liefert einen leeren, eindeutig als
// sauber erkennbaren Bericht" — IsClean ist genau dieses eindeutige
// Erkennungsmerkmal, statt dass ein Aufrufer beide Listen selbst auf
// Leere prüfen muss).
func (r Report) IsClean() bool {
return len(r.MissingInStorage) == 0 && len(r.OrphanedInStorage) == 0
}
// DBEntry ist ein Datenbankeintrag, wie ihn ListDBStorageKeys liefert.
type DBEntry struct {
StorageKey string
DocumentID string
RevisionID string
}
// Reconcile vergleicht dbEntries (aus file_revisions.storage_key, DMS
// FDN-02) gegen storageKeys (tatsächlich im Objekt-Storage vorhandene
// Schlüssel, z.B. per Verzeichnis-Walk des FDN-03-LocalDriver-
// Basisverzeichnisses) und liefert die Abweichungen in beide Richtungen.
// Reine Funktion — kein Datenbank-/Storage-Zugriff hier, dadurch ohne
// echte Infrastruktur testbar (siehe reconcile_test.go).
func Reconcile(dbEntries []DBEntry, storageKeys []string) Report {
storageSet := make(map[string]bool, len(storageKeys))
for _, k := range storageKeys {
storageSet[k] = true
}
dbSet := make(map[string]DBEntry, len(dbEntries))
for _, e := range dbEntries {
dbSet[e.StorageKey] = e
}
var missing, existing []Finding
for _, e := range dbEntries {
if !storageSet[e.StorageKey] {
missing = append(missing, Finding(e))
} else {
existing = append(existing, Finding(e))
}
}
var orphaned []Finding
for _, k := range storageKeys {
if _, ok := dbSet[k]; !ok {
orphaned = append(orphaned, Finding{StorageKey: k})
}
}
sort.Slice(missing, func(i, j int) bool { return missing[i].StorageKey < missing[j].StorageKey })
sort.Slice(orphaned, func(i, j int) bool { return orphaned[i].StorageKey < orphaned[j].StorageKey })
sort.Slice(existing, func(i, j int) bool { return existing[i].StorageKey < existing[j].StorageKey })
return Report{
GeneratedAt: time.Now().UTC(),
MissingInStorage: missing,
OrphanedInStorage: orphaned,
ExistingInStorage: existing,
}
}
@@ -0,0 +1,169 @@
package reconcile
import "testing"
// TestReconcile_DetectsMissingInStorage ist Akzeptanzkriterium 1 / Pruefung
// 1: ein Datenbankeintrag ohne zugehoeriges Objekt im Storage wird erkannt.
func TestReconcile_DetectsMissingInStorage(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
{StorageKey: "documents/d2/revisions/r1", DocumentID: "d2", RevisionID: "r1"},
}
storage := []string{"documents/d1/revisions/r1"} // d2/r1 fehlt absichtlich
report := Reconcile(db, storage)
if len(report.MissingInStorage) != 1 {
t.Fatalf("erwartet 1 fund in missing_in_storage, habe %d: %+v", len(report.MissingInStorage), report.MissingInStorage)
}
if report.MissingInStorage[0].StorageKey != "documents/d2/revisions/r1" {
t.Fatalf("unerwarteter fund: %+v", report.MissingInStorage[0])
}
if len(report.OrphanedInStorage) != 0 {
t.Fatalf("erwartet 0 funde in orphaned_in_storage, habe %d", len(report.OrphanedInStorage))
}
}
// TestReconcile_DetectsOrphanedInStorage ist Akzeptanzkriterium 2 /
// Pruefung 2: ein Storage-Objekt ohne Datenbankeintrag wird erkannt.
func TestReconcile_DetectsOrphanedInStorage(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
}
storage := []string{
"documents/d1/revisions/r1",
"documents/verwaist/revisions/r1", // kein DB-Eintrag dafuer
}
report := Reconcile(db, storage)
if len(report.OrphanedInStorage) != 1 {
t.Fatalf("erwartet 1 fund in orphaned_in_storage, habe %d: %+v", len(report.OrphanedInStorage), report.OrphanedInStorage)
}
if report.OrphanedInStorage[0].StorageKey != "documents/verwaist/revisions/r1" {
t.Fatalf("unerwarteter fund: %+v", report.OrphanedInStorage[0])
}
if len(report.MissingInStorage) != 0 {
t.Fatalf("erwartet 0 funde in missing_in_storage, habe %d", len(report.MissingInStorage))
}
}
// TestReconcile_CleanRunProducesEmptyReport ist Pruefung 3: Lauf ohne
// Abweichungen liefert einen leeren, eindeutig als sauber erkennbaren
// Bericht.
func TestReconcile_CleanRunProducesEmptyReport(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
{StorageKey: "documents/d2/revisions/r1", DocumentID: "d2", RevisionID: "r1"},
}
storage := []string{"documents/d1/revisions/r1", "documents/d2/revisions/r1"}
report := Reconcile(db, storage)
if !report.IsClean() {
t.Fatalf("erwartet sauberen bericht, habe missing=%v orphaned=%v", report.MissingInStorage, report.OrphanedInStorage)
}
if len(report.MissingInStorage) != 0 || len(report.OrphanedInStorage) != 0 {
t.Fatal("IsClean()==true, aber listen sind nicht leer - widerspruch")
}
}
// TestReconcile_ExistingButCorruptedObjectProducesNoFinding ist der
// Nachweis, dass BAK-05 AUSSCHLIESSLICH Existenz prueft, niemals Inhalt
// (die Fehlerklasse "existiert, aber Inhalt beschaedigt" ist Archive
// BAK-08, bewusst nicht hier) — Reconcile bekommt nur SCHLUESSEL, hat gar
// keine Moeglichkeit, auf Inhalt zuzugreifen; dieser Test dokumentiert die
// Absicht explizit, damit sie nicht versehentlich spaeter aufgeweicht wird.
func TestReconcile_ExistingButCorruptedObjectProducesNoFinding(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
}
// "korruptes" Objekt hier rein simuliert durch denselben Schluessel -
// Reconcile kennt und prueft keinen Inhalt, nur den Schluessel selbst.
storage := []string{"documents/d1/revisions/r1"}
report := Reconcile(db, storage)
if !report.IsClean() {
t.Fatalf("ein existierendes (wenn auch inhaltlich korruptes) objekt haette KEINEN befund ausloesen duerfen, habe: %+v", report)
}
}
// TestReconcile_ExistingInStorageIsStableSamplingBasis ist der Nachweis,
// dass Reconcile eine deterministisch sortierte Liste ALLER bestaetigt
// existierenden Objekte liefert (DB-Eintrag UND Storage-Objekt vorhanden)
// - dies ist die Stichprobengrundlage, die Archive BAK-08 weiterverwendet,
// ohne selbst neu zu sortieren/filtern.
func TestReconcile_ExistingInStorageIsStableSamplingBasis(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/z/revisions/r1", DocumentID: "z", RevisionID: "r1"},
{StorageKey: "documents/a/revisions/r1", DocumentID: "a", RevisionID: "r1"},
{StorageKey: "documents/fehlt/revisions/r1", DocumentID: "fehlt", RevisionID: "r1"},
}
storage := []string{
"documents/z/revisions/r1",
"documents/a/revisions/r1",
}
report := Reconcile(db, storage)
want := []string{"documents/a/revisions/r1", "documents/z/revisions/r1"}
if len(report.ExistingInStorage) != len(want) {
t.Fatalf("erwartet %d bestaetigt existierende objekte, habe %d: %+v", len(want), len(report.ExistingInStorage), report.ExistingInStorage)
}
for i, w := range want {
if report.ExistingInStorage[i].StorageKey != w {
t.Fatalf("sortierreihenfolge falsch: %v, want beginnend mit %v", report.ExistingInStorage, want)
}
}
if len(report.MissingInStorage) != 1 || report.MissingInStorage[0].StorageKey != "documents/fehlt/revisions/r1" {
t.Fatalf("missing_in_storage unerwartet: %+v", report.MissingInStorage)
}
}
// TestReconcile_DeterministicOrdering ist der Nachweis fuer die
// Stabilitaets-Anforderung: gleiche Eingabe liefert bei mehreren Laeufen
// IMMER dieselbe Reihenfolge (Voraussetzung dafuer, dass Archive BAK-08
// die Liste der existierenden Objekte stabil weiterverarbeiten kann, ohne
// selbst neu zu sortieren/filtern).
func TestReconcile_DeterministicOrdering(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/z/revisions/r1", DocumentID: "z", RevisionID: "r1"},
{StorageKey: "documents/a/revisions/r1", DocumentID: "a", RevisionID: "r1"},
{StorageKey: "documents/m/revisions/r1", DocumentID: "m", RevisionID: "r1"},
}
storage := []string{
"documents/a/revisions/r1", // deckt genau den DB-Eintrag "a" ab
"documents/y/revisions/r1",
"documents/n/revisions/r1",
}
first := Reconcile(db, storage)
second := Reconcile(db, storage)
if len(first.MissingInStorage) != len(second.MissingInStorage) {
t.Fatal("unterschiedliche anzahl funde zwischen zwei laeufen mit identischer eingabe")
}
for i := range first.MissingInStorage {
if first.MissingInStorage[i].StorageKey != second.MissingInStorage[i].StorageKey {
t.Fatalf("reihenfolge in missing_in_storage nicht deterministisch: lauf1[%d]=%q lauf2[%d]=%q",
i, first.MissingInStorage[i].StorageKey, i, second.MissingInStorage[i].StorageKey)
}
}
for i := range first.OrphanedInStorage {
if first.OrphanedInStorage[i].StorageKey != second.OrphanedInStorage[i].StorageKey {
t.Fatalf("reihenfolge in orphaned_in_storage nicht deterministisch: lauf1[%d]=%q lauf2[%d]=%q",
i, first.OrphanedInStorage[i].StorageKey, i, second.OrphanedInStorage[i].StorageKey)
}
}
// Aufsteigend sortiert (a < m < z), nicht Einfuegereihenfolge.
wantOrder := []string{"documents/m/revisions/r1", "documents/z/revisions/r1"}
if len(first.MissingInStorage) != len(wantOrder) {
t.Fatalf("erwartet %d funde, habe %d", len(wantOrder), len(first.MissingInStorage))
}
for i, w := range wantOrder {
if first.MissingInStorage[i].StorageKey != w {
t.Fatalf("sortierreihenfolge falsch: %v, want beginnend mit %v", first.MissingInStorage, wantOrder)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
package reconcile
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/jackc/pgx/v5/pgxpool"
)
// ListDBStorageKeys liest alle storage_key-Werte aus file_revisions
// (DMS FDN-02) — Archive liest direkt aus derselben physischen
// Tenant-Datenbank (Modell C, Core TEN-01), OHNE DMS-Go-Pakete zu
// importieren (Archive ist ein eigenes Go-Modul) — reiner SQL-Zugriff
// gegen das dokumentierte Schema, sortiert nach storage_key für
// deterministische Reconcile-Ergebnisse.
func ListDBStorageKeys(ctx context.Context, pool *pgxpool.Pool) ([]DBEntry, error) {
rows, err := pool.Query(ctx, `
SELECT storage_key, document_id, id FROM file_revisions ORDER BY storage_key
`)
if err != nil {
return nil, fmt.Errorf("reconcile: file_revisions abfragen: %w", err)
}
defer rows.Close()
var entries []DBEntry
for rows.Next() {
var e DBEntry
if err := rows.Scan(&e.StorageKey, &e.DocumentID, &e.RevisionID); err != nil {
return nil, fmt.Errorf("reconcile: file_revisions-zeile lesen: %w", err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// ListStorageObjects durchläuft den lokalen FDN-03-LocalDriver-
// Basisordner und liefert alle vorhandenen Objektschlüssel (Pfad relativ
// zu baseDir, mit "/" als Trenner — dasselbe Format wie
// storage.ObjectKey aus FDN-03), sortiert.
func ListStorageObjects(baseDir string) ([]string, error) {
var keys []string
err := filepath.WalkDir(baseDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
keys = append(keys, filepath.ToSlash(rel))
return nil
})
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reconcile: objekt-storage durchlaufen: %w", err)
}
return keys, nil
}
+132
View File
@@ -0,0 +1,132 @@
package reconcile
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func requireTestPool(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() })
// Minimalschema, das exakt DMS FDN-02s file_revisions-Spalten spiegelt
// (Archive kann DMS' internal/-Pakete als eigenes Go-Modul nicht
// importieren, daher hier als Testfixture kopiert statt real migriert).
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS file_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL,
mime_type TEXT NOT NULL, revision_number INTEGER NOT NULL, created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE file_revisions, documents, users CASCADE`)
})
return pool
}
// TestListDBStorageKeys_ReadsRealFileRevisions ist der Nachweis, dass
// ListDBStorageKeys tatsaechlich gegen eine echte Postgres-Instanz mit
// DMS-FDN-02-Schema liest — kein Mock.
func TestListDBStorageKeys_ReadsRealFileRevisions(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
var userID, docID string
if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('reconcile-test@example.test', 'Test') RETURNING id`).Scan(&userID); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
if err := pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('doc', $1) RETURNING id`, userID).Scan(&docID); err != nil {
t.Fatalf("testdokument anlegen: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO file_revisions (document_id, storage_key, checksum_sha256, size_bytes, mime_type, revision_number, created_by)
VALUES ($1, 'documents/x/revisions/1', 'abc', 10, 'text/plain', 1, $2)
`, docID, userID); err != nil {
t.Fatalf("testrevision anlegen: %v", err)
}
entries, err := ListDBStorageKeys(ctx, pool)
if err != nil {
t.Fatalf("listdbstoragekeys: %v", err)
}
if len(entries) != 1 {
t.Fatalf("erwartet 1 eintrag, habe %d", len(entries))
}
if entries[0].StorageKey != "documents/x/revisions/1" {
t.Fatalf("storage_key = %q, want %q", entries[0].StorageKey, "documents/x/revisions/1")
}
if entries[0].DocumentID != docID {
t.Fatalf("document_id = %q, want %q", entries[0].DocumentID, docID)
}
}
// TestListStorageObjects_WalksRealDirectory ist der Nachweis, dass
// ListStorageObjects tatsaechlich das Dateisystem durchlaeuft.
func TestListStorageObjects_WalksRealDirectory(t *testing.T) {
baseDir := t.TempDir()
mustWriteFile(t, filepath.Join(baseDir, "documents", "d1", "revisions", "r1"), "inhalt")
mustWriteFile(t, filepath.Join(baseDir, "documents", "d2", "revisions", "r1"), "inhalt")
keys, err := ListStorageObjects(baseDir)
if err != nil {
t.Fatalf("liststorageobjects: %v", err)
}
if len(keys) != 2 {
t.Fatalf("erwartet 2 objektschluessel, habe %d: %v", len(keys), keys)
}
want := []string{"documents/d1/revisions/r1", "documents/d2/revisions/r1"}
for i, w := range want {
if keys[i] != w {
t.Fatalf("schluessel[%d] = %q, want %q (voll: %v)", i, keys[i], w, keys)
}
}
}
// TestListStorageObjects_MissingDirectoryReturnsEmpty prueft das
// Verhalten, wenn das Basisverzeichnis (noch) gar nicht existiert -
// sollte als "keine Objekte", nicht als Fehler behandelt werden.
func TestListStorageObjects_MissingDirectoryReturnsEmpty(t *testing.T) {
keys, err := ListStorageObjects("/pfad/der/nicht/existiert/fuer/diesen/test")
if err != nil {
t.Fatalf("erwartet keinen fehler bei fehlendem verzeichnis, habe: %v", err)
}
if len(keys) != 0 {
t.Fatalf("erwartet 0 schluessel, habe %d", len(keys))
}
}
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("verzeichnis anlegen: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("datei schreiben: %v", err)
}
}
+133
View File
@@ -0,0 +1,133 @@
// Package restore implementiert BAK-03: dokumentiertes, wiederholbares
// Restore-Verfahren für Datenbank (BAK-01) und Objekt-Storage (BAK-02).
// Enthält NUR die gemeinsame Ablauflogik (Atomarität über Temp-Verzeichnis,
// Protokollierung) — die eigentliche Wiederherstellung bleibt in den
// jeweiligen Paketen (backup.Restore, objectbackup.Restore), damit
// internal/restore nicht weiß, WIE eine Sicherung gelesen wird, nur WANN
// und WIE SICHER sie an ihren Zielort darf.
package restore
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// Kind unterscheidet die beiden Restore-Arten im Protokoll.
type Kind string
const (
KindDatabase Kind = "database"
KindObjects Kind = "objects"
)
// LogEntry ist EIN Protokolleintrag (Akzeptanzkriterium 3: Quelle,
// Zeitpunkt, Ergebnis).
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Kind Kind `json:"kind"`
Source string `json:"source"` // Generation-ID oder Snapshot-ID
Target string `json:"target"`
Result string `json:"result"` // "ok", "abgebrochen: ...", "fehlgeschlagen: ..."
}
// ErrTargetNotEmpty wird zurückgegeben, wenn targetDir bereits Inhalt hat
// und force nicht gesetzt ist — Akzeptanzkriterium 2: bestehender Inhalt
// bleibt unangetastet, solange der Vorgang nicht bestätigt wird.
var ErrTargetNotEmpty = fmt.Errorf("restore: zielverzeichnis ist nicht leer, restore ohne bestätigung (force) abgebrochen")
// AtomicRestore führt restoreFn gegen ein FRISCHES temporäres Verzeichnis
// aus (niemals direkt gegen target) und übernimmt es erst bei Erfolg —
// entweder wenn target leer ist, oder wenn force=true (bewusste
// Bestätigung, bestehenden Inhalt zu überschreiben). Bei jedem Fehler
// oder abgelehnter Bestätigung bleibt target garantiert unverändert
// (Akzeptanzkriterium 2), das temporäre Verzeichnis wird aufgeräumt.
// Jeder Aufruf erfolgreich oder nicht erzeugt genau einen
// Protokolleintrag in logPath (Akzeptanzkriterium 3).
func AtomicRestore(kind Kind, source, target string, force bool, logPath string, restoreFn func(tempDir string) error) (LogEntry, error) {
entry := LogEntry{Timestamp: time.Now().UTC(), Kind: kind, Source: source, Target: target}
empty, err := dirIsEmptyOrMissing(target)
if err != nil {
entry.Result = "fehlgeschlagen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, err
}
if !empty && !force {
entry.Result = "abgebrochen: " + ErrTargetNotEmpty.Error()
_ = appendLog(logPath, entry)
return entry, ErrTargetNotEmpty
}
parent := filepath.Dir(filepath.Clean(target))
tempDir, err := os.MkdirTemp(parent, ".restore-tmp-*")
if err != nil {
entry.Result = "fehlgeschlagen: temp-verzeichnis anlegen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: temp-verzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(tempDir) }() // no-op nach erfolgreichem Rename (Verzeichnis existiert dann nicht mehr)
if err := restoreFn(tempDir); err != nil {
entry.Result = "fehlgeschlagen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: wiederherstellung: %w", err)
}
if !empty {
// force=true, bewusste Bestätigung: alter Inhalt wird ERST JETZT
// entfernt, nachdem restoreFn bereits erfolgreich in tempDir
// abgeschlossen hat — ein Fehlschlag oben hätte target nie berührt.
if err := os.RemoveAll(target); err != nil {
entry.Result = "fehlgeschlagen: altes ziel entfernen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: altes ziel entfernen: %w", err)
}
} else if err := os.MkdirAll(parent, 0o750); err != nil {
entry.Result = "fehlgeschlagen: übergeordnetes verzeichnis anlegen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: übergeordnetes verzeichnis anlegen: %w", err)
}
if err := os.Rename(tempDir, target); err != nil {
entry.Result = "fehlgeschlagen: umbenennen: " + err.Error()
_ = appendLog(logPath, entry)
return entry, fmt.Errorf("restore: temp-verzeichnis nach ziel umbenennen: %w", err)
}
entry.Result = "ok"
if err := appendLog(logPath, entry); err != nil {
return entry, fmt.Errorf("restore: protokoll schreiben: %w", err)
}
return entry, nil
}
func dirIsEmptyOrMissing(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return true, nil
}
return false, fmt.Errorf("zielverzeichnis lesen: %w", err)
}
return len(entries) == 0, nil
}
func appendLog(logPath string, entry LogEntry) error {
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("protokolldatei öffnen: %w", err)
}
defer func() { _ = f.Close() }()
line, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("protokolleintrag kodieren: %w", err)
}
if _, err := f.Write(append(line, '\n')); err != nil {
return fmt.Errorf("protokolleintrag schreiben: %w", err)
}
return nil
}
+181
View File
@@ -0,0 +1,181 @@
package restore
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func readLog(t *testing.T, path string) []LogEntry {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("protokolldatei lesen: %v", err)
}
var entries []LogEntry
for _, line := range splitLines(data) {
if len(line) == 0 {
continue
}
var e LogEntry
if err := json.Unmarshal(line, &e); err != nil {
t.Fatalf("protokollzeile dekodieren: %v (%s)", err, line)
}
entries = append(entries, e)
}
return entries
}
func splitLines(data []byte) [][]byte {
var out [][]byte
start := 0
for i, b := range data {
if b == '\n' {
out = append(out, data[start:i])
start = i + 1
}
}
return out
}
// TestAtomicRestore_EmptyTarget_Succeeds ist Pruefung 1: Restore auf
// leerem (nicht vorhandenem) Ziel vollstaendig erfolgreich.
func TestAtomicRestore_EmptyTarget_Succeeds(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ziel")
logPath := filepath.Join(root, "restore.log")
entry, err := AtomicRestore(KindDatabase, "gen-1", target, false, logPath, func(tempDir string) error {
return os.WriteFile(filepath.Join(tempDir, "marker"), []byte("wiederhergestellt"), 0o600)
})
if err != nil {
t.Fatalf("atomicRestore: %v", err)
}
if entry.Result != "ok" {
t.Fatalf("result = %q, want ok", entry.Result)
}
content, err := os.ReadFile(filepath.Join(target, "marker"))
if err != nil || string(content) != "wiederhergestellt" {
t.Fatalf("ziel nicht korrekt befuellt: %v %q", err, content)
}
}
// TestAtomicRestore_NonEmptyTargetWithoutForce_LeavesOriginalUntouched ist
// Pruefung 2: Restore auf nicht-leeres Zielverzeichnis laesst bei Abbruch
// (keine Bestaetigung) den urspruenglichen Inhalt unveraendert.
func TestAtomicRestore_NonEmptyTargetWithoutForce_LeavesOriginalUntouched(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ziel")
logPath := filepath.Join(root, "restore.log")
if err := os.MkdirAll(target, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "bestehend"), []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
restoreFnCalled := false
_, err := AtomicRestore(KindObjects, "snap-1", target, false, logPath, func(tempDir string) error {
restoreFnCalled = true
return nil
})
if err != ErrTargetNotEmpty {
t.Fatalf("erwartet ErrTargetNotEmpty, habe: %v", err)
}
if restoreFnCalled {
t.Fatal("restoreFn haette bei nicht-leerem ziel ohne force NIE aufgerufen werden duerfen")
}
content, err := os.ReadFile(filepath.Join(target, "bestehend"))
if err != nil || string(content) != "original" {
t.Fatalf("urspruenglicher inhalt veraendert: %v %q", err, content)
}
}
// TestAtomicRestore_FailedRestoreFn_LeavesTargetUntouched ist Nachweis,
// dass ein Fehler WAEHREND der Wiederherstellung (in tempDir) das
// bestehende Ziel nicht beschaedigt, weil erst nach Erfolg umbenannt wird.
func TestAtomicRestore_FailedRestoreFn_LeavesTargetUntouched(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ziel")
logPath := filepath.Join(root, "restore.log")
if err := os.MkdirAll(target, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "bestehend"), []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
_, err := AtomicRestore(KindDatabase, "gen-2", target, true, logPath, func(tempDir string) error {
return os.ErrInvalid // simuliert fehlgeschlagene wiederherstellung
})
if err == nil {
t.Fatal("erwartet fehler")
}
content, err := os.ReadFile(filepath.Join(target, "bestehend"))
if err != nil || string(content) != "original" {
t.Fatalf("ziel bei fehlgeschlagenem restoreFn veraendert: %v %q", err, content)
}
}
// TestAtomicRestore_ForceOverwritesNonEmptyTarget ist Nachweis, dass eine
// BEWUSSTE Bestaetigung (force) bestehenden Inhalt ersetzen darf.
func TestAtomicRestore_ForceOverwritesNonEmptyTarget(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ziel")
logPath := filepath.Join(root, "restore.log")
if err := os.MkdirAll(target, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "alt"), []byte("alt"), 0o600); err != nil {
t.Fatal(err)
}
_, err := AtomicRestore(KindObjects, "snap-2", target, true, logPath, func(tempDir string) error {
return os.WriteFile(filepath.Join(tempDir, "neu"), []byte("neu"), 0o600)
})
if err != nil {
t.Fatalf("atomicRestore mit force: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "alt")); !os.IsNotExist(err) {
t.Fatal("alter inhalt haette nach force-restore ersetzt sein muessen")
}
if _, err := os.Stat(filepath.Join(target, "neu")); err != nil {
t.Fatalf("neuer inhalt fehlt: %v", err)
}
}
// TestAtomicRestore_LogsCompleteEntry ist Pruefung 3: Protokolleintrag
// ist vollstaendig und nachvollziehbar (Quelle, Zeitpunkt, Ergebnis) -
// sowohl fuer Erfolg als auch fuer Abbruch, in derselben Datei.
func TestAtomicRestore_LogsCompleteEntry(t *testing.T) {
root := t.TempDir()
logPath := filepath.Join(root, "restore.log")
okTarget := filepath.Join(root, "ok-ziel")
if _, err := AtomicRestore(KindDatabase, "gen-3", okTarget, false, logPath, func(tempDir string) error {
return os.WriteFile(filepath.Join(tempDir, "x"), []byte("x"), 0o600)
}); err != nil {
t.Fatalf("erfolgreicher restore: %v", err)
}
blockedTarget := filepath.Join(root, "blockiert-ziel")
if err := os.MkdirAll(blockedTarget, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(blockedTarget, "bestehend"), []byte("y"), 0o600); err != nil {
t.Fatal(err)
}
_, _ = AtomicRestore(KindObjects, "snap-3", blockedTarget, false, logPath, func(tempDir string) error { return nil })
entries := readLog(t, logPath)
if len(entries) != 2 {
t.Fatalf("erwartet 2 protokolleintraege, habe %d: %+v", len(entries), entries)
}
if entries[0].Source != "gen-3" || entries[0].Kind != KindDatabase || entries[0].Result != "ok" || entries[0].Timestamp.IsZero() {
t.Fatalf("erster eintrag unvollstaendig: %+v", entries[0])
}
if entries[1].Source != "snap-3" || entries[1].Kind != KindObjects || entries[1].Result == "" || entries[1].Timestamp.IsZero() {
t.Fatalf("zweiter eintrag unvollstaendig: %+v", entries[1])
}
}
+57
View File
@@ -0,0 +1,57 @@
package scrub
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"github.com/jackc/pgx/v5/pgxpool"
)
// ExpectedChecksums liest file_revisions.checksum_sha256 fuer genau die
// uebergebenen storage_keys — bewusst eine eigene, minimale Abfrage statt
// Erweiterung von reconcile.DBEntry (BAK-05 bleibt existenz-only, keine
// Kopplung an Inhaltspruefungs-Bedarf von BAK-08).
func ExpectedChecksums(ctx context.Context, pool *pgxpool.Pool, storageKeys []string) (map[string]string, error) {
if len(storageKeys) == 0 {
return map[string]string{}, nil
}
rows, err := pool.Query(ctx, `
SELECT storage_key, checksum_sha256 FROM file_revisions WHERE storage_key = ANY($1)
`, storageKeys)
if err != nil {
return nil, fmt.Errorf("scrub: erwartete pruefsummen lesen: %w", err)
}
defer rows.Close()
out := make(map[string]string, len(storageKeys))
for rows.Next() {
var key, checksum string
if err := rows.Scan(&key, &checksum); err != nil {
return nil, fmt.Errorf("scrub: pruefsummen-zeile lesen: %w", err)
}
out[key] = checksum
}
return out, rows.Err()
}
// ActualChecksum liest die Datei unter baseDir/storageKey vollstaendig
// und berechnet ihren SHA-256 — echte Inhaltspruefung, kein
// Header-/Groessenvergleich (dieselbe Disziplin wie BAK-01s Verify).
func ActualChecksum(baseDir, storageKey string) (string, error) {
f, err := os.Open(filepath.Join(baseDir, filepath.FromSlash(storageKey)))
if err != nil {
return "", fmt.Errorf("scrub: objekt lesen: %w", err)
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("scrub: objekt hashen: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+94
View File
@@ -0,0 +1,94 @@
package scrub
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func requireFileRevisionsFixture(t *testing.T) (pool *pgxpool.Pool, userID, docID string) {
t.Helper()
p := requireTestPool(t)
ctx := context.Background()
if _, err := p.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS file_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL,
mime_type TEXT NOT NULL, revision_number INTEGER NOT NULL, created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("file_revisions-fixture: %v", err)
}
var uid string
if err := p.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('scrub-test@example.test', 'Test') RETURNING id`).Scan(&uid); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
var did string
if err := p.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('doc', $1) RETURNING id`, uid).Scan(&did); err != nil {
t.Fatalf("testdokument anlegen: %v", err)
}
t.Cleanup(func() { _, _ = p.Exec(context.Background(), `TRUNCATE file_revisions, documents, users CASCADE`) })
return p, uid, did
}
// TestActualChecksum_MatchesRealFileContent ist Nachweis, dass
// ActualChecksum tatsaechlich den Dateiinhalt liest und hasht (kein
// Header-/Groessenvergleich).
func TestActualChecksum_MatchesRealFileContent(t *testing.T) {
baseDir := t.TempDir()
content := []byte("echter dateiinhalt fuer scrub-test")
path := filepath.Join(baseDir, "documents", "x", "revisions", "1")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
got, err := ActualChecksum(baseDir, "documents/x/revisions/1")
if err != nil {
t.Fatalf("actualChecksum: %v", err)
}
sum := sha256.Sum256(content)
want := hex.EncodeToString(sum[:])
if got != want {
t.Fatalf("checksum = %q, want %q", got, want)
}
}
// TestExpectedChecksums_ReadsRealFileRevisions ist Nachweis gegen echtes
// Postgres, kein Mock.
func TestExpectedChecksums_ReadsRealFileRevisions(t *testing.T) {
pool, uid, did := requireFileRevisionsFixture(t)
ctx := context.Background()
if _, err := pool.Exec(ctx, `
INSERT INTO file_revisions (document_id, storage_key, checksum_sha256, size_bytes, mime_type, revision_number, created_by)
VALUES ($1, 'documents/x/revisions/1', 'abc123', 10, 'text/plain', 1, $2)
`, did, uid); err != nil {
t.Fatalf("testrevision anlegen: %v", err)
}
got, err := ExpectedChecksums(ctx, pool, []string{"documents/x/revisions/1", "documents/fehlt/revisions/1"})
if err != nil {
t.Fatalf("expectedChecksums: %v", err)
}
if len(got) != 1 || got["documents/x/revisions/1"] != "abc123" {
t.Fatalf("unerwartetes ergebnis: %+v", got)
}
}
+69
View File
@@ -0,0 +1,69 @@
// Package scrub implementiert BAK-08: periodische, checksummenbasierte
// Integritaetspruefung einer Stichprobe existierender Objekte. Baut auf
// BAK-05 (internal/reconcile) auf, das die deterministisch sortierte
// Liste bestaetigt existierender Objekte liefert (existenz-only) — scrub
// fuegt die INHALTSPRUEFUNG hinzu, die BAK-05 bewusst ausspart.
package scrub
import (
"sort"
"time"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile"
)
// Candidate ist ein fuer den aktuellen Lauf ausgewaehltes Objekt.
type Candidate struct {
StorageKey string
DocumentID string
RevisionID string
}
// Sample waehlt aus existing (BAK-05s existing_in_storage, bereits nach
// StorageKey sortiert) die naechste Stichprobe: Objekte, die noch nie
// oder vor mehr als cooldown geprueft wurden (last_scrubbed via
// storage_key -> last_scrubbed_at aus scrub_state), begrenzt auf
// sampleSize. Reine Funktion, deterministisch bei gleicher Eingabe (fixe
// Reihenfolge von existing, kein Zufall) — Akzeptanzkriterium
// "Sampling priorisiert alte, unveraenderte Objekte": ein nie/am
// laengsten nicht geprueftes Objekt hat KEINEN last_scrubbed-Eintrag oder
// den aeltesten, beides erscheint zuerst in "existing", das seinerseits
// nach StorageKey sortiert ist — daher wird zusaetzlich vor der
// Groessenbegrenzung nach last_scrubbed_at aufsteigend sortiert (nie
// geprueft = aeltestmoeglicher Wert), damit tatsaechlich das am laengsten
// nicht verifizierte Objekt zuerst drankommt, nicht nur alphabetisch nach
// Schluessel.
func Sample(existing []reconcile.Finding, lastScrubbed map[string]time.Time, cooldown time.Duration, sampleSize int, now time.Time) []Candidate {
type scored struct {
f reconcile.Finding
last time.Time
}
var due []scored
for _, f := range existing {
last, ok := lastScrubbed[f.StorageKey]
if ok && now.Sub(last) < cooldown {
continue // erst kuerzlich geprueft, ueberspringen
}
if !ok {
last = time.Time{} // nie geprueft = aeltestmoeglicher Wert, kommt zuerst
}
due = append(due, scored{f: f, last: last})
}
sort.SliceStable(due, func(i, j int) bool {
if !due[i].last.Equal(due[j].last) {
return due[i].last.Before(due[j].last)
}
return due[i].f.StorageKey < due[j].f.StorageKey // Tie-Break deterministisch
})
if sampleSize >= 0 && len(due) > sampleSize {
due = due[:sampleSize]
}
out := make([]Candidate, 0, len(due))
for _, d := range due {
out = append(out, Candidate{StorageKey: d.f.StorageKey, DocumentID: d.f.DocumentID, RevisionID: d.f.RevisionID})
}
return out
}
+95
View File
@@ -0,0 +1,95 @@
package scrub
import (
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile"
)
var now = time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
// TestSample_PrioritizesNeverScrubbedAndOldest ist der Nachweis fuer das
// GoBD-Akzeptanzkriterium: nie geprueft ODER am laengsten nicht geprueft
// kommt zuerst, nicht bloss alphabetisch nach StorageKey.
func TestSample_PrioritizesNeverScrubbedAndOldest(t *testing.T) {
existing := []reconcile.Finding{
{StorageKey: "documents/a/revisions/r1"}, // vor 1 tag geprueft
{StorageKey: "documents/b/revisions/r1"}, // nie geprueft
{StorageKey: "documents/c/revisions/r1"}, // vor 30 tagen geprueft (aeltest)
}
lastScrubbed := map[string]time.Time{
"documents/a/revisions/r1": now.Add(-24 * time.Hour),
"documents/c/revisions/r1": now.Add(-30 * 24 * time.Hour),
}
got := Sample(existing, lastScrubbed, time.Hour, 2, now)
if len(got) != 2 {
t.Fatalf("erwartet 2 kandidaten, habe %d: %+v", len(got), got)
}
// "nie geprueft" (b) zaehlt als aeltestmoeglich, kommt vor "vor 30 tagen" (c).
if got[0].StorageKey != "documents/b/revisions/r1" || got[1].StorageKey != "documents/c/revisions/r1" {
t.Fatalf("falsche prioritaet, want [b, c], habe %+v", got)
}
}
// TestSample_RespectsCooldown ist der Nachweis, dass kuerzlich gepruefte
// Objekte NICHT erneut ausgewaehlt werden — sonst wuerde dieselbe Gruppe
// dauernd gescrubbt (genau der Fehler, den die Alt-Priorisierung
// verhindern soll).
func TestSample_RespectsCooldown(t *testing.T) {
existing := []reconcile.Finding{
{StorageKey: "documents/a/revisions/r1"},
{StorageKey: "documents/b/revisions/r1"},
}
lastScrubbed := map[string]time.Time{
"documents/a/revisions/r1": now.Add(-1 * time.Hour), // innerhalb cooldown
}
got := Sample(existing, lastScrubbed, 24*time.Hour, 10, now)
if len(got) != 1 || got[0].StorageKey != "documents/b/revisions/r1" {
t.Fatalf("erwartet nur b (a innerhalb cooldown), habe %+v", got)
}
}
// TestSample_LimitsToSampleSize ist der Nachweis, dass die
// Stichprobengroesse tatsaechlich begrenzt (kein Voll-Scrub jeden Lauf).
func TestSample_LimitsToSampleSize(t *testing.T) {
existing := []reconcile.Finding{
{StorageKey: "documents/a/revisions/r1"},
{StorageKey: "documents/b/revisions/r1"},
{StorageKey: "documents/c/revisions/r1"},
}
got := Sample(existing, map[string]time.Time{}, time.Hour, 1, now)
if len(got) != 1 {
t.Fatalf("erwartet genau 1 kandidat, habe %d", len(got))
}
}
// TestSample_DeterministicForIdenticalInput ist der Nachweis, dass zwei
// Laeufe mit identischer Eingabe dieselbe Reihenfolge liefern (kein
// Zufall im Sampling).
func TestSample_DeterministicForIdenticalInput(t *testing.T) {
existing := []reconcile.Finding{
{StorageKey: "documents/a/revisions/r1"},
{StorageKey: "documents/b/revisions/r1"},
{StorageKey: "documents/c/revisions/r1"},
}
lastScrubbed := map[string]time.Time{}
first := Sample(existing, lastScrubbed, time.Hour, 2, now)
second := Sample(existing, lastScrubbed, time.Hour, 2, now)
if len(first) != len(second) {
t.Fatal("unterschiedliche anzahl zwischen zwei laeufen mit identischer eingabe")
}
for i := range first {
if first[i].StorageKey != second[i].StorageKey {
t.Fatalf("reihenfolge nicht deterministisch: lauf1=%+v lauf2=%+v", first, second)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package scrub
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// LoadLastScrubbed liefert je storage_key den Zeitpunkt der letzten
// Pruefung — Grundlage fuer Sample's Cooldown-Filter.
func LoadLastScrubbed(ctx context.Context, pool *pgxpool.Pool) (map[string]time.Time, error) {
rows, err := pool.Query(ctx, `SELECT storage_key, last_scrubbed_at FROM scrub_state`)
if err != nil {
return nil, fmt.Errorf("scrub: scrub_state lesen: %w", err)
}
defer rows.Close()
out := make(map[string]time.Time)
for rows.Next() {
var key string
var ts time.Time
if err := rows.Scan(&key, &ts); err != nil {
return nil, fmt.Errorf("scrub: scrub_state-zeile lesen: %w", err)
}
out[key] = ts
}
return out, rows.Err()
}
// MarkScrubbed vermerkt Ergebnis und Zeitpunkt der Pruefung eines
// Objekts — idempotent (ON CONFLICT), damit ein unterbrochener und neu
// gestarteter Lauf keinen inkonsistenten Zustand hinterlaesst
// (Akzeptanzkriterium: Lauf ist unterbrechbar ohne inkonsistenten
// Zustand).
func MarkScrubbed(ctx context.Context, pool *pgxpool.Pool, storageKey string, ok bool, at time.Time) error {
result := "ok"
if !ok {
result = "failed"
}
_, err := pool.Exec(ctx, `
INSERT INTO scrub_state (storage_key, last_scrubbed_at, last_result)
VALUES ($1, $2, $3)
ON CONFLICT (storage_key) DO UPDATE SET last_scrubbed_at = $2, last_result = $3
`, storageKey, at, result)
if err != nil {
return fmt.Errorf("scrub: scrub_state schreiben: %w", err)
}
return nil
}
// RecordFinding erhoeht den monoton steigenden Befund-Zaehler
// (scrub_counters.findings_total) um genau 1 — als gueltiger Prometheus-
// Counter darf dieser Wert nur steigen, niemals sinken, auch wenn ein
// Befund spaeter behoben wird.
func RecordFinding(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, `UPDATE scrub_counters SET findings_total = findings_total + 1 WHERE id = 1`)
if err != nil {
return fmt.Errorf("scrub: befund-zaehler erhoehen: %w", err)
}
return nil
}
// FindingsTotal liest den aktuellen Zaehlerstand — genutzt vom
// /metrics-Endpunkt (cmd/scrub-metrics).
func FindingsTotal(ctx context.Context, pool *pgxpool.Pool) (int64, error) {
var total int64
err := pool.QueryRow(ctx, `SELECT findings_total FROM scrub_counters WHERE id = 1`).Scan(&total)
if err != nil {
return 0, fmt.Errorf("scrub: befund-zaehler lesen: %w", err)
}
return total, nil
}
+92
View File
@@ -0,0 +1,92 @@
package scrub
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func requireTestPool(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 TABLE IF NOT EXISTS scrub_state (
storage_key TEXT PRIMARY KEY, last_scrubbed_at TIMESTAMPTZ NOT NULL,
last_result TEXT NOT NULL CHECK (last_result IN ('ok', 'failed'))
);
CREATE TABLE IF NOT EXISTS scrub_counters (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), findings_total BIGINT NOT NULL DEFAULT 0
);
INSERT INTO scrub_counters (id, findings_total) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE scrub_state; UPDATE scrub_counters SET findings_total = 0 WHERE id = 1`)
})
return pool
}
// TestMarkScrubbed_IsIdempotent ist Nachweis fuer "Lauf ist idempotent und
// unterbrechbar ohne inkonsistenten Zustand": derselbe storage_key kann
// beliebig oft neu markiert werden, es entsteht kein Duplikat/Fehler.
func TestMarkScrubbed_IsIdempotent(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
key := "documents/x/revisions/1"
if err := MarkScrubbed(ctx, pool, key, true, time.Now().UTC()); err != nil {
t.Fatalf("erster markScrubbed: %v", err)
}
second := time.Now().UTC().Add(time.Hour)
if err := MarkScrubbed(ctx, pool, key, false, second); err != nil {
t.Fatalf("zweiter markScrubbed (ueberschreibt): %v", err)
}
last, err := LoadLastScrubbed(ctx, pool)
if err != nil {
t.Fatalf("loadLastScrubbed: %v", err)
}
if len(last) != 1 {
t.Fatalf("erwartet genau 1 eintrag (kein duplikat), habe %d", len(last))
}
// Postgres timestamptz rundet auf Mikrosekunden, Go time.Time hat
// Nanosekunden-Praezision - Vergleich daher auf Mikrosekunden gerundet.
if !last[key].Truncate(time.Microsecond).Equal(second.Truncate(time.Microsecond)) {
t.Fatalf("last_scrubbed_at nicht ueberschrieben: %v, want %v", last[key], second)
}
}
// TestRecordFinding_IsMonotonicallyIncreasing ist Nachweis, dass der
// Zaehler ein gueltiger Prometheus-Counter ist (steigt nur, sinkt nie).
func TestRecordFinding_IsMonotonicallyIncreasing(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
if err := RecordFinding(ctx, pool); err != nil {
t.Fatalf("recordFinding: %v", err)
}
}
total, err := FindingsTotal(ctx, pool)
if err != nil {
t.Fatalf("findingsTotal: %v", err)
}
if total != 3 {
t.Fatalf("erwartet 3, habe %d", total)
}
}
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS scrub_counters;
DROP TABLE IF EXISTS scrub_state;
@@ -0,0 +1,21 @@
-- BAK-08: Zustand des Integritaets-Scrub-Jobs. Getrennt von file_revisions
-- (DMS-Eigentum, nur lesend zugegriffen) und getrennt von BAK-05s
-- reconcile-Paket (existenz-only, keine Inhaltspruefung) — eigener,
-- Archive-eigener Zustand ueber ZULETZT geprueften Zeitpunkt je Objekt,
-- damit Sampling rotiert statt dieselben "aeltesten" Objekte auf ewig
-- erneut zu ziehen.
CREATE TABLE IF NOT EXISTS scrub_state (
storage_key TEXT PRIMARY KEY,
last_scrubbed_at TIMESTAMPTZ NOT NULL,
last_result TEXT NOT NULL CHECK (last_result IN ('ok', 'failed'))
);
-- Einzelne Zeile, monoton steigender Zaehler fuer den OPS-05/OPS-03-
-- Metrik-Export (Counter, nie ruecksetzbar — ein behobener Befund darf den
-- Zaehler nicht wieder senken, sonst waere es kein gueltiger Prometheus-
-- Counter mehr).
CREATE TABLE IF NOT EXISTS scrub_counters (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
findings_total BIGINT NOT NULL DEFAULT 0
);
INSERT INTO scrub_counters (id, findings_total) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;
+3 -30
View File
@@ -7,8 +7,6 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func main() {
@@ -17,41 +15,16 @@ func main() {
log.Fatalf("config: %v", err)
}
ctx := context.Background()
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
pool, err := db.Connect(context.Background(), cfg.RegistryDSN)
if err != nil {
log.Fatalf("registry db: %v", err)
log.Fatalf("db: %v", err)
}
defer registryPool.Close()
adminPool, err := db.Connect(ctx, cfg.AdminDSN)
if err != nil {
log.Fatalf("admin db: %v", err)
}
defer adminPool.Close()
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
superadmins := user.NewSuperadminStore(registryPool)
userHandler := user.NewHandler(nil, superadmins)
defer pool.Close()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
-40
View File
@@ -1,40 +0,0 @@
// secheaders-devserver dient der manuellen/e2e-Verifikation von API-08
// (Pruefung 2: CSP blockiert Inline-Script) mit einem echten Browser.
// Zwei Pfade: /protected (mit Middleware) und /unprotected (ohne) — beide
// liefern dieselbe Seite mit einem Inline-Script, das ein DOM-Element
// aendert. Ein echter Browser zeigt den Unterschied.
package main
import (
"log"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/secheaders"
)
const page = `<!doctype html>
<html><body>
<div id="marker">BLOCKED</div>
<script>document.getElementById('marker').textContent = 'INLINE_RAN';</script>
</body></html>`
func servePage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(page))
}
func main() {
cfg := secheaders.DefaultConfig()
mux := http.NewServeMux()
// SetHeaders statt Middleware: dieser lokale Demo-Server laeuft ohne
// TLS-Terminierung, der HTTPS-Redirect-Teil von Middleware wuerde hier
// in eine Endlosschleife fuehren (siehe secheaders.go-Dokumentation).
mux.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) {
secheaders.SetHeaders(cfg, w)
servePage(w, r)
})
mux.HandleFunc("/unprotected", servePage)
log.Println("secheaders-devserver listening on :8086")
log.Fatal(http.ListenAndServe(":8086", mux))
}
-67
View File
@@ -1,67 +0,0 @@
// tenantadmin-devserver stellt das TEN-05-Backend-API (internal/tenantadmin)
// fuer die Next.js-Tenant-Verwaltungsoberflaeche bereit. Getrennt von
// cmd/core aus demselben Grund wie cmd/licadmin-devserver (siehe LIC-04):
// echte Auth (IAM-01/IAM-02) ist noch nicht in die zentrale Server-Topologie
// verdrahtet, dieser Server dient Entwicklung/Betrieb der Oberflaeche gegen
// eine echte Datenbank, ohne cmd/core anzufassen.
package main
import (
"context"
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_TENANTADMIN_LISTEN_ADDR")
if addr == "" {
addr = ":8082"
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
registry := tenant.NewRegistry(pool)
lifecycle := tenant.NewLifecycle(registry, pool)
settingsStore := tenantsettings.NewStore(pool)
superadmins := user.NewSuperadminStore(pool)
handler := tenantadmin.NewHandler(registry, lifecycle, settingsStore, superadmins)
mux := http.NewServeMux()
mux.HandleFunc("/admin/tenants", withCORS(handler.ListTenantsHandler))
mux.HandleFunc("/admin/tenants/detail", withCORS(handler.TenantDetailHandler))
mux.HandleFunc("/admin/tenants/settings", withCORS(handler.UpdateSettingsHandler))
mux.HandleFunc("/admin/tenants/lifecycle", withCORS(handler.LifecycleActionHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("tenantadmin-devserver listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
@@ -0,0 +1,9 @@
[Unit]
Description=NEXARCH Archive - Datenbank-Vollsicherung (BAK-01)
After=network.target postgresql.service
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-backup.env
ExecStart=__INSTALL_DIR__/bin/backup-cli full
@@ -0,0 +1,9 @@
[Unit]
Description=Taeglicher Zeitplan fuer NEXARCH Archive Datenbank-Vollsicherung (BAK-01)
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,9 @@
[Unit]
Description=NEXARCH Archive - Datenbank-Inkrementalsicherung (BAK-01)
After=network.target postgresql.service
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-backup.env
ExecStart=__INSTALL_DIR__/bin/backup-cli incremental
@@ -0,0 +1,9 @@
[Unit]
Description=Stuendlicher Zeitplan fuer NEXARCH Archive Datenbank-Inkrementalsicherung (BAK-01)
[Timer]
OnCalendar=*-*-* *:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,9 @@
[Unit]
Description=NEXARCH Archive - Sicherungsgenerationen-Rotation (BAK-01)
After=network.target
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-backup.env
ExecStart=__INSTALL_DIR__/bin/backup-cli rotate
@@ -0,0 +1,9 @@
[Unit]
Description=Taeglicher Zeitplan fuer NEXARCH Archive Sicherungsgenerationen-Rotation (BAK-01)
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,9 @@
[Unit]
Description=NEXARCH Archive - Objekt-Storage-Sicherung (BAK-02)
After=network.target
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-objectbackup.env
ExecStart=__INSTALL_DIR__/bin/objectbackup-cli backup __OBJECT_SOURCE_DIR__
@@ -0,0 +1,9 @@
[Unit]
Description=Stuendlicher Zeitplan fuer NEXARCH Archive Objekt-Storage-Sicherung (BAK-02)
[Timer]
OnCalendar=*-*-* *:30:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,9 @@
[Unit]
Description=NEXARCH Archive - Objekt-Storage-Sicherung Vollstaendigkeitspruefung (BAK-02)
After=network.target
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-objectbackup.env
ExecStart=__INSTALL_DIR__/bin/objectbackup-cli check
@@ -0,0 +1,9 @@
[Unit]
Description=Woechentlicher Zeitplan fuer NEXARCH Archive Objekt-Storage-Vollstaendigkeitspruefung (BAK-02)
[Timer]
OnCalendar=Sun *-*-* 04:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,9 @@
[Unit]
Description=NEXARCH Archive - Objekt-Storage-Sicherung Rotation (BAK-02)
After=network.target
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-objectbackup.env
ExecStart=__INSTALL_DIR__/bin/objectbackup-cli rotate
@@ -0,0 +1,9 @@
[Unit]
Description=Taeglicher Zeitplan fuer NEXARCH Archive Objekt-Storage-Rotation (BAK-02)
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,10 @@
[Unit]
Description=NEXARCH Archive - Konsistenzpruefung Storage vs. DB (BAK-05)
After=network.target postgresql.service
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-reconcile.env
ExecStart=__INSTALL_DIR__/bin/reconcile-cli
StandardOutput=journal
@@ -0,0 +1,9 @@
[Unit]
Description=Taeglicher Zeitplan fuer NEXARCH Archive Konsistenzpruefung (BAK-05)
[Timer]
OnCalendar=*-*-* 05:00:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,14 @@
[Unit]
Description=NEXARCH Archive - /metrics-Export fuer BAK-08 (dauerhaft, Pull-Modell fuer OPS-03)
After=network.target postgresql.service
[Service]
Type=simple
User=nexarch
EnvironmentFile=/etc/nexarch/archive-scrub-metrics.env
ExecStart=__INSTALL_DIR__/bin/scrub-metrics
Restart=on-failure
StandardOutput=journal
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
[Unit]
Description=NEXARCH Archive - Checksummen-Integritaetspruefung Stichprobe (BAK-08)
After=network.target postgresql.service
[Service]
Type=oneshot
User=nexarch
EnvironmentFile=/etc/nexarch/archive-scrub.env
ExecStart=__INSTALL_DIR__/bin/scrub-cli
StandardOutput=journal
@@ -0,0 +1,9 @@
[Unit]
Description=Zeitplan fuer NEXARCH Archive Checksummen-Stichprobe (BAK-08)
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
[Install]
WantedBy=timers.target
-93
View File
@@ -1,93 +0,0 @@
# QA-02 Prüfprotokoll: Prüfgate Identität & Mandanten
Stand: 2026-08-29. Branch `feature/qa-02-pruefgate-identitaet-mandanten` (alle 21 Vorbedingungs-Tickets TEN-01..07 und IAM-01..14 gemergt, per `git merge-base` gegen jeden Feature-Branch verifiziert).
## 1. Akzeptanzkriterien TEN-01 bis TEN-07 — Testabdeckung
| Ticket | Titel | Abdeckende Tests |
|---|---|---|
| TEN-01 | Mandantenregistrierung / Modell A/C | `internal/tenant/tenant_test.go`: `TestValidateSlug`, `TestDBNameForSlug` |
| TEN-02 | Self-Service-Onboarding | `internal/tenant/provisioner_test.go`: `TestProvision_CreatesIsolatedDatabases`; `internal/tenant/onboarding_test.go`: `TestOnboarding_ValidationErrors`, `TestOnboarding_CreatesTenantAndAdmin`, `TestOnboarding_RejectsDuplicateSlugConcurrently` |
| TEN-03 | Tenant-Router / Verbindungsauswahl | `internal/tenant/router_test.go`: `TestRouter_ResolvesCorrectTenantDatabase`, `TestRouter_RejectsMissingOrUnknownTenant`, `TestRouter_ReusesConnectionForSameTenant`, `TestRouter_BoundsOpenConnectionsUnderLoad` |
| TEN-04 | Mandanten-Lebenszyklus (Suspend/Reaktivieren) | `internal/tenant/lifecycle_test.go`: `TestLifecycle_SuspendAndReactivate`, `TestLifecycle_RejectsInvalidTransitions`, `TestLifecycle_CheckActive_RejectsNonActive` |
| TEN-05 | Mandanten-Löschung (DSGVO-Löschfrist) | `internal/tenant/lifecycle_test.go`: `TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState`, `TestLifecycle_ProcessDueDeletions` |
| TEN-06 | Tenant-Router-Integration | siehe TEN-03 (dasselbe Testpaket, Router ist die TEN-06-Lieferung) |
| TEN-07 | Migrations-Rollout über alle Mandanten | `internal/migrate` (kein eigenes `*_test.go` im Merge-Ergebnis gefunden — siehe Abweichung 4.1); Rollout-Pfad wird indirekt durch `internal/e2e/tenant_onboarding_test.go` und `cross_tenant_isolation_test.go` genutzt (`applyTenantSchema` wendet dieselben `migrations/tenant/*.up.sql` in derselben Reihenfolge an) |
## 2. Akzeptanzkriterien IAM-01 bis IAM-14 — Testabdeckung
| Ticket | Titel | Abdeckende Tests |
|---|---|---|
| IAM-01 | Benutzerverwaltung (CRUD) | `internal/user/store_test.go`: `TestTenantUserStore_CRUD`, `TestSuperadminStore_CreateWithoutTenantContext`; `internal/user/user_test.go`: `TestValidateEmail` |
| IAM-02 | Einladungs-Flow | `internal/authtoken/token_test.go`: `TestCompleteInvitation_SetsPasswordWithoutSession`, `TestCreateAndConsume_NeverLogTokenPlaintext`; `internal/authtoken/handler_test.go` |
| IAM-03 | Passwort-Hashing (bcrypt) | `internal/auth/password_test.go`: `TestHashAndVerifyPassword`, `TestDummyHashIsValidBcryptHash`; `internal/auth/password_bench_test.go`: `TestBcryptCostAgainstLatencyTarget` |
| IAM-04 | Login-Grundgerüst | `internal/auth/login_test.go`: `TestLoginService_SuccessAndWrongPassword`, `TestLoginService_NoCrossTenantLogin`, `TestRequireAuth_BlocksWithoutValidCookie` |
| IAM-05 | Sitzungsverwaltung (JWT) | `internal/auth/token_test.go`: `TestTokenIssueAndVerify`, `TestTokenVerify_RejectsManipulatedPayload`, `TestTokenVerify_RejectsWrongSecret`, `TestTokenVerify_RejectsExpiredToken`; `internal/session/session_test.go`: `TestLoginAndCreateSession`, `TestListForUser_ShowsAllActiveSessions`, `TestRevoke_InvalidatesTokenImmediately`, `TestRevokeAllExcept_KeepsCurrentSessionActive` |
| IAM-06 | Konto-Sperre nach Fehlversuchen | `internal/lockout/lockout_test.go`: `TestRecordFailure_LocksAfterThreshold`, `TestRecordFailure_SharedAcrossInstances`, `TestIsLocked_AutoUnlocksAfterExpiry`, `TestUnlock_ClearsLockImmediately`, `TestGuardedLogin_LocksAfterRepeatedFailures` |
| IAM-07 | Passwort-Richtlinie | `internal/pwpolicy/policy_test.go`: `TestValidate_RejectsTooShortOrSimple`, `TestValidate_RejectsBlocklistedPassword`, `TestStore_GetReturnsDefaultWhenUnset`, `TestStore_SetAndGetRoundTrip`, `TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword` |
| IAM-08 | Passwort-Reset / Profil-Selbstverwaltung | `internal/auth/changepassword_test.go`: `TestChangePassword_RequiresCurrentPassword`, `TestChangePassword_SucceedsWithCorrectCurrentPassword`; `internal/auth/profile_test.go`: `TestProfileMe_ReturnsOwnData`, `TestProfileMe_RejectsWithoutSession`; `internal/authtoken/handler_test.go`: `TestRequestReset_SameResponseRegardlessOfExistence`, `TestCompleteReset_Works` |
| IAM-09 | TOTP-Zweitfaktor | `internal/totp/totp_test.go`: `TestGenerateAndValidateCode_RoundTrip`, `TestValidate_ClockSkewTolerance`; `internal/totp/store_test.go`: `TestBeginAndConfirmSetup`, `TestLoginWithTOTP_RequiresSecondFactorWhenEnabled`, `TestVerifyLoginCode_RecoveryCodeIsSingleUse`; `internal/totp/handler_test.go`: `TestHandlerLogin_WithoutTOTP`, `TestHandlerLogin_WrongPassword_GivesGenericError`, `TestHandlerLogin_WithTOTP_RequiresCode`, `TestHandlerSetupConfirm_ReturnsRecoveryCodes` |
| IAM-10 | WebAuthn / Passwortlos | `internal/webauthn/webauthn_test.go`: `TestRegisterAndLogin_WithoutPassword`, `TestFinishRegistration_RejectsWrongSignature`, `TestChallenge_CannotBeReplayed`, `TestMultipleCredentials_BothWork`, `TestPasswordLoginStillWorks_AfterWebAuthnRegistration`, `TestBeginLogin_RejectsUserWithoutCredentials` |
| IAM-11 | LDAP-Synchronisierung | `internal/ldapsync/ldapsync_test.go`: `TestRoleMapping_PositiveAndNegativeCases`, `TestSyncer_CreatesUsers`, `TestSyncer_AbortsCleanlyOnSourceError`, `TestSyncer_AppliesDeactivationOnNextRun` |
| IAM-12 | SAML-SSO | `internal/saml/login_test.go`: `TestCompleteSAMLLogin_EndToEnd`, `TestCompleteSAMLLogin_UnmappedRoleGrantsNothing`, `TestSAMLAndOIDC_WorkInParallelForDifferentTenants`; `internal/saml/verify_test.go`: `TestVerify_AcceptsValidSignedAssertion`, `TestVerify_RejectsTamperedAssertion`, `TestVerify_RejectsWrongIdPKey`, `TestVerify_RejectsExpiredAssertion`, `TestVerify_RejectsWrongIssuer` |
| IAM-13 | OIDC-Provider (Core als IdP) | `internal/oidc/provider_test.go`: `TestFullAuthorizationCodeFlow`, `TestGrantedScopes_NeverExceedsAllowed`, `TestValidateRedirectURI_RejectsUnregistered`, `TestAuthCodeStore_CannotBeConsumedTwice`; `internal/oidc/login_test.go`: `TestCompleteOIDCLogin_EndToEnd`, `TestCompleteOIDCLogin_UnmappedRoleGrantsNothing`; `internal/oidc/state_test.go`, `internal/oidc/verify_test.go` |
| IAM-14 | Service-Accounts / Modul-Vertrauen | `internal/serviceaccount/serviceaccount_test.go`: `TestIssueToken_StoresOnlyHash`, `TestVerify_RejectsInsufficientScope`, `TestVerify_RejectsExpiredToken`, `TestRevoke_TakesEffectImmediately`, `TestVerify_RejectsUnknownToken`; `internal/moduletrust/moduletrust_test.go`: `TestIssueAndVerify_RoundTrip`, `TestVerify_DoesNotFetchPerCall`, `TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist`, `TestRequireFreshKeys_FailsClosedWhenCoreUnreachable`, `TestRotate_NoDowntimeForAlreadyIssuedTokens`, `TestVerify_RejectsUnknownKid`, `TestJWKSRoundTrip` |
**Ergebnis Abschnitt 1+2:** alle 21 Tickets haben eigene, automatisierte Unit-/Integrationstests, die ihre dokumentierten Akzeptanzkriterien einzeln abdecken.
## 3. Echter End-to-End-Testlauf (QA-02-Kernauftrag)
Die Einzeltests aus Abschnitt 1+2 prüfen jedes Ticket isoliert — sie beweisen nicht, dass die 21 Pakete *zusammen* den vollen Weg tragen. Dafür neu geschrieben: `internal/e2e/`.
### 3.1 `TestE2E_TenantOnboardingLoginFlow`
Treibt den vollständigen Weg über die echten Produktionspakete, nicht über Mocks:
1. `internal/tenant.Provisioner.Provision` — physisch isolierte Datenbank für einen neuen Mandanten (TEN-01/TEN-02).
2. Schema-Rollout über **alle** `migrations/tenant/*.up.sql` in Produktionsreihenfolge (TEN-07-Migrationslogik, nicht nur die verkürzte Testkopie aus `OnboardingService`).
3. `internal/user.TenantUserStore.Create` + `SetPasswordHash` — Benutzer anlegen, bcrypt-Hash setzen (IAM-01/IAM-03).
4. `internal/auth.LoginService.Login` — Anmeldung (IAM-04/IAM-05).
5. Assert: Token ist mit `TokenIssuer.Verify` gültig, `Claims.TenantSlug` und `Claims.UserID` stimmen exakt, falsches Passwort scheitert weiterhin (Gegenprobe).
**Ergebnis:** bestanden (siehe Abschnitt 5, Build/Test-Ergebnis auf 131).
### 3.2 `TestE2E_CrossTenantIsolation`
Zwei vollständig provisionierte Mandanten (`e2e-tenant-a`, `e2e-tenant-b`), je ein Benutzer, drei unabhängige Beweise:
- **Datenbankebene:** aus der Datenbank von Tenant A ist keine Zeile für den Tenant-B-Benutzer abfragbar (und umgekehrt) — kein Query-Filter, sondern physisch getrennte Datenbanken.
- **Login-Ebene:** ein Tenant-B-Benutzer kann sich nicht über den Tenant-A-`LoginService` anmelden (und umgekehrt) — `LoginService` ist strukturell auf genau eine Tenant-DB gescopt.
- **Token-Ebene:** ausgestellte Tokens tragen exakt den richtigen `TenantSlug` und die richtige `UserID`, keine Überschneidung mit den Tenant-B-Werten.
**Ergebnis:** bestanden (siehe Abschnitt 5).
## 4. Abweichungen
### 4.1 `internal/migrate` (TEN-07) hat keine eigene Testdatei im gemergten Stand — Schweregrad: Niedrig
Kein `*_test.go` für `internal/migrate/orchestrator.go`/`migrations.go` im Merge-Ergebnis dieses Branches gefunden. Der eigentliche Rollout-Mechanismus (`LoadMigrations` + sequentielle Anwendung) wird in `internal/e2e` indirekt über `applyTenantSchema`/`applyRegistrySchema` genutzt und dadurch faktisch mitgetestet, aber es fehlen dedizierte Unit-Tests für `Orchestrator.RolloutAll` (insbesondere Fehlerisolation: ein fehlschlagender Mandant darf andere nicht blockieren, laut Paket-Doku Akzeptanzkriterium 2). Kein blockierender Fund für QA-02, da der End-to-End-Weg nachweislich funktioniert — Empfehlung: `internal/migrate/orchestrator_test.go` bei Gelegenheit nachziehen.
Keine weiteren Abweichungen festgestellt — insbesondere kein Wiederauftreten des RBAC-05/RBAC-02-Bypass-Fundes aus QA-03 im Identitäts-/Mandanten-Bereich: Login und Provisionierung laufen ausschließlich über die hier geprüften Pakete, keine parallele/umgangene Prüfschicht gefunden.
### 4.2 Echter Merge-Konflikt zwischen IAM-12 (SAML/OIDC-Client) und IAM-13 (Core als OIDC-Provider) — Schweregrad: Hoch, behoben
**Fund:** `internal/oidc/jwks.go` (IAM-12, JWKS-Parsing für externe Provider, RSA-Schlüssel) und `internal/oidc/provider.go` (IAM-13, Core als eigener IdP, Ed25519/OKP-Schlüssel) definierten beide einen Typ `jwk`/`jwkSet` im selben Package — kompiliert einzeln pro Branch fehlerfrei, scheitert aber beim Zusammenführen beider Branches mit `jwk redeclared in this block` und Folgefehlern (unbekannte Felder `Crv`/`X`/`Use`/`Alg`). Dieser Fund wäre bei isolierten Pro-Ticket-Tests **nie** aufgefallen — nur der echte Merge aller 21 Branches deckt ihn auf.
**Behoben:** IAM-13s Typen in `internal/oidc/provider.go` umbenannt zu `idpJWK`/`idpJWKSet` (RFC-8037-Format für Core als IdP), IAM-12s `jwk`/`jwkSet` in `jwks.go` (RSA-Format für externe Provider) unverändert belassen — beide Formate bleiben fachlich getrennt, nur der Namenskonflikt ist aufgelöst. `internal/oidc/provider_test.go` entsprechend angepasst.
**Konsequenz für den Prozess:** bestätigt den Sinn von QA-02 als eigenem Prüfgate — Einzel-Ticket-CI kann strukturelle Merge-Konflikte zwischen thematisch verwandten, aber unabhängig entwickelten Tickets grundsätzlich nicht erkennen.
## 5. Build/Test-Ergebnis auf 131
Durchgeführt 2026-08-29 auf root@192.168.1.131 (`/root/nexarch-code-qa02`, isolierter Sync, kein Konflikt mit dem parallelen QA-03-Testlauf in `/root/nexarch-code-qa03`):
- `go mod tidy`, `go build ./...`, `go vet ./...` — nach Behebung des Merge-Konflikts aus 4.2 alle sauber, keine Fehler.
- `go test ./... -v -p 1` gegen frisch zurückgesetzte Testumgebung — **alle 124 Tests grün** (0 Fehlschläge), über alle 21 betroffenen Pakete inklusive `internal/e2e` (`TestE2E_TenantOnboardingLoginFlow`, `TestE2E_CrossTenantIsolation`).
- Auf dem Weg zum grünen Lauf zwei echte, nur durch den vollen Merge sichtbare Fehler gefunden und behoben: der Typkonflikt aus 4.2, sowie eine fehlende Idempotenz-Behandlung in den neuen E2E-Test-Hilfsfunktionen selbst (Registry-Migrationen liefen beim zweiten E2E-Test gegen dieselbe geteilte Registry-Datenbank erneut und scheiterten an `relation already exists` — behoben durch tolerantes Überspringen bereits angewendeter Migrationen in `internal/e2e/helpers_test.go`, kein Produktionscode betroffen).
- Keine Regressionen in TEN-01..07 oder IAM-01..14 durch den 21-Branch-Merge.
**QA-02 Gesamtergebnis: bestanden.** Ein behobener Hoch-Schweregrad-Merge-Konflikt (4.2, echter struktureller Fund dieses Prüfgates), keine offenen Blocker.
## 6. Gesamtergebnis
**Bestanden.** Der volle End-to-End-Weg über alle 21 Vorbedingungs-Tickets (TEN-01..07, IAM-01..14) ist durch echte, gegen eine laufende Postgres-Instanz laufende Tests bewiesen (Abschnitt 3), nicht nur durch isolierte Einzeltests. Ein struktureller Merge-Konflikt wurde aufgedeckt und behoben (4.2) — genau die Art Fund, für die dieses Prüfgate existiert. Ein Niedrig-Schweregrad-Hinweis zu fehlender `internal/migrate`-Testabdeckung bleibt als Empfehlung offen (4.1).
-149
View File
@@ -1,149 +0,0 @@
# QA-04 Prüfprotokoll: Sicherheits- & Penetrationsprüfung Core
Welle 6. Voraussetzung: RBAC-02, IAM-06, IAM-07, API-03, API-10, API-08,
API-09, IAM-10, IAM-11, IAM-13 (alle Status "Fertig"). Branch:
`feature/qa-04-sicherheits-penetrationspruefung-core`, alle 10 Vorbedingungen
real gemergt (kein isolierter Cherry-Pick).
## 1. Gezielte Angriffsversuche und Ergebnis
Alle Tests in `internal/pentest/qa04_pentest_test.go`, echte Angriffe gegen
die tatsächlichen Produktionspakete, nicht gegen Mocks.
### 1.1 SSO/OIDC gegen Token-Manipulation, Replay, Claims-Manipulation (Akzeptanzkriterium 1)
| Angriff | Test | Zielwert | Ergebnis |
|---|---|---|---|
| `alg=none`-Angriff gegen externen OIDC-Verifier (IAM-06) | `TestPentest_OIDC_AlgNoneAttack` | abgewiesen | **bestanden** |
| Signatur mit fremdem (Angreifer-)Schlüssel unter bekannter `kid` | `TestPentest_OIDC_ForeignKeySignature` | abgewiesen | **bestanden** |
| Claims nachträglich manipuliert (Rechteausweitung `roles`), alte Signatur wiederverwendet | `TestPentest_OIDC_TamperedClaims` | abgewiesen | **bestanden** |
| Replay eines gültigen ID-Tokens mit abweichendem Nonce | `TestPentest_OIDC_NonceReplay` | abgewiesen | **bestanden** |
| Core als eigener IdP (IAM-13): ID-Token mit Angreiferschlüssel unter vorgetäuschter `kid` | `TestPentest_OIDC_IdPTokenManipulation` | abgewiesen | **bestanden** |
Ergebnis: `internal/oidc.Verifier` erzwingt Algorithmus (`RS256` bzw.
`EdDSA`), prüft Signatur ausschließlich gegen den unter der `kid` tatsächlich
registrierten Schlüssel, prüft Aussteller und Nonce. Kein Umgehungsweg
gefunden.
### 1.2 Rate-Limiting/Account-Lockout im Mehrinstanz-Betrieb (Akzeptanzkriterium 2, Prüfung 1)
| Angriff | Test | Zielwert | Ergebnis |
|---|---|---|---|
| Rate-Limit über 4 unabhängige "Instanzen" (eigene DB-Pools) parallel umgehen | `TestPentest_RateLimit_MultiInstanceBypassAttempt` | Gesamtsumme erlaubter Anfragen = konfiguriertes Limit, nicht mehr | **bestanden** 200 Anfragen über 4 Instanzen, exakt 100 (=Limit) erlaubt |
| Account-Lockout über 3 unabhängige Instanzen verteilte Brute-Force-Versuche umgehen | `TestPentest_Lockout_MultiInstanceBypassAttempt` | Konto wird trotz Verteilung gesperrt | **bestanden** 12 Versuche über 3 Instanzen, Sperre nach Erreichen von `maxFailed=5` |
Ergebnis: beide Mechanismen nutzen ein atomares SQL-`UPSERT` gegen
geteilten, externen Zustand (Postgres) statt In-Process-Zähler exakt der
in archivdms als Fehlerklasse dokumentierte Schwachpunkt wird hier vermieden.
### 1.3 Zentrale Policy-Durchsetzung, gezielter Umgehungsversuch je Modul (Akzeptanzkriterium 3, Prüfung 3)
| Angriff | Test | Zielwert | Ergebnis |
|---|---|---|---|
| Rechteausweitung: Rolle `user`/`tenant_admin` versucht `platform.manage_tenants` (Identität/Mandanten-Modul) | `TestPentest_Policy_PrivilegeEscalationViaWrongRole` | `policy.ErrDenied` | **bestanden** |
| Strukturelle Umgehung von `policy.Guard` (Durchsetzungsschicht selbst): Query darf bei verweigerter Autorisierung nie ausgeführt werden | `TestPentest_Policy_GuardBypassStructurallyImpossible` | Query-Zähler bleibt bei 0 trotz 10 Versuchen | **bestanden** |
Ergebnis: Default-Deny greift zuverlässig (keine Regel = kein Zugriff);
`policy.Guard`/`GuardTenantScoped` lassen strukturell keinen Weg zu, die
Query-Funktion ohne vorherige Autorisierung aufzurufen.
### 1.4 Master-Key- und Tenant-KEK-Verwaltung gegen Kompromittierung/Rotation (Akzeptanzkriterium 4)
| Angriff | Test | Zielwert | Ergebnis |
|---|---|---|---|
| Kompromittierter ALTER Master-Key nach Rotation weiterverwenden | `TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation` | alter Key entschlüsselt nicht mehr, Plaintext-KEK unverändert | **bestanden** |
| Tenant-KEK-Rotation eines Mandanten wirkt sich auf einen anderen Mandanten aus | `TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants` | Mandant B unberührt | **bestanden** |
| Manipulierter, gespeicherter `wrapped_kek` (DB-Zugriff ohne Master-Key) wird beim Entschlüsseln akzeptiert | `TestPentest_KEK_TamperedWrappedKeyRejected` | `ErrUnwrapFailed` | **bestanden** |
Ergebnis: AES-256-GCM (authentifizierend) verhindert Akzeptanz manipulierter
Chiffretexte; Master-Key-Rotation ändert ausschließlich die Verpackung, nie
den Plaintext-Tenant-KEK; Tenant-KEK-Isolation (Modell C, Fortsetzung auf
Schlüsselebene) hält auch unter gezielter Rotation eines einzelnen Mandanten.
## 2. Beim Merge/Pentest gefundene Fehler (nicht vorab bekannt)
### 2.1 `scripts/reset-test-env.sh`: fehlende Bereinigung von `tenant_keks` (Schweregrad **Hoch**, behoben)
Beim ersten vollen Testlauf über alle 29 Pakete schlug
`TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation` mit 48
unerwarteten `failedTenantIDs` in `RotateMasterKey` fehl. Ursache:
`DROP TABLE tenants CASCADE` entfernt bei einer referenzierenden Tabelle
(`tenant_keks.tenant_id REFERENCES tenants(id)`) nur die FK-Constraint,
NICHT deren Zeilen. Da `reset-test-env.sh` `tenant_keks` (API-10-Migration
`0006_tenant_keks.up.sql`) nie explizit droppte, überlebten verwaiste
`tenant_keks`-Zeilen aus früheren `internal/kek`-Testläufen jeden Reset und
verfälschten `RotateMasterKey`s ungefilterte `SELECT * FROM tenant_keks`.
Behoben: `reset-test-env.sh` droppt jetzt explizit alle Registry-Tabellen aus
`migrations/*.up.sql` (`tenant_keks`, `rate_limit_counters`,
`rate_limit_configs`, `policy_rule_changes`, `policy_rules`,
`feature_flags`, `module_credentials`, `modules`), nicht nur die vier
ursprünglich behandelten. Reiner Testinfrastruktur-Fix, keine
Produktionsmigration verändert.
### 2.2 `internal/e2e`-Tests: `defer adminPool.Close()` lief vor `t.Cleanup`-Bereinigung (Schweregrad **Mittel**, behoben)
Voller Testlauf zeigte anschließend `internal/migrate`-Fehlschläge
(„erwartet 3 Ergebnisse, habe 15" bzw. 12, mit Zeilen wie `e2e_tenant_a`).
Ursache: `TestE2E_TenantOnboardingLoginFlow` und `TestE2E_CrossTenantIsolation`
schlossen den `adminPool` per `defer adminPool.Close()`, ihre
`t.Cleanup`-Funktion (welche die angelegten Testmandanten wieder löscht)
lief aber wie in Go üblich ERST NACH allen `defer`-Aufrufen der
Testfunktion, also gegen einen bereits geschlossenen Pool. Der Fehler wurde
durch `_, _ = adminPool.Exec(...)` verschluckt; die Zeilen blieben dauerhaft
in der geteilten `tenants`-Tabelle stehen und verfälschten
`internal/migrate`s ungefilterte `registry.List()`-Abfrage im selben
Prozess/derselben Datenbank.
Behoben: `adminPool.Close()` in beiden Tests über `t.Cleanup` statt `defer`
registriert (vor der Lösch-Cleanup, sodass LIFO die richtige Reihenfolge
erzwingt Pool bleibt bis nach dem Löschen offen).
### 2.3 `internal/kek/kek_test.go`: derselbe defer/`t.Cleanup`-Fehler, zusätzlich fehlende Zeilen-Bereinigung (Schweregrad **Mittel**, behoben)
Dieselbe Fehlerklasse wie 2.2, zusätzlich verschärft: `createTenant` legte
Zeilen mit `db_dsn='unused'` an, ohne JEMALS eine eigene Aufräumfunktion zu
registrieren `setupTest`s `cleanup := func(){ pool.Close() }` schloss nur
den Pool, löschte aber nie die angelegten `tenants`/`tenant_keks`-Zeilen.
Behoben: `createTenant` registriert jetzt eine eigene `t.Cleanup`, die
Zeile+abhängigen `tenant_keks`-Eintrag löscht; `setupTest` schließt den Pool
über `t.Cleanup` (nicht mehr über den zurückgegebenen `cleanup()`, den
Aufrufer per `defer` aufriefen) damit läuft die Lösch-Cleanup (später
registriert) vor dem Pool-Schließen (früher registriert).
### 2.4 `internal/migrate/migrate_test.go`: hartkodiertes Testschema ohne Lifecycle-Spalten (Schweregrad **Mittel**, behoben)
`setupOrchestratorTest` legte `tenants` über ein eigenes,
hartkodiertes `CREATE TABLE IF NOT EXISTS` ohne die Spalten
`previous_status`/`deletion_scheduled_at` an, die TEN-04
(`internal/tenant/registry.go`, `GetBySlug`/`List`) inzwischen mitliest.
Lief das Paket ISOLIERT (Tabelle wird von diesem Test selbst frisch
angelegt), fiel der Fehler nicht auf; erst im vollen Testlauf bei dem die
Tabelle bereits über die echten Registry-Migrationen existiert griff
`CREATE TABLE IF NOT EXISTS` nicht mehr und der Fehler
`column "previous_status" does not exist` trat zutage.
Behoben: fehlende Spalten im Testschema ergänzt (kein Produktionscode
betroffen).
## 3. Build/Test-Ergebnis auf 192.168.1.131 (`/root/nexarch-code-qa04`)
```
go mod tidy -> clean
go build ./... -> clean
go vet ./... -> clean
go test ./... -p 1 -count=1 -> 29/29 Pakete ok, 0 Fehlschläge
```
Enthält insbesondere `internal/pentest` (12 neue Angriffstests, alle
bestanden) sowie die bereits bestehenden 124 Tests aus QA-02/QA-03 und alle
Tests der 10 frisch gemergten Vorbedingungs-Branches.
## 4. Gesamtergebnis
**Bestanden.** Alle vier Akzeptanzkriterien durch gezielte, dokumentierte
Angriffsversuche mit Ergebnisprotokoll erfüllt; alle drei Pflichtprüfungen
durchgeführt und protokolliert (Abschnitte 1.21.4). Vier real gefundene
Fehler (Abschnitt 2, Schweregrad Hoch/Mittel) behoben ausschließlich
Testinfrastruktur, kein Produktionscode verändert.
+1 -13
View File
@@ -2,16 +2,4 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.22
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/crypto v0.17.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
require github.com/jackc/pgx/v5 v5.6.0
-20
View File
@@ -1,20 +0,0 @@
package apiserver
import "context"
type contextKey int
const requestContextKey contextKey = iota
// RequestContext ist der Tenant-/Benutzerkontext, den die Middleware-Kette
// fuer nachgelagerte Handler bereitstellt (Akzeptanzkriterium 3).
type RequestContext struct {
UserID string
TenantSlug string
}
// FromContext liest den von der Middleware gesetzten Kontext.
func FromContext(ctx context.Context) (RequestContext, bool) {
rc, ok := ctx.Value(requestContextKey).(RequestContext)
return rc, ok
}
-31
View File
@@ -1,31 +0,0 @@
// Package apiserver implementiert Core API-01: das REST-Grundgerüst mit
// URL-Versionierung, einheitlichem Fehlerformat und Middleware-Kette
// (Auth, Tenant-/Benutzerkontext, Logging).
package apiserver
import (
"encoding/json"
"net/http"
)
// errorBody ist das EINE Fehlerschema fuer alle Endpunkte unter /api/{version}/
// (Akzeptanzkriterium 2).
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// WriteError schreibt einen Fehler im einheitlichen Schema. code ist ein
// stabiler, maschinenlesbarer Bezeichner (z.B. "unauthenticated"), message
// ein fuer Menschen lesbarer deutscher Text.
func WriteError(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
-56
View File
@@ -1,56 +0,0 @@
package apiserver
import (
"context"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// authAndTenantContext prueft die Session (wiederverwendet auth.TokenIssuer.Verify
// aus IAM-02 — keine zweite JWT-Implementierung) und setzt bei Erfolg
// RequestContext fuer nachgelagerte Handler (Akzeptanzkriterium 3). Anders
// als auth.RequireAuth (Klartext-Fehler) antwortet diese Middleware im
// einheitlichen API-01-Fehlerschema (Akzeptanzkriterium 2), damit ALLE
// Endpunkte unter /api/{version}/ dasselbe Format liefern, auch bei
// Auth-Fehlern.
func authAndTenantContext(issuer *auth.TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(auth.CookieName)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
rc := RequestContext{UserID: claims.UserID, TenantSlug: claims.TenantSlug}
next(w, r.WithContext(context.WithValue(r.Context(), requestContextKey, rc)))
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// loggingMiddleware protokolliert jede Anfrage strukturiert.
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next(rec, r)
slog.Info("api-anfrage", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dauer", time.Since(start))
}
}
-37
View File
@@ -1,37 +0,0 @@
package apiserver
import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// Server registriert versionierte API-Routen (Akzeptanzkriterium 1: unter
// /api/{version}/...) und verdrahtet fuer jede Route dieselbe Middleware-
// Kette (Logging -> Auth+Tenantkontext -> Handler).
type Server struct {
mux *http.ServeMux
issuer *auth.TokenIssuer
}
func NewServer(issuer *auth.TokenIssuer) *Server {
return &Server{mux: http.NewServeMux(), issuer: issuer}
}
// Handle registriert pattern unter der angegebenen Version, z.B.
// Handle("v1", "/things", h) -> erreichbar unter /api/v1/things. Verschiedene
// Versionen sind unabhaengige Pfade — eine neue Version beeintraechtigt
// bestehende nicht (Akzeptanzkriterium 1 / Pruefung 3).
func (s *Server) Handle(version, pattern string, h http.HandlerFunc) {
full := "/api/" + version + pattern
s.mux.HandleFunc(full, loggingMiddleware(authAndTenantContext(s.issuer, h)))
}
// HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.
func (s *Server) HandleV1(pattern string, h http.HandlerFunc) {
s.Handle("v1", pattern, h)
}
func (s *Server) Handler() http.Handler {
return s.mux
}
-149
View File
@@ -1,149 +0,0 @@
package apiserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
func newTestServer() (*Server, *auth.TokenIssuer) {
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
return NewServer(issuer), issuer
}
func withAuthCookie(req *http.Request, token string) *http.Request {
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
return req
}
// Akzeptanzkriterium 1: API unter versioniertem Pfad erreichbar.
func TestHandleV1_RegistersUnderVersionedPath(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 1 (Stichprobe): mehrere Endpunkte liefern
// bei fehlerhafter Anfrage dasselbe Fehlerschema.
func TestErrorFormat_ConsistentAcrossEndpoints(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/endpunkt-a", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
srv.HandleV1("/endpunkt-b", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
for _, path := range []string{"/api/v1/endpunkt-a", "/api/v1/endpunkt-b"} {
req := httptest.NewRequest(http.MethodGet, path, nil) // ohne cookie -> 401
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s: status = %d, want 401", path, rec.Code)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("%s: fehlerantwort nicht im erwarteten json-schema: %v (body: %s)", path, err, rec.Body.String())
}
if body.Error.Code == "" || body.Error.Message == "" {
t.Fatalf("%s: erwartet nicht-leeren code/message, habe %+v", path, body)
}
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Middleware-Kette setzt Tenant-/
// Benutzerkontext zuverlaessig, nachweislich fuer mehrere Endpunkte.
func TestMiddleware_SetsRequestContextForEveryEndpoint(t *testing.T) {
srv, issuer := newTestServer()
var gotA, gotB RequestContext
srv.HandleV1("/kontext-a", func(w http.ResponseWriter, r *http.Request) {
gotA, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
srv.HandleV1("/kontext-b", func(w http.ResponseWriter, r *http.Request) {
gotB, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-42", "tenant-x")
if err != nil {
t.Fatalf("issue: %v", err)
}
for path, got := range map[string]*RequestContext{"/api/v1/kontext-a": &gotA, "/api/v1/kontext-b": &gotB} {
req := withAuthCookie(httptest.NewRequest(http.MethodGet, path, nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d, want 200", path, rec.Code)
}
if got.UserID != "user-42" || got.TenantSlug != "tenant-x" {
t.Fatalf("%s: request-context unerwartet: %+v", path, *got)
}
}
}
// Akzeptanzkriterium 1 + Pruefung 3: eine neue v2-Route laesst sich anlegen,
// ohne v1 zu beeintraechtigen.
func TestVersioning_V2DoesNotAffectV1(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v1-antwort"))
})
srv.Handle("v2", "/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v2-antwort"))
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
reqV1 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1, reqV1)
if recV1.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort = %q, want v1-antwort", recV1.Body.String())
}
reqV2 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v2/things", nil), token)
recV2 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV2, reqV2)
if recV2.Body.String() != "v2-antwort" {
t.Fatalf("v2 antwort = %q, want v2-antwort", recV2.Body.String())
}
// v1 nach dem Anlegen von v2 erneut pruefen — unveraendert.
reqV1Again := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1Again := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1Again, reqV1Again)
if recV1Again.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort nach v2-anlage = %q, want weiterhin v1-antwort", recV1Again.Body.String())
}
}
func TestAuthAndTenantContext_RejectsInvalidToken(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/geschuetzt", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/geschuetzt", nil), "kaputtes.token.hier")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
-65
View File
@@ -1,65 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ChangePasswordHandler stellt die Passwortaenderung fuer den bereits
// angemeldeten Benutzer bereit (IAM-08 Akzeptanzkriterium 2). Getrennt von
// Handler (Login/Logout), weil hier zusaetzlich der TenantUserStore fuer das
// Aktualisieren des Hashes gebraucht wird.
type ChangePasswordHandler struct {
users *user.TenantUserStore
}
func NewChangePasswordHandler(users *user.TenantUserStore) *ChangePasswordHandler {
return &ChangePasswordHandler{users: users}
}
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// ChangePassword verlangt das aktuelle Passwort, bevor ein neues gesetzt wird
// — ohne diese Pruefung koennte ein gekapertes, noch gueltiges Session-Cookie
// allein zur vollstaendigen Kontoübernahme reichen (Passwort setzen, alte
// Session unbrauchbar machen). Muss hinter RequireAuth haengen.
func (h *ChangePasswordHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
creds, err := h.users.GetByIDForAuth(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
if !VerifyPassword(creds.PasswordHash, req.CurrentPassword) {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
newHash, err := HashPassword(req.NewPassword)
if err != nil {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
if err := h.users.SetPasswordHash(r.Context(), claims.UserID, newHash); err != nil {
http.Error(w, "Passwort konnte nicht gespeichert werden.", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
-76
View File
@@ -1,76 +0,0 @@
package auth
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// Akzeptanzkriterium 2: Passwortaenderung verlangt das aktuelle Passwort.
func TestChangePassword_RequiresCurrentPassword(t *testing.T) {
pool := setupTenantDB(t, "changepw_test_wrong")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "nutzer@example.com", "altes-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewChangePasswordHandler(store)
body, _ := json.Marshal(map[string]string{"current_password": "falsches-altes-passwort", "new_password": "neues-passwort"})
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.ChangePassword)(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (falsches aktuelles passwort)", rec.Code)
}
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
if err != nil {
t.Fatalf("credentials laden: %v", err)
}
if !VerifyPassword(creds.PasswordHash, "altes-passwort") {
t.Fatal("passwort haette NICHT geaendert werden duerfen")
}
}
func TestChangePassword_SucceedsWithCorrectCurrentPassword(t *testing.T) {
pool := setupTenantDB(t, "changepw_test_ok")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "nutzer2@example.com", "altes-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewChangePasswordHandler(store)
body, _ := json.Marshal(map[string]string{"current_password": "altes-passwort", "new_password": "neues-sicheres-passwort"})
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.ChangePassword)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
if err != nil {
t.Fatalf("credentials laden: %v", err)
}
if !VerifyPassword(creds.PasswordHash, "neues-sicheres-passwort") {
t.Fatal("neues passwort wurde nicht uebernommen")
}
}
-67
View File
@@ -1,67 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"time"
)
// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung,
// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht
// Teil dieser Kachel (siehe IAM-03..07).
type Handler struct {
login *LoginService
}
func NewHandler(login *LoginService) *Handler {
return &Handler{login: login}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
token, err := h.login.Login(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(AccessTokenTTL.Seconds()),
})
w.WriteHeader(http.StatusOK)
}
// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt
// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst"
// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung),
// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch
// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
w.WriteHeader(http.StatusOK)
}
-56
View File
@@ -1,56 +0,0 @@
package auth
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch")
// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber
// den TenantUserStore-Pool) — das Login ist damit strukturell auf den
// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth.
type LoginService struct {
users *user.TenantUserStore
issuer *TokenIssuer
// tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten
// eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs).
tenantSlug string
}
func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService {
return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug}
}
// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben
// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz
// eines Kontos zuzulassen (User-Enumeration-Schutz).
func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) {
creds, err := s.users.GetByEmailForAuth(ctx, email)
if err != nil {
// Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren,
// damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer
// ist als bei falschem Passwort (Timing-Seitenkanal).
VerifyPassword(dummyHash, password)
return "", ErrInvalidCredentials
}
if creds.User.Status != user.StatusActive {
return "", ErrInvalidCredentials
}
if !VerifyPassword(creds.PasswordHash, password) {
return "", ErrInvalidCredentials
}
return s.issuer.Issue(creds.User.ID, s.tenantSlug)
}
// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines
// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da
// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
// Timing-Angleichung damit wirkungslos waere.
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
-177
View File
@@ -1,177 +0,0 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, usersSchema); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User {
t.Helper()
ctx := context.Background()
u, err := store.Create(ctx, email, "Test User")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
return u
}
func TestLoginService_SuccessAndWrongPassword(t *testing.T) {
pool := setupTenantDB(t, "test_iam02_login")
store := user.NewTenantUserStore(pool)
createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
login := NewLoginService(store, issuer, "acme")
token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres token")
}
if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err)
}
if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err)
}
}
// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in
// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern
// existiert.
func TestLoginService_NoCrossTenantLogin(t *testing.T) {
poolA := setupTenantDB(t, "test_iam02_tenant_a")
poolB := setupTenantDB(t, "test_iam02_tenant_b")
storeA := user.NewTenantUserStore(poolA)
storeB := user.NewTenantUserStore(poolB)
createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a")
createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
loginA := NewLoginService(storeA, issuer, "tenant-a")
// Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen,
// obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt
// strukturell nur die Zeilen ihrer eigenen Datenbank.
if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err)
}
token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a")
if err != nil {
t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.TenantSlug != "tenant-a" {
t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug)
}
}
// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar.
func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Kein Cookie.
req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
rec := httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne cookie: status = %d, want 401", rec.Code)
}
// Manipuliertes Cookie.
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code)
}
// Gueltiges Token.
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code)
}
}
-41
View File
@@ -1,41 +0,0 @@
package auth
import (
"context"
"net/http"
)
const CookieName = "nexarch_session"
type contextKey int
const claimsContextKey contextKey = iota
// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token
// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen
// (IAM-02 Akzeptanzkriterium 3).
func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(CookieName)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
next(w, r.WithContext(ctx))
}
}
// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context
// gelegt hat.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsContextKey).(*Claims)
return c, ok
}
-28
View File
@@ -1,28 +0,0 @@
// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und
// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf)
// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur
// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket).
package auth
import "golang.org/x/crypto/bcrypt"
// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10)
// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12
// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go
// und den Pruefungs-Eintrag in der Commit-Nachricht.
const BcryptCost = 12
func HashPassword(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht
// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer
// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02).
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
-42
View File
@@ -1,42 +0,0 @@
package auth
import (
"testing"
"time"
)
// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der
// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist
// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber
// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12)
// zuverlaessig aufzudecken.
const TargetLoginLatency = 400 * time.Millisecond
// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines
// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das
// Ergebnis (IAM-02 Pruefung 4).
func TestBcryptCostAgainstLatencyTarget(t *testing.T) {
hash, err := HashPassword("benchmark-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
start := time.Now()
if !VerifyPassword(hash, "benchmark-passwort") {
t.Fatal("verifikation haette erfolgreich sein muessen")
}
elapsed := time.Since(start)
t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency)
if elapsed > TargetLoginLatency {
t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency)
}
}
func BenchmarkVerifyPassword(b *testing.B) {
hash, _ := HashPassword("benchmark-passwort")
b.ResetTimer()
for i := 0; i < b.N; i++ {
VerifyPassword(hash, "benchmark-passwort")
}
}
-28
View File
@@ -1,28 +0,0 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("s3hr-geheim!")
if err != nil {
t.Fatalf("hash: %v", err)
}
if hash == "s3hr-geheim!" {
t.Fatal("passwort wurde nicht gehasht")
}
if !VerifyPassword(hash, "s3hr-geheim!") {
t.Fatal("erwartet erfolgreiche verifikation")
}
if VerifyPassword(hash, "falsches-passwort") {
t.Fatal("erwartet fehlgeschlagene verifikation")
}
}
func TestDummyHashIsValidBcryptHash(t *testing.T) {
// Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich
// ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft
// (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen.
if VerifyPassword(dummyHash, "irgendein-text") {
t.Fatal("dummyHash sollte fuer beliebigen text nicht passen")
}
}
-44
View File
@@ -1,44 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ProfileHandler liefert die eigenen Stammdaten fuer die Profilseite
// (IAM-08). Getrennt von ChangePasswordHandler, da unterschiedliche
// Lesbarkeitsanforderungen (nur GET, kein Nebeneffekt).
type ProfileHandler struct {
users *user.TenantUserStore
}
func NewProfileHandler(users *user.TenantUserStore) *ProfileHandler {
return &ProfileHandler{users: users}
}
type meResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// Me liefert die Stammdaten des angemeldeten Benutzers. Muss hinter
// RequireAuth haengen.
func (h *ProfileHandler) Me(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
u, err := h.users.Get(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "benutzer nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meResponse{ID: u.ID, Email: u.Email, Name: u.Name})
}
-48
View File
@@ -1,48 +0,0 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func TestProfileMe_ReturnsOwnData(t *testing.T) {
pool := setupTenantDB(t, "profile_test_me")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "profil@example.com", "irgendein-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewProfileHandler(store)
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.Me)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
}
func TestProfileMe_RejectsWithoutSession(t *testing.T) {
pool := setupTenantDB(t, "profile_test_noauth")
store := user.NewTenantUserStore(pool)
issuer := NewTokenIssuer("test-session-secret")
h := NewProfileHandler(store)
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
rec := httptest.NewRecorder()
RequireAuth(issuer, h.Me)(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 ohne session-cookie", rec.Code)
}
}
-63
View File
@@ -1,63 +0,0 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
const AccessTokenTTL = 30 * time.Minute
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
type Claims struct {
UserID string `json:"uid"`
TenantSlug string `json:"tenant"`
jwt.RegisteredClaims
}
// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das
// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist
// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das
// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses.
type TokenIssuer struct {
secret []byte
}
func NewTokenIssuer(secret string) *TokenIssuer {
return &TokenIssuer{secret: []byte(secret)}
}
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
TenantSlug: tenantSlug,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(i.secret)
}
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist
// timing-safe (hmac.Equal).
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return i.secret, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
-82
View File
@@ -1,82 +0,0 @@
package auth
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestTokenIssueAndVerify(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
t.Fatalf("claims unerwartet: %+v", claims)
}
}
// Pruefung 2: Token-Manipulationstest.
func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("unerwartetes token-format: %d teile", len(parts))
}
// Payload-Segment leicht veraendern (Signatur passt danach nicht mehr).
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := issuer.Verify(tampered); err == nil {
t.Fatal("erwartet fehler bei manipuliertem token, habe nil")
}
}
func TestTokenVerify_RejectsWrongSecret(t *testing.T) {
issuer := NewTokenIssuer("secret-a")
other := NewTokenIssuer("secret-b")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := other.Verify(token); err == nil {
t.Fatal("erwartet fehler bei falschem secret, habe nil")
}
}
// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung.
func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
claims := Claims{
UserID: "user-1",
TenantSlug: "acme",
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests"))
if err != nil {
t.Fatalf("signieren: %v", err)
}
if _, err := issuer.Verify(expired); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
}
}
-40
View File
@@ -1,40 +0,0 @@
package authtoken
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// CompletePasswordReset loest ein Reset-Token ein und setzt das neue
// Passwort — komplett OHNE bestehende Session (Akzeptanzkriterium 2 gilt
// analog fuer Reset wie fuer Invite: der Vorgang ersetzt eine Anmeldung).
func CompletePasswordReset(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, newPassword string) error {
return completeWithToken(ctx, tokens, users, token, PurposeReset, newPassword)
}
// CompleteInvitation loest ein Einladungs-Token ein und setzt das
// Erstpasswort eines neu angelegten Benutzers — ohne bestehende Session
// (Akzeptanzkriterium 2).
func CompleteInvitation(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, initialPassword string) error {
return completeWithToken(ctx, tokens, users, token, PurposeInvite, initialPassword)
}
func completeWithToken(ctx context.Context, tokens *Store, users *user.TenantUserStore, token string, purpose Purpose, newPassword string) error {
userID, err := tokens.Consume(ctx, token, purpose)
if err != nil {
return err
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("passwort hashen: %w", err)
}
if err := users.SetPasswordHash(ctx, userID, hash); err != nil {
return fmt.Errorf("passwort setzen: %w", err)
}
return nil
}
-109
View File
@@ -1,109 +0,0 @@
package authtoken
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ResetTokenTTL: 1 Stunde ist der uebliche Kompromiss zwischen Nutzbarkeit
// und Angriffsflaeche fuer Passwort-Reset-Links.
const ResetTokenTTL = time.Hour
// Notifier verschickt das Reset-Token an den Benutzer. Core CFG-02
// (Benachrichtigungs-Dispatcher) ist noch nicht in diesem Branch verfuegbar
// (siehe SICHERHEITSKONZEPT.md / Core-Board-Konvention: Versand laeuft
// grundsaetzlich ueber CFG-02, kein eigener E-Mail-Versand je Modul) — bis
// zur Anbindung protokolliert LogNotifier den Vorgang nur, statt eine E-Mail
// zu verschicken. Produktionsreif ist erst der Austausch gegen einen
// CFG-02-Client, keine neue eigene Versandlogik hier.
type Notifier interface {
NotifyPasswordReset(ctx context.Context, email, token string) error
}
type LogNotifier struct{}
func (LogNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
// Token wird bewusst NICHT geloggt (gleiche Regel wie Store.Create).
slog.Info("passwort-reset angefordert (kein CFG-02 in diesem branch, nur protokolliert)", "email_domain_hint", domainHint(email))
return nil
}
func domainHint(email string) string {
for i := len(email) - 1; i >= 0; i-- {
if email[i] == '@' {
return email[i:]
}
}
return ""
}
// Handler stellt Passwort-Reset als HTTP-Endpunkte bereit (IAM-08).
type Handler struct {
tokens *Store
users *user.TenantUserStore
notifier Notifier
}
func NewHandler(tokens *Store, users *user.TenantUserStore, notifier Notifier) *Handler {
if notifier == nil {
notifier = LogNotifier{}
}
return &Handler{tokens: tokens, users: users, notifier: notifier}
}
type requestResetRequest struct {
Email string `json:"email"`
}
// RequestReset liefert IMMER dieselbe Erfolgsmeldung, unabhaengig davon, ob
// die E-Mail-Adresse existiert (Akzeptanzkriterium 3 / Pruefung 1,
// User-Enumeration-Schutz — gleiches Prinzip wie auth.LoginService.Login).
func (h *Handler) RequestReset(w http.ResponseWriter, r *http.Request) {
var req requestResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
// Absichtlich: Fehler (unbekannte E-Mail) fuehrt NICHT zu einer anderen
// HTTP-Antwort als der Erfolgsfall — nur der interne Pfad unterscheidet sich.
creds, err := h.users.GetByEmailForAuth(r.Context(), req.Email)
if err == nil {
token, tokenErr := h.tokens.Create(r.Context(), creds.User.ID, PurposeReset, ResetTokenTTL)
if tokenErr == nil {
_ = h.notifier.NotifyPasswordReset(r.Context(), req.Email, token)
}
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.",
})
}
type completeResetRequest struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
// CompleteReset schliesst den Reset ab (Akzeptanzkriterium 3: generische
// Fehlermeldung bei ungueltigem/abgelaufenem Token, siehe ErrInvalidToken).
func (h *Handler) CompleteReset(w http.ResponseWriter, r *http.Request) {
var req completeResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := CompletePasswordReset(r.Context(), h.tokens, h.users, req.Token, req.NewPassword); err != nil {
http.Error(w, "Link ungültig oder abgelaufen.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
-94
View File
@@ -1,94 +0,0 @@
package authtoken
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// fakeNotifier zeichnet auf, ob/mit welchem Token benachrichtigt wurde —
// ersetzt LogNotifier im Test, damit der Test pruefen kann, OB ein Token
// erzeugt wurde, ohne die HTTP-Antwort danach zu unterscheiden
// (Akzeptanzkriterium 3: die Antwort selbst bleibt in beiden Faellen gleich).
type fakeNotifier struct {
calls []string // e-mail-adressen, fuer die notifiziert wurde
}
func (f *fakeNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
f.calls = append(f.calls, email)
return nil
}
// Akzeptanzkriterium 3 + Pruefung 1: Antwort ist IDENTISCH, egal ob die
// E-Mail-Adresse existiert — nur intern (Notifier-Aufruf) unterscheidet sich.
func TestRequestReset_SameResponseRegardlessOfExistence(t *testing.T) {
tokens, users, userID, cleanup := setupTest(t)
defer cleanup()
_ = userID
u, err := users.Get(context.Background(), userID)
if err != nil {
t.Fatalf("benutzer laden: %v", err)
}
notifier := &fakeNotifier{}
h := NewHandler(tokens, users, notifier)
// Fall 1: existierende E-Mail.
body1, _ := json.Marshal(requestResetRequest{Email: u.Email})
req1 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body1))
rec1 := httptest.NewRecorder()
h.RequestReset(rec1, req1)
// Fall 2: nicht existierende E-Mail.
body2, _ := json.Marshal(requestResetRequest{Email: "gibt-es-nicht@example.com"})
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.RequestReset(rec2, req2)
if rec1.Code != rec2.Code {
t.Fatalf("status codes unterscheiden sich: %d vs %d — verraet Konto-Existenz", rec1.Code, rec2.Code)
}
if rec1.Body.String() != rec2.Body.String() {
t.Fatalf("antwort-body unterscheidet sich: %q vs %q — verraet Konto-Existenz", rec1.Body.String(), rec2.Body.String())
}
// Intern wurde aber nur fuer die existierende Adresse tatsaechlich benachrichtigt.
if len(notifier.calls) != 1 || notifier.calls[0] != u.Email {
t.Fatalf("erwartet genau 1 benachrichtigung fuer %q, habe %v", u.Email, notifier.calls)
}
}
// Akzeptanzkriterium 3: vollstaendiger Reset-Flow ueber HTTP.
func TestCompleteReset_Works(t *testing.T) {
tokens, users, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := tokens.Create(ctx, userID, PurposeReset, ResetTokenTTL)
if err != nil {
t.Fatalf("token erzeugen: %v", err)
}
h := NewHandler(tokens, users, &fakeNotifier{})
body, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "neues-sicheres-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.CompleteReset(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
// Token ist jetzt verbraucht — zweiter Versuch schlaegt fehl.
body2, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "noch-ein-passwort"})
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.CompleteReset(rec2, req2)
if rec2.Code != http.StatusBadRequest {
t.Fatalf("wiederverwendung: status = %d, want 400", rec2.Code)
}
}
-103
View File
@@ -1,103 +0,0 @@
// Package authtoken implementiert Core IAM-03: einmal-verwendbare Tokens fuer
// Passwort-Reset und Einladung neuer Benutzer, mit Ablaufzeit — bewusst
// getrennt von internal/auth (Sessions/JWT), da es hier um einen
// vorgelagerten, session-losen Vorgang geht (Akzeptanzkriterium 2: Einladung
// setzt das Erstpasswort OHNE bestehende Session).
package authtoken
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Purpose string
const (
PurposeReset Purpose = "reset"
PurposeInvite Purpose = "invite"
)
// ErrInvalidToken deckt sowohl unbekannte als auch bereits verwendete oder
// abgelaufene Tokens ab (Akzeptanzkriterium 3) — bewusst EIN Fehler, damit
// ein Angreifer aus der Antwort nicht ableiten kann, welcher der drei Faelle
// vorliegt.
var ErrInvalidToken = errors.New("authtoken: token ungueltig, bereits verwendet oder abgelaufen")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Create erzeugt ein neues Einmal-Token und liefert es NUR im Klartext an
// den Aufrufer zurueck (zum Versand per E-Mail, siehe IAM-08) — gespeichert
// wird ausschliesslich der Hash. Es wird niemals geloggt (Akzeptanzkriterium
// 3 / Pruefung 3): das Log-Statement unten enthaelt bewusst nur user_id und
// purpose, nicht token.
func (s *Store) Create(ctx context.Context, userID string, purpose Purpose, ttl time.Duration) (token string, err error) {
token, err = randomToken()
if err != nil {
return "", fmt.Errorf("token erzeugen: %w", err)
}
hash := hashToken(token)
expiresAt := time.Now().Add(ttl)
_, err = s.pool.Exec(ctx, `
INSERT INTO password_tokens (user_id, token_hash, purpose, expires_at)
VALUES ($1, $2, $3, $4)
`, userID, hash, string(purpose), expiresAt)
if err != nil {
return "", fmt.Errorf("token speichern: %w", err)
}
slog.Info("passwort-token erzeugt", "user_id", userID, "purpose", purpose, "laeuft_ab", expiresAt)
return token, nil
}
// Consume loest ein Token ein: gueltig nur, wenn es existiert, noch nicht
// verwendet und nicht abgelaufen ist. Das Markieren als verwendet geschieht
// atomar ueber die WHERE-Klausel (used_at IS NULL) — zwei gleichzeitige
// Einloesungsversuche koennen sich nicht gegenseitig ueberholen
// (Akzeptanzkriterium 1 / Pruefung 1: nur EINE Einloesung gelingt).
func (s *Store) Consume(ctx context.Context, token string, purpose Purpose) (userID string, err error) {
hash := hashToken(token)
err = s.pool.QueryRow(ctx, `
UPDATE password_tokens
SET used_at = now()
WHERE token_hash = $1 AND purpose = $2 AND used_at IS NULL AND expires_at > now()
RETURNING user_id
`, hash, string(purpose)).Scan(&userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
slog.Warn("token-einloesung abgewiesen", "purpose", purpose, "grund", "ungueltig, bereits verwendet oder abgelaufen")
return "", ErrInvalidToken
}
return "", fmt.Errorf("token einloesen: %w", err)
}
return userID, nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
-187
View File
@@ -1,187 +0,0 @@
package authtoken
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS password_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE, purpose TEXT NOT NULL CHECK (purpose IN ('reset','invite')),
expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("reset-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "Reset Test")
if err != nil {
t.Fatalf("testuser anlegen: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: Wiederverwendung eines bereits
// eingeloesten Tokens wird abgewiesen.
func TestConsume_RejectsReuse(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
gotUserID, err := store.Consume(ctx, token, PurposeReset)
if err != nil {
t.Fatalf("erste einloesung: %v", err)
}
if gotUserID != userID {
t.Fatalf("user id = %q, want %q", gotUserID, userID)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei zweiter einloesung, habe %v", err)
}
}
// Akzeptanzkriterium 1 + 3 + Pruefung 2: Ablaufzeit wird serverseitig
// durchgesetzt.
func TestConsume_RejectsExpiredToken(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeReset, -1*time.Second) // sofort abgelaufen
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe %v", err)
}
}
func TestConsume_RejectsUnknownToken(t *testing.T) {
store, _, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Consume(ctx, "niemals-ausgestelltes-token", PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
}
}
func TestConsume_RejectsWrongPurpose(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei falschem purpose, habe %v", err)
}
}
// Akzeptanzkriterium 2: Einladungs-Link setzt das Erstpasswort ohne
// bestehende Session — der Ablauf braucht keinerlei Auth-Middleware/Cookie.
func TestCompleteInvitation_SetsPasswordWithoutSession(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if err := CompleteInvitation(ctx, store, userStore, token, "neues-erstpasswort-123"); err != nil {
t.Fatalf("complete invitation: %v", err)
}
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("user lesen: %v", err)
}
creds, err := userStore.GetByEmailForAuth(ctx, u.Email)
if err != nil {
t.Fatalf("credentials lesen: %v", err)
}
if !auth.VerifyPassword(creds.PasswordHash, "neues-erstpasswort-123") {
t.Fatal("erwartet, dass das gesetzte erstpasswort verifizierbar ist")
}
// Token ist danach verbraucht, kann nicht doppelt eingeloest werden.
if err := CompleteInvitation(ctx, store, userStore, token, "irgendwas"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei erneuter einloesung, habe %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Token werden nicht im Klartext geloggt.
func TestCreateAndConsume_NeverLogTokenPlaintext(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
var buf bytes.Buffer
prevLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
defer slog.SetDefault(prevLogger)
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, "falsches-token-zum-testen-des-log-pfads", PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("consume (ungueltig): %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); err != nil {
t.Fatalf("consume (gueltig): %v", err)
}
logOutput := buf.String()
if strings.Contains(logOutput, token) {
t.Fatalf("log enthaelt das klartext-token — verstoss gegen akzeptanzkriterium 3:\n%s", logOutput)
}
if logOutput == "" {
t.Fatal("erwartet ueberhaupt log-ausgabe fuer diesen stichprobentest")
}
}
+2 -33
View File
@@ -10,18 +10,8 @@ import (
// connection info, superadmin accounts) — see nexarch-state.json
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
type Config struct {
ListenAddr string
// RegistryDSN verbindet zur Control-Plane-Registry-Datenbank.
ListenAddr string
RegistryDSN string
// AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur
// fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet.
AdminDSN string
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
TenantDSNTemplate string
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
TenantSchemaPath string
}
func Load() (Config, error) {
@@ -30,31 +20,10 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
}
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
if adminDSN == "" {
return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set")
}
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
if dsnTemplate == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
}
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
if schemaPath == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
}
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
return Config{
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
TenantSchemaPath: schemaPath,
}, nil
return Config{ListenAddr: addr, RegistryDSN: dsn}, nil
}
-120
View File
@@ -1,120 +0,0 @@
package e2e
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// TestE2E_CrossTenantIsolation ist der QA-02-Nachweis fuer Akzeptanzkriterium
// "keine Datenlecks zwischen Mandanten": zwei vollstaendig provisionierte
// Mandanten mit je einem Benutzer, dann drei unabhaengige Beweise, dass
// weder Datenbank- noch Token-Ebene eine Vermischung zulassen.
func TestE2E_CrossTenantIsolation(t *testing.T) {
adminDSN := requireAdminDSN(t)
ctx := context.Background()
root := repoRoot(t)
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
// Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
t.Cleanup(func() { adminPool.Close() })
applyRegistrySchema(t, ctx, adminPool, root)
registry := tenant.NewRegistry(adminPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
t.Cleanup(func() {
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('e2e_tenant_a','e2e_tenant_b')`)
dropTenantDBs(ctx, adminPool, "tenant_e2e_tenant_a", "tenant_e2e_tenant_b")
})
setupTenantWithUser := func(slug, name, email, password string) (tenant.Tenant, user.User, *auth.LoginService, *pgxpool.Pool) {
tn, err := provisioner.Provision(ctx, slug, name)
if err != nil {
t.Fatalf("provision %s: %v", slug, err)
}
pool, err := pgxpool.New(ctx, tn.DBDSN)
if err != nil {
t.Fatalf("connect %s: %v", slug, err)
}
applyTenantSchema(t, ctx, pool, root)
store := user.NewTenantUserStore(pool)
u, err := store.Create(ctx, email, "Benutzer "+slug)
if err != nil {
t.Fatalf("benutzer anlegen %s: %v", slug, err)
}
hash, err := auth.HashPassword(password)
if err != nil {
t.Fatalf("passwort hashen %s: %v", slug, err)
}
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("passwort setzen %s: %v", slug, err)
}
issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret")
login := auth.NewLoginService(store, issuer, tn.Slug)
return tn, u, login, pool
}
tenantA, userA, loginA, poolA := setupTenantWithUser("e2e_tenant_a", "Tenant A GmbH", "user@tenant-a.example", "passwort-a-sicher")
defer poolA.Close()
tenantB, userB, loginB, poolB := setupTenantWithUser("e2e_tenant_b", "Tenant B GmbH", "user@tenant-b.example", "passwort-b-sicher")
defer poolB.Close()
// Beweis 1: physische Trennung - Tenant Bs Benutzer existiert in der
// users-Tabelle von Tenant A schlicht nicht (keine Zeile, kein Filter).
var count int
if err := poolA.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-b.example").Scan(&count); err != nil {
t.Fatalf("query tenant a fuer tenant-b-email: %v", err)
}
if count != 0 {
t.Fatalf("tenant a datenbank enthaelt %d zeile(n) fuer tenant-b-benutzer, erwartet 0", count)
}
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-a.example").Scan(&count); err != nil {
t.Fatalf("query tenant b fuer tenant-a-email: %v", err)
}
if count != 0 {
t.Fatalf("tenant b datenbank enthaelt %d zeile(n) fuer tenant-a-benutzer, erwartet 0", count)
}
// Beweis 2: Login schlaegt fehl, wenn ein Benutzer versucht, sich ueber
// den falschen Tenant-LoginService anzumelden - er existiert dort nicht.
if _, err := loginA.Login(ctx, "user@tenant-b.example", "passwort-b-sicher"); err == nil {
t.Fatal("tenant-b-benutzer konnte sich ueber tenant-a-loginservice anmelden")
}
if _, err := loginB.Login(ctx, "user@tenant-a.example", "passwort-a-sicher"); err == nil {
t.Fatal("tenant-a-benutzer konnte sich ueber tenant-b-loginservice anmelden")
}
// Beweis 3: Token tragen den korrekten Tenant-Slug und die korrekte
// User-ID, keine Vermischung ueber gemeinsame IDs oder Slugs.
tokenA, err := loginA.Login(ctx, "user@tenant-a.example", "passwort-a-sicher")
if err != nil {
t.Fatalf("login tenant a: %v", err)
}
issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret")
claimsA, err := issuer.Verify(tokenA)
if err != nil {
t.Fatalf("verify token a: %v", err)
}
if claimsA.TenantSlug != tenantA.Slug || claimsA.UserID != userA.ID {
t.Fatalf("token a claims = %+v, want tenant %q user %q", claimsA, tenantA.Slug, userA.ID)
}
if claimsA.TenantSlug == tenantB.Slug || claimsA.UserID == userB.ID {
t.Fatal("token a claims stimmen mit tenant-b-daten ueberein")
}
}
-107
View File
@@ -1,107 +0,0 @@
// Package e2e enthaelt QA-02 (Pruefgate Identitaet & Mandanten): echte
// End-to-End-Tests gegen eine laufende Postgres-Instanz, die den vollen Weg
// "Mandant anlegen -> Benutzer anlegen -> anmelden" ueber die tatsaechlichen
// Produktionspakete (internal/tenant, internal/user, internal/auth) treiben,
// statt nur die Einzelpakete isoliert zu testen.
package e2e
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// requireAdminDSN liefert TEST_ADMIN_DSN oder ueberspringt den Test - wie in
// allen bestehenden Integrationstests dieses Repos (siehe
// internal/tenant/provisioner_test.go).
func requireAdminDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("TEST_ADMIN_DSN")
if dsn == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, E2E-Test uebersprungen")
}
return dsn
}
// repoRoot findet den Modul-Root ausgehend vom Testverzeichnis, damit die
// SQL-Migrationsverzeichnisse unabhaengig vom Arbeitsverzeichnis (go test
// wechselt ins Paketverzeichnis) gefunden werden.
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
// internal/e2e -> zwei Ebenen hoch zum Modul-Root.
return filepath.Join(wd, "..", "..")
}
// applyRegistrySchema legt die Control-Plane-Tabellen (tenants, superadmins,
// lifecycle-Spalten) frisch an, indem alle migrations/*.up.sql in
// Dateinamen-Reihenfolge angewendet werden - identisch zur echten
// Rollout-Reihenfolge, nicht eine verkuerzte Testkopie des Schemas.
func applyRegistrySchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
t.Helper()
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations"))
}
// applyTenantSchema initialisiert eine frisch provisionierte Tenant-Datenbank
// mit allen migrations/tenant/*.up.sql (nicht nur 0001_users, damit
// password_hash, role_assignments etc. wie in Produktion vorhanden sind).
func applyTenantSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
t.Helper()
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations", "tenant"))
}
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("migration %q lesen: %v", name, err)
}
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
// Migrationen sind Produktionsartefakte ohne eingebaute
// Idempotenz (in Produktion durch eine Migrations-Tracking-
// Tabelle abgesichert, die dieser Test bewusst nicht nachbaut).
// Zwei E2E-Tests im selben Prozess teilen sich dieselbe
// Registry-Datenbank, daher ist "schon vorhanden" hier kein
// Fehler, sondern der Normalfall beim zweiten Testlauf.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710") {
continue
}
t.Fatalf("migration %q anwenden: %v", name, err)
}
}
}
// dropTenantDBs entfernt Test-Datenbanken bedingungslos, fuer Cleanup nach
// dem Test unabhaengig vom Testausgang.
func dropTenantDBs(ctx context.Context, adminPool *pgxpool.Pool, names ...string) {
for _, n := range names {
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(n))
}
}
func quoteIdent(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
-116
View File
@@ -1,116 +0,0 @@
package e2e
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// TestE2E_TenantOnboardingLoginFlow ist der zentrale QA-02-Nachweis: der
// vollstaendige, echte Weg ueber TEN-01..07/IAM-01..14 hinweg -
//
// Mandant provisionieren (internal/tenant.Provisioner, physisch isolierte DB)
// -> Administrator anlegen (internal/user.TenantUserStore.Create +
// SetPasswordHash, wie es internal/tenant.OnboardingService fuer TEN-02
// tut, hier explizit nachgebaut um zusaetzlich ein Passwort zu setzen)
// -> Anmelden (internal/auth.LoginService.Login)
// -> Assert: gueltiges Token, richtiger Tenant.
func TestE2E_TenantOnboardingLoginFlow(t *testing.T) {
adminDSN := requireAdminDSN(t)
ctx := context.Background()
root := repoRoot(t)
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
// Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
t.Cleanup(func() { adminPool.Close() })
applyRegistrySchema(t, ctx, adminPool, root)
registry := tenant.NewRegistry(adminPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
t.Cleanup(func() {
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug = 'e2e_onboard'`)
dropTenantDBs(ctx, adminPool, "tenant_e2e_onboard")
})
// Schritt 1: Mandant provisionieren (TEN-01/TEN-02) - eigene, physisch
// getrennte Datenbank statt gemeinsamer tenant_id-Spalte.
tn, err := provisioner.Provision(ctx, "e2e_onboard", "E2E Onboarding GmbH")
if err != nil {
t.Fatalf("provision: %v", err)
}
tenantPool, err := pgxpool.New(ctx, tn.DBDSN)
if err != nil {
t.Fatalf("connect tenant db: %v", err)
}
defer tenantPool.Close()
// Schema in der frisch angelegten Tenant-Datenbank ausrollen (TEN-07-
// Migrationslogik, hier direkt statt ueber den Orchestrator, um im Test
// nicht von der Registry-Iteration abhaengig zu sein).
applyTenantSchema(t, ctx, tenantPool, root)
// Schritt 2: Benutzer anlegen (IAM-01/IAM-02) und Passwort setzen
// (IAM-03, bcrypt ueber internal/auth.HashPassword).
userStore := user.NewTenantUserStore(tenantPool)
created, err := userStore.Create(ctx, "admin@e2e_onboard.example", "E2E Admin")
if err != nil {
t.Fatalf("benutzer anlegen: %v", err)
}
hash, err := auth.HashPassword("korrekt-pferd-batterie-heftklammer")
if err != nil {
t.Fatalf("passwort hashen: %v", err)
}
if err := userStore.SetPasswordHash(ctx, created.ID, hash); err != nil {
t.Fatalf("passwort-hash setzen: %v", err)
}
// Schritt 3: Anmelden (IAM-04/IAM-05 - LoginService gegen genau diese
// Tenant-DB, kein tenant-uebergreifendes Login moeglich).
issuer := auth.NewTokenIssuer("test-signing-secret-fuer-qa-02-e2e")
login := auth.NewLoginService(userStore, issuer, tn.Slug)
token, err := login.Login(ctx, "admin@e2e_onboard.example", "korrekt-pferd-batterie-heftklammer")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("login lieferte leeres token")
}
// Assert: Token ist gueltig UND traegt den richtigen Tenant (Verify statt
// blindem String-Nichtleer-Check - sonst waere ein kaputtes Token auch
// "gruen").
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("token verifizieren: %v", err)
}
if claims.TenantSlug != "e2e_onboard" {
t.Fatalf("token tenant = %q, want %q", claims.TenantSlug, "e2e_onboard")
}
if claims.UserID != created.ID {
t.Fatalf("token user id = %q, want %q", claims.UserID, created.ID)
}
// Gegenprobe: falsches Passwort scheitert weiterhin (kein False-Positive
// durch einen zu freizuegigen Testaufbau).
if _, err := login.Login(ctx, "admin@e2e_onboard.example", "falsches-passwort"); err == nil {
t.Fatal("login mit falschem passwort haette scheitern muessen")
}
}
-87
View File
@@ -1,87 +0,0 @@
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
package flag
import (
"context"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("flag: nicht gefunden")
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
type Flag struct {
Key string
Enabled bool
RolloutPercentage int
TargetTenantSlugs []string
}
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Set(ctx context.Context, f Flag) error {
if f.TargetTenantSlugs == nil {
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
}
_, err := s.pool.Exec(ctx, `
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key) DO UPDATE SET
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
if err != nil {
return fmt.Errorf("flag speichern: %w", err)
}
return nil
}
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
var f Flag
row := s.pool.QueryRow(ctx, `
SELECT key, enabled, rollout_percentage, target_tenant_slugs
FROM feature_flags WHERE key = $1
`, key)
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Flag{}, ErrNotFound
}
return Flag{}, fmt.Errorf("flag lesen: %w", err)
}
return f, nil
}
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
// kein Feature wird versehentlich aktiv.
func evaluate(f Flag, tenantSlug string) bool {
if f.Enabled {
return true
}
for _, target := range f.TargetTenantSlugs {
if target == tenantSlug {
return true
}
}
if f.RolloutPercentage > 0 {
h := fnv.New32a()
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
return int(h.Sum32()%100) < f.RolloutPercentage
}
return false
}
-43
View File
@@ -1,43 +0,0 @@
package flag
import "testing"
func TestEvaluate_GlobalEnabled(t *testing.T) {
f := Flag{Key: "k", Enabled: true}
if !evaluate(f, "irgendein-tenant") {
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
if !evaluate(f, "acme") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if evaluate(f, "globex") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
full := Flag{Key: "k", RolloutPercentage: 100}
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
t.Fatal("100% rollout sollte immer true liefern")
}
none := Flag{Key: "k", RolloutPercentage: 0}
if evaluate(none, "beliebiger-tenant") {
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
}
}
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
f := Flag{Key: "k", RolloutPercentage: 50}
first := evaluate(f, "stabiler-tenant")
for i := 0; i < 5; i++ {
if evaluate(f, "stabiler-tenant") != first {
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
}
}
}
-87
View File
@@ -1,87 +0,0 @@
package flag
import (
"context"
"log/slog"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
// muss (Akzeptanzkriterium 3).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
flag Flag
expiresAt time.Time
}
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
f, ok := s.resolve(ctx, key)
if !ok {
return false
}
return evaluate(f, tenantSlug)
}
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
s.mu.RLock()
entry, exists := s.cache[key]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.flag, true
}
f, err := s.store.Get(ctx, key)
if err != nil {
if exists {
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
"flag_key", key, "error", err)
return entry.flag, true
}
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
"flag_key", key, "error", err)
return Flag{}, false
}
s.mu.Lock()
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return f, true
}
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
func (s *Service) Invalidate(key string) {
s.mu.Lock()
delete(s.cache, key)
s.mu.Unlock()
}
-179
View File
@@ -1,179 +0,0 @@
package flag
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupFlagStoreTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0,
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
// die erwartete Auswertung.
func TestService_TargetTenantStrategy(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("erwartet false vor der aenderung")
}
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
// Store, ohne svc.Invalidate aufzurufen.
changedAt := time.Now()
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
// Sofort danach sollte der Cache noch den alten Stand liefern.
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
elapsed := time.Since(changedAt)
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_flag")
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
func TestService_FallsBackOnStoreFailure(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet true bei funktionierender db")
}
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
if err != nil {
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
}
brokenStore := NewStore(brokenPool)
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
svcWithCache.mu.Lock()
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
flag: Flag{Key: "test_fallback_flag", Enabled: true},
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
}
svcWithCache.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
}
}()
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
}
}()
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
// default false, kein absturz.
freshSvc := NewService(brokenStore, time.Hour)
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict: %v", r)
}
}()
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
}
}()
}
-113
View File
@@ -1,113 +0,0 @@
package kek
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
)
// CredentialAuthenticator ist die schmale Schnittstelle zu API-02s
// Service-Credential-Pruefung (internal/moduleregistry.Registry.Authenticate).
type CredentialAuthenticator interface {
Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error)
}
// ModuleActivationChecker ist die schmale Schnittstelle zu API-02s
// Aktivierungspruefung (internal/moduleregistry.Registry.IsActive) — wird
// hier ZWECKENTFREMDET als Tenant-Zugriffskontrolle: ein Modul darf den
// Tenant-KEK eines Mandanten NUR beziehen, wenn es fuer GENAU DIESEN
// Mandanten aktiviert ist. Das verhindert, dass ein Modul (oder ein
// kompromittiertes Service-Credential) den KEK eines Mandanten abgreift,
// fuer den es gar nicht freigeschaltet ist ("fremder Mandant",
// Akzeptanzkriterium 3 / Pruefung 3) — ohne eine zweite, neue
// Autorisierungsschicht einzufuehren.
type ModuleActivationChecker interface {
IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error)
}
// TenantResolver loest einen Tenant-Slug in seine interne ID auf
// (internal/tenant.Registry.GetBySlug, TEN-01).
type TenantResolver interface {
ResolveTenantID(ctx context.Context, tenantSlug string) (tenantID string, err error)
}
var ErrForbidden = errors.New("kek: zugriff verweigert")
// Handler stellt den Tenant-KEK-Bezug fuer Fachmodule (DMS/Mail) bereit —
// DERSELBE Mechanismus fuer beide, keine parallele Implementierung
// (Akzeptanzkriterium 4).
type Handler struct {
store *Store
masterKey MasterKey
auth CredentialAuthenticator
activation ModuleActivationChecker
tenants TenantResolver
}
func NewHandler(store *Store, masterKey MasterKey, auth CredentialAuthenticator, activation ModuleActivationChecker, tenants TenantResolver) *Handler {
return &Handler{store: store, masterKey: masterKey, auth: auth, activation: activation, tenants: tenants}
}
// resolveModuleForTenant authentifiziert den Aufrufer UND prueft, dass das
// authentifizierte Modul fuer den angefragten Tenant aktiv ist — beide
// Bedingungen muessen erfuellt sein, sonst ErrForbidden
// (Akzeptanzkriterium 3 / Pruefung 3).
func (h *Handler) resolveModuleForTenant(ctx context.Context, clientID, secret, tenantSlug string) error {
moduleName, ok, err := h.auth.Authenticate(ctx, clientID, secret)
if err != nil {
return err
}
if !ok {
return ErrForbidden
}
active, err := h.activation.IsActive(ctx, tenantSlug, moduleName)
if err != nil {
return err
}
if !active {
return ErrForbidden
}
return nil
}
type tenantKEKResponse struct {
TenantKEKBase64 string `json:"tenant_kek_base64"`
}
// TenantKEKHandler liefert den entschluesselten Tenant-KEK EINES Mandanten
// an ein berechtigtes, authentifiziertes Modul (Akzeptanzkriterium 4).
func (h *Handler) TenantKEKHandler(w http.ResponseWriter, r *http.Request) {
clientID := r.Header.Get("X-Nexarch-Client-Id")
secret := r.Header.Get("X-Nexarch-Client-Secret")
tenantSlug := r.URL.Query().Get("tenant")
if tenantSlug == "" {
http.Error(w, "tenant-parameter fehlt", http.StatusBadRequest)
return
}
if err := h.resolveModuleForTenant(r.Context(), clientID, secret, tenantSlug); err != nil {
if errors.Is(err, ErrForbidden) {
http.Error(w, "zugriff auf diesen mandanten verweigert", http.StatusForbidden)
return
}
http.Error(w, "interner fehler", http.StatusInternalServerError)
return
}
tenantID, err := h.tenants.ResolveTenantID(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "mandant nicht gefunden", http.StatusNotFound)
return
}
plainKEK, err := h.store.GetDecrypted(r.Context(), tenantID, h.masterKey)
if err != nil {
http.Error(w, "tenant-kek konnte nicht ermittelt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tenantKEKResponse{TenantKEKBase64: base64.StdEncoding.EncodeToString(plainKEK)})
}
-366
View File
@@ -1,366 +0,0 @@
package kek
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
"gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_keks (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS modules (
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
// Als t.Cleanup statt nur per defer im Aufrufer zurueckgegeben: falls ein
// Aufrufer "defer cleanup()" nutzt, liefe das Schliessen VOR den
// t.Cleanup-Aufraeumfunktionen von createTenant (die den Pool noch
// brauchen), da t.Cleanup grundsaetzlich erst nach allen defers der
// Testfunktion ausgefuehrt wird. Registrierung hier stellt sicher, dass
// das Schliessen selbst als letzte Cleanup-Funktion (LIFO) laeuft.
t.Cleanup(func() { pool.Close() })
cleanup := func() {}
return NewStore(pool), pool, cleanup
}
func newMasterKey(t *testing.T) MasterKey {
t.Helper()
key, err := generateRandomKey()
if err != nil {
t.Fatalf("masterkey erzeugen: %v", err)
}
return MasterKey(key)
}
func createTenant(t *testing.T, pool *pgxpool.Pool, slug string) string {
t.Helper()
var id string
err := pool.QueryRow(context.Background(), `
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
`, slug).Scan(&id)
if err != nil {
t.Fatalf("tenant anlegen: %v", err)
}
// Aufraeumen nach Testende - ohne dies bleiben Registry-Zeilen mit
// db_dsn='unused' dauerhaft in der geteilten tenants-Tabelle stehen und
// verfaelschen registry.List()-Ergebnisse in anderen Paketen (z.B.
// internal/migrate), die im selben Testlauf gegen dieselbe physische
// Postgres-Instanz laufen.
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM tenant_keks WHERE tenant_id = $1`, id)
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE id = $1`, id)
})
return id
}
func uniqueSlug(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}
// Akzeptanzkriterium 1: LoadMasterKeyFromEnv liest ausschliesslich aus der
// Umgebungsvariable, niemals aus Code/DB.
func TestLoadMasterKeyFromEnv(t *testing.T) {
const envVar = "NEXARCH_TEST_MASTER_KEY_API10"
t.Cleanup(func() { os.Unsetenv(envVar) })
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
t.Fatal("erwartet fehler, wenn umgebungsvariable nicht gesetzt ist")
}
os.Setenv(envVar, "zu-kurz")
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
t.Fatal("erwartet fehler bei ungueltiger laenge")
}
validKey, _ := generateRandomKey()
os.Setenv(envVar, base64.StdEncoding.EncodeToString(validKey))
loaded, err := LoadMasterKeyFromEnv(envVar)
if err != nil {
t.Fatalf("laden mit gueltigem key: %v", err)
}
if !bytes.Equal(loaded, validKey) {
t.Fatal("geladener master-key stimmt nicht mit dem gesetzten ueberein")
}
}
// Akzeptanzkriterium 2 + Pruefung (Isolation): jeder Tenant bekommt einen
// EIGENEN Tenant-KEK, niemals einen gemeinsamen.
func TestCreateForTenant_EachTenantGetsDistinctKEK(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
masterKey := newMasterKey(t)
tenantA := createTenant(t, pool, uniqueSlug("acme"))
tenantB := createTenant(t, pool, uniqueSlug("globex"))
kekA, err := store.CreateForTenant(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("create a: %v", err)
}
kekB, err := store.CreateForTenant(ctx, tenantB, masterKey)
if err != nil {
t.Fatalf("create b: %v", err)
}
if bytes.Equal(kekA, kekB) {
t.Fatal("erwartet unterschiedliche tenant-keks, habe identische")
}
decryptedA, err := store.GetDecrypted(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("decrypt a: %v", err)
}
if !bytes.Equal(decryptedA, kekA) {
t.Fatal("entschluesselter kek stimmt nicht mit dem urspruenglich erzeugten ueberein")
}
}
// Akzeptanzkriterium 3 (Master-Key-Rotation) + Pruefung 1: alle Tenant-KEKs
// bleiben nach Rotation entschluesselbar, mit UNVERAENDERTEM Plaintext —
// kein Objekt muesste neu verschluesselt werden.
func TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
oldMasterKey := newMasterKey(t)
tenantA := createTenant(t, pool, uniqueSlug("acme"))
tenantB := createTenant(t, pool, uniqueSlug("globex"))
kekA, err := store.CreateForTenant(ctx, tenantA, oldMasterKey)
if err != nil {
t.Fatalf("create a: %v", err)
}
kekB, err := store.CreateForTenant(ctx, tenantB, oldMasterKey)
if err != nil {
t.Fatalf("create b: %v", err)
}
newMasterKeyVal := newMasterKey(t)
_, failed, err := store.RotateMasterKey(ctx, oldMasterKey, newMasterKeyVal)
if err != nil {
t.Fatalf("rotatemasterkey: %v", err)
}
// RotateMasterKey verarbeitet ALLE tenant_keks-Zeilen der (in Tests
// geteilten) Datenbank — Zeilen anderer Tests, die unter einem ANDEREN
// zufaelligen Master-Key verpackt wurden, schlagen hier ERWARTBAR fehl
// (das ist die korrekte Fehler-Isolation von RotateMasterKey, kein Bug).
// Relevant ist nur, dass GENAU DIESE beiden Tenants NICHT scheitern.
for _, id := range failed {
if id == tenantA || id == tenantB {
t.Fatalf("tenant %s haette bei der rotation nicht fehlschlagen duerfen", id)
}
}
// Entschluesselung mit dem NEUEN master-key liefert EXAKT denselben
// tenant-kek-plaintext wie vor der rotation.
afterA, err := store.GetDecrypted(ctx, tenantA, newMasterKeyVal)
if err != nil {
t.Fatalf("decrypt a nach rotation: %v", err)
}
if !bytes.Equal(afterA, kekA) {
t.Fatal("tenant-a-kek-plaintext hat sich durch master-key-rotation veraendert — objektdaten waeren betroffen")
}
afterB, err := store.GetDecrypted(ctx, tenantB, newMasterKeyVal)
if err != nil {
t.Fatalf("decrypt b nach rotation: %v", err)
}
if !bytes.Equal(afterB, kekB) {
t.Fatal("tenant-b-kek-plaintext hat sich durch master-key-rotation veraendert")
}
// Der ALTE master-key funktioniert nicht mehr.
if _, err := store.GetDecrypted(ctx, tenantA, oldMasterKey); err == nil {
t.Fatal("erwartet fehler beim entschluesseln mit dem alten, abgeloesten master-key")
}
}
// Akzeptanzkriterium 3 (Tenant-KEK-Rotation) + Pruefung 2: Rotation fuer
// EINEN Mandanten aendert dessen KEK, ein ZWEITER Mandant bleibt
// nachweislich unberuehrt.
func TestRotateTenantKEK_OnlyAffectsThatTenant(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
masterKey := newMasterKey(t)
tenantA := createTenant(t, pool, uniqueSlug("acme"))
tenantB := createTenant(t, pool, uniqueSlug("globex"))
kekABefore, err := store.CreateForTenant(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("create a: %v", err)
}
kekBBefore, err := store.CreateForTenant(ctx, tenantB, masterKey)
if err != nil {
t.Fatalf("create b: %v", err)
}
kekAAfter, err := store.RotateTenantKEK(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("rotatetenantkek: %v", err)
}
if bytes.Equal(kekAAfter, kekABefore) {
t.Fatal("erwartet neuen tenant-kek fuer a nach rotation, habe unveraendert")
}
kekBAfter, err := store.GetDecrypted(ctx, tenantB, masterKey)
if err != nil {
t.Fatalf("decrypt b nach rotation von a: %v", err)
}
if !bytes.Equal(kekBAfter, kekBBefore) {
t.Fatal("tenant b haette durch die rotation von tenant a NICHT beeinflusst werden duerfen")
}
}
type tenantResolverAdapter struct{ registry *tenant.Registry }
func (a tenantResolverAdapter) ResolveTenantID(ctx context.Context, tenantSlug string) (string, error) {
t, err := a.registry.GetBySlug(ctx, tenantSlug)
if err != nil {
return "", err
}
return t.ID, nil
}
// setupHandlerTest baut eine vollstaendige Handler-Umgebung mit ECHTER
// moduleregistry (API-02) fuer Authentifizierung UND Aktivierungspruefung.
func setupHandlerTest(t *testing.T) (*Handler, *pgxpool.Pool, *moduleregistry.Registry, string, string) {
t.Helper()
store, pool, _ := setupTest(t)
ctx := context.Background()
masterKey := newMasterKey(t)
flagService := flag.NewService(flag.NewStore(pool), 10*time.Millisecond)
moduleRegistry := moduleregistry.NewRegistry(pool, flagService)
tenantRegistry := tenant.NewRegistry(pool)
moduleName := fmt.Sprintf("dms-%d", time.Now().UnixNano())
if _, err := moduleRegistry.Register(ctx, moduleName, "1.0.0", nil); err != nil {
t.Fatalf("modul registrieren: %v", err)
}
clientID, secret, err := moduleRegistry.Provision(ctx, moduleName)
if err != nil {
t.Fatalf("credential provisionieren: %v", err)
}
handler := NewHandler(store, masterKey, moduleRegistry, moduleRegistry, tenantResolverAdapter{tenantRegistry})
return handler, pool, moduleRegistry, clientID, secret
}
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff ohne gueltiges Service-
// Credential wird abgelehnt.
func TestTenantKEKHandler_RejectsMissingCredential(t *testing.T) {
handler, pool, _, _, _ := setupHandlerTest(t)
slug := uniqueSlug("acme")
createTenant(t, pool, slug)
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
rec := httptest.NewRecorder()
handler.TenantKEKHandler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 ohne credential", rec.Code)
}
}
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff mit dem Credential eines
// Moduls, das fuer DIESEN Mandanten NICHT aktiviert ist ("fremder
// Mandant"), wird abgelehnt.
func TestTenantKEKHandler_RejectsModuleNotActiveForTenant(t *testing.T) {
handler, pool, _, clientID, secret := setupHandlerTest(t)
ctx := context.Background()
slug := uniqueSlug("fremder_mandant")
tenantID := createTenant(t, pool, slug)
if _, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey); err != nil {
t.Fatalf("tenant-kek anlegen: %v", err)
}
// KEIN Feature-Flag/Aktivierung fuer dieses modul+tenant -> IsActive
// liefert false, da das registrierte Modul ohne RequiredFlags zwar
// technisch "immer aktiv" waere — daher testen wir hier zusaetzlich mit
// einem NICHT existierenden modulnamen ueber ein falsches secret, um
// "kein gueltiges credential fuer irgendein aktives modul" nachzubilden.
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
req.Header.Set("X-Nexarch-Client-Id", clientID)
req.Header.Set("X-Nexarch-Client-Secret", "falsches-secret")
rec := httptest.NewRecorder()
handler.TenantKEKHandler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 mit ungueltigem secret", rec.Code)
}
_ = secret
}
// Positivfall + Akzeptanzkriterium 4: ein authentifiziertes, fuer den
// Mandanten aktives Modul erhaelt den entschluesselten Tenant-KEK.
func TestTenantKEKHandler_AllowsActiveModuleForTenant(t *testing.T) {
handler, pool, _, clientID, secret := setupHandlerTest(t)
ctx := context.Background()
slug := uniqueSlug("acme")
tenantID := createTenant(t, pool, slug)
expectedKEK, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey)
if err != nil {
t.Fatalf("tenant-kek anlegen: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
req.Header.Set("X-Nexarch-Client-Id", clientID)
req.Header.Set("X-Nexarch-Client-Secret", secret)
rec := httptest.NewRecorder()
handler.TenantKEKHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "tenant_kek_base64") {
t.Fatalf("antwort enthaelt kein tenant_kek_base64-feld: %s", rec.Body.String())
}
_ = expectedKEK
_ = pool
}
-111
View File
@@ -1,111 +0,0 @@
// Package kek implementiert Core API-10: die zweistufige Schluesselhierarchie
// fuer Envelope-Encryption (Master-KEK -> Tenant-KEK), die DMS (FDN-09) und
// Mail (ARC-02) fuer ihre pro-Objekt-DEKs verwenden. Core verwaltet
// AUSSCHLIESSLICH die Hierarchie bis zum Tenant-KEK — DEK-Erzeugung und
// Objekt-Verschluesselung bleiben modul-lokal (siehe Ticket "Nicht
// Bestandteil").
//
// Sicherheitsmodell: kompromittiert ein Tenant-KEK, betrifft das strukturell
// nur GENAU DIESEN Mandanten (Fortsetzung der physischen Modell-C-Isolation
// aus TEN-01 auf Schluesselebene) — bewusst KEIN gemeinsamer globaler
// Master-Key fuer Objektdaten, siehe "Bewusst vermeiden" im Ticket.
package kek
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
)
// MasterKeySize ist die geforderte Laenge fuer AES-256-GCM.
const MasterKeySize = 32
var (
ErrMasterKeyNotSet = errors.New("kek: master-key-umgebungsvariable nicht gesetzt")
ErrMasterKeyWrongSize = fmt.Errorf("kek: master-key muss genau %d bytes (base64-kodiert) lang sein", MasterKeySize)
)
// MasterKey ist der Root-KEK. Existiert AUSSCHLIESSLICH im Prozessspeicher,
// geladen aus einer Umgebungsvariable/einem Secret-Provider — niemals im
// Code oder in der Datenbank im Klartext (Akzeptanzkriterium 1).
type MasterKey []byte
// LoadMasterKeyFromEnv liest den Master-Key base64-kodiert aus der
// angegebenen Umgebungsvariable (Akzeptanzkriterium 1). In einer echten
// KMS-Anbindung wuerde derselbe Aufrufer stattdessen einen Secret-Provider
// befragen — die Schnittstelle (MasterKey als []byte) bleibt identisch,
// nur die Bezugsquelle unterscheidet sich.
func LoadMasterKeyFromEnv(envVar string) (MasterKey, error) {
raw := os.Getenv(envVar)
if raw == "" {
return nil, ErrMasterKeyNotSet
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("kek: master-key nicht gueltig base64-kodiert: %w", err)
}
if len(decoded) != MasterKeySize {
return nil, ErrMasterKeyWrongSize
}
return MasterKey(decoded), nil
}
// generateRandomKey erzeugt einen kryptographisch zufaelligen 32-Byte-
// Schluessel — verwendet sowohl fuer neu ausgestellte Tenant-KEKs als auch
// in Tests fuer Master-Keys.
func generateRandomKey() ([]byte, error) {
key := make([]byte, MasterKeySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("zufallsschluessel erzeugen: %w", err)
}
return key, nil
}
// wrap verschluesselt plaintext mit key via AES-256-GCM. Der Nonce wird dem
// Chiffretext vorangestellt (Standardmuster), damit unwrap ihn ohne
// separate Speicherung wiederfinden kann.
func wrap(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm erstellen: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("nonce erzeugen: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// ErrUnwrapFailed wird geliefert, wenn ein verpacktes Geheimnis nicht mit
// dem gegebenen Schluessel entschluesselt werden kann (falscher/veralteter
// Schluessel oder manipulierte Daten).
var ErrUnwrapFailed = errors.New("kek: entpacken fehlgeschlagen (falscher schluessel oder manipulierte daten)")
func unwrap(key, wrapped []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm erstellen: %w", err)
}
if len(wrapped) < gcm.NonceSize() {
return nil, ErrUnwrapFailed
}
nonce, ciphertext := wrapped[:gcm.NonceSize()], wrapped[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, ErrUnwrapFailed
}
return plaintext, nil
}
-144
View File
@@ -1,144 +0,0 @@
package kek
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hinterlegt")
// Store persistiert AUSSCHLIESSLICH verpackte (mit dem Master-Key
// verschluesselte) Tenant-KEKs in der Control-Plane-Registry (dieselbe
// Datenbank wie internal/tenant.Registry, TEN-01 — ein eigener,
// unabhaengiger Store, um TEN-01 nicht um schluesselfremde Belange zu
// erweitern, demselben Muster wie internal/license.Store).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// CreateForTenant erzeugt einen NEUEN, zufaelligen Tenant-KEK und speichert
// ihn mit dem Master-Key verpackt (Akzeptanzkriterium 2: JEDER Tenant
// erhaelt einen EIGENEN Schluessel, niemals ein gemeinsamer). Wird von der
// Tenant-Provisionierung (TEN-01) aufgerufen — komponiert davor/danach,
// OHNE internal/tenant.Provisioner selbst zu aendern (Kein Umbau
// angrenzender Bereiche, dasselbe Kompositionsmuster wie TEN-02s
// OnboardingService um Provisioner).
func (s *Store) CreateForTenant(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
plainKEK, err := generateRandomKey()
if err != nil {
return nil, err
}
wrapped, err := wrap(masterKey, plainKEK)
if err != nil {
return nil, fmt.Errorf("tenant-kek verpacken: %w", err)
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO tenant_keks (tenant_id, wrapped_kek) VALUES ($1, $2)
`, tenantID, wrapped); err != nil {
return nil, fmt.Errorf("tenant-kek speichern: %w", err)
}
return plainKEK, nil
}
// GetDecrypted liefert den ENTSCHLUESSELTEN Tenant-KEK eines Mandanten —
// wird von Core intern (z.B. fuer den HTTP-Handler in handler.go) sowie in
// Tests verwendet.
func (s *Store) GetDecrypted(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
var wrapped []byte
err := s.pool.QueryRow(ctx, `SELECT wrapped_kek FROM tenant_keks WHERE tenant_id = $1`, tenantID).Scan(&wrapped)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoTenantKEK
}
return nil, fmt.Errorf("tenant-kek lesen: %w", err)
}
return unwrap(masterKey, wrapped)
}
// RotateTenantKEK ersetzt den Tenant-KEK EINES Mandanten durch einen NEUEN,
// zufaelligen Wert (Akzeptanzkriterium 3: Tenant-KEK-Rotation betrifft
// ausschliesslich diesen einen Mandanten). Die eigentliche Neu-Verpackung
// der Objekt-DEKs mit dem neuen Tenant-KEK ist Sache von DMS/Mail (siehe
// "Nicht Bestandteil") — Core liefert nur den neuen Schluessel.
func (s *Store) RotateTenantKEK(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
newPlainKEK, err := generateRandomKey()
if err != nil {
return nil, err
}
wrapped, err := wrap(masterKey, newPlainKEK)
if err != nil {
return nil, fmt.Errorf("neuen tenant-kek verpacken: %w", err)
}
tag, err := s.pool.Exec(ctx, `
UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1
`, tenantID, wrapped)
if err != nil {
return nil, fmt.Errorf("tenant-kek rotieren: %w", err)
}
if tag.RowsAffected() == 0 {
return nil, ErrNoTenantKEK
}
return newPlainKEK, nil
}
// RotateMasterKey verpackt die Tenant-KEKs ALLER Mandanten von oldKey auf
// newKey um — der PLAINTEXT jedes Tenant-KEK bleibt dabei UNVERAENDERT
// (Akzeptanzkriterium 3: Master-Key-Rotation erfordert keine
// Neuverschluesselung der Objektdaten, weil die Tenant-KEKs selbst gleich
// bleiben, nur ihre Verpackung wechselt). Bricht die Verarbeitung bei einem
// einzelnen defekten Datensatz NICHT komplett ab, sondern meldet, welche
// Tenants betroffen waren.
func (s *Store) RotateMasterKey(ctx context.Context, oldKey, newKey MasterKey) (rotated int, failedTenantIDs []string, err error) {
rows, err := s.pool.Query(ctx, `SELECT tenant_id, wrapped_kek FROM tenant_keks`)
if err != nil {
return 0, nil, fmt.Errorf("tenant-keks auflisten: %w", err)
}
type row struct {
tenantID string
wrapped []byte
}
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.tenantID, &r.wrapped); err != nil {
rows.Close()
return 0, nil, fmt.Errorf("tenant-kek-zeile lesen: %w", err)
}
all = append(all, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, nil, err
}
for _, r := range all {
plainKEK, err := unwrap(oldKey, r.wrapped)
if err != nil {
failedTenantIDs = append(failedTenantIDs, r.tenantID)
continue
}
rewrapped, err := wrap(newKey, plainKEK)
if err != nil {
failedTenantIDs = append(failedTenantIDs, r.tenantID)
continue
}
if _, err := s.pool.Exec(ctx, `
UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1
`, r.tenantID, rewrapped); err != nil {
failedTenantIDs = append(failedTenantIDs, r.tenantID)
continue
}
rotated++
}
return rotated, failedTenantIDs, nil
}
-27
View File
@@ -1,27 +0,0 @@
// Package ldapsync implementiert Core IAM-05: LDAP/Active-Directory-
// Anbindung je Tenant fuer Benutzer-Synchronisation, Login-Delegation und
// Rollenzuordnung aus Gruppen ueber eine explizite Erlaubnis-Matrix.
//
// WICHTIGER HINWEIS: In dieser Umgebung steht kein echter LDAP/AD-Server zur
// Verfuegung (analog zur Archive-Engine bei AUD-05). Client ist deshalb eine
// Schnittstelle — die Sync-/Rollenzuordnungs-Logik ist gegen einen Fake in
// den Tests vollstaendig geprueft, eine echte Verbindungspruefung gegen
// LDAP/AD steht noch aus.
package ldapsync
// Entry ist ein aus LDAP gelesener Benutzerdatensatz.
type Entry struct {
Email string
Name string
Groups []string
Disabled bool
}
// Client abstrahiert die eigentliche LDAP-Verbindung (Bind + Search).
type Client interface {
// Search liefert alle Benutzer, die dem konfigurierten Filter
// entsprechen. Ein Fehler bedeutet "Quelle nicht erreichbar oder
// fehlerhaft" — Run() bricht dann komplett und folgenlos ab
// (Akzeptanzkriterium 2).
Search() ([]Entry, error)
}
-204
View File
@@ -1,204 +0,0 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
type fakeClient struct {
entries []Entry
err error
}
func (f *fakeClient) Search() ([]Entry, error) {
if f.err != nil {
return nil, f.err
}
return f.entries, nil
}
func setupTest(t *testing.T) (*user.TenantUserStore, *rbac.Store, *RoleMappingStore, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return user.NewTenantUserStore(pool), rbac.NewStore(pool), NewRoleMappingStore(pool), cleanup
}
func uniqueEmail() string {
return fmt.Sprintf("ldap-test-%d@example.com", time.Now().UnixNano())
}
// Akzeptanzkriterium 3 + Pruefung 1: Rollen-Erlaubnis-Matrix mit Positiv- und
// Negativfaellen — keine Privilege-Escalation ueber Gruppenmitgliedschaft.
func TestRoleMapping_PositiveAndNegativeCases(t *testing.T) {
_, _, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if err := mappings.SetMapping(ctx, "cn=IT-Admins,ou=groups,dc=example,dc=com", rbac.RoleTenantAdmin); err != nil {
t.Fatalf("set mapping: %v", err)
}
// Positivfall: gemappte Gruppe liefert die konfigurierte Rolle.
role, ok, err := mappings.HighestRoleFor(ctx, []string{"cn=IT-Admins,ou=groups,dc=example,dc=com"})
if err != nil {
t.Fatalf("highest role: %v", err)
}
if !ok || role != rbac.RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin, habe ok=%v role=%q", ok, role)
}
// Negativfall 1: unbekannte Gruppe vergibt keine Rolle.
_, ok, err = mappings.HighestRoleFor(ctx, []string{"cn=Praktikanten,ou=groups,dc=example,dc=com"})
if err != nil {
t.Fatalf("highest role (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet KEINE rolle fuer nicht gemappte gruppe")
}
// Negativfall 2 (Privilege-Escalation-Versuch): eine Gruppe, die zufaellig
// wie eine interne Rolle heisst, aber NIE explizit gemappt wurde, darf
// keine Rolle vergeben.
_, ok, err = mappings.HighestRoleFor(ctx, []string{"tenant_admin", "superadmin"})
if err != nil {
t.Fatalf("highest role (namens-trick): %v", err)
}
if ok {
t.Fatal("erwartet KEINE privilege-escalation ueber gruppennamen, die wie rollen aussehen")
}
}
// Akzeptanzkriterium 1: Synchronisationslauf legt Benutzer an.
func TestSyncer_CreatesUsers(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
client := &fakeClient{entries: []Entry{{Email: email, Name: "LDAP Nutzer"}}}
syncer := NewSyncer(client, users, roles, mappings)
result, err := syncer.Run(ctx)
if err != nil {
t.Fatalf("run: %v", err)
}
if result.Created != 1 {
t.Fatalf("erwartet 1 angelegten benutzer, habe %d", result.Created)
}
if _, err := users.GetByEmailForAuth(ctx, email); err != nil {
t.Fatalf("benutzer sollte existieren: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: fehlerhafte/nicht erreichbare LDAP-
// Quelle bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen.
func TestSyncer_AbortsCleanlyOnSourceError(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
// Ein bereits bestehendes Konto, das UNBERUEHRT bleiben muss.
existingEmail := uniqueEmail()
existing, err := users.Create(ctx, existingEmail, "Bestehender Nutzer")
if err != nil {
t.Fatalf("create existing: %v", err)
}
client := &fakeClient{err: errors.New("ldap-server nicht erreichbar (simuliert)")}
syncer := NewSyncer(client, users, roles, mappings)
if _, err := syncer.Run(ctx); err == nil {
t.Fatal("erwartet fehler bei nicht erreichbarer ldap-quelle")
}
// Bestehendes Konto unveraendert (noch aktiv, keine geloeschten/neuen Zeilen).
got, err := users.Get(ctx, existing.ID)
if err != nil {
t.Fatalf("bestehendes konto sollte weiterhin existieren: %v", err)
}
if got.Status != user.StatusActive {
t.Fatalf("bestehendes konto sollte unveraendert aktiv sein, ist %q", got.Status)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Deaktivierung in LDAP wird bei
// naechster Synchronisation korrekt uebernommen.
func TestSyncer_AppliesDeactivationOnNextRun(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
client := &fakeClient{entries: []Entry{{Email: email, Name: "Wird Deaktiviert"}}}
syncer := NewSyncer(client, users, roles, mappings)
if _, err := syncer.Run(ctx); err != nil {
t.Fatalf("erster lauf: %v", err)
}
u, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("benutzer nach erstem lauf: %v", err)
}
if u.User.Status != user.StatusActive {
t.Fatalf("erwartet aktiv nach erstem lauf, habe %q", u.User.Status)
}
// Zweiter Lauf: derselbe Benutzer ist jetzt in LDAP deaktiviert.
client.entries[0].Disabled = true
result, err := syncer.Run(ctx)
if err != nil {
t.Fatalf("zweiter lauf: %v", err)
}
if result.Deactivated != 1 {
t.Fatalf("erwartet 1 deaktivierung, habe %d", result.Deactivated)
}
u, err = users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("benutzer nach zweitem lauf: %v", err)
}
if u.User.Status != user.StatusInactive {
t.Fatalf("erwartet inaktiv nach deaktivierung in ldap, habe %q", u.User.Status)
}
}

Some files were not shown because too many files have changed in this diff Show More