diff --git a/docs/QA-04-PRUEFPROTOKOLL.md b/docs/QA-04-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..882e8b8 --- /dev/null +++ b/docs/QA-04-PRUEFPROTOKOLL.md @@ -0,0 +1,149 @@ +# 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.2–1.4). Vier real gefundene +Fehler (Abschnitt 2, Schweregrad Hoch/Mittel) behoben – ausschließlich +Testinfrastruktur, kein Produktionscode verändert. diff --git a/internal/e2e/cross_tenant_isolation_test.go b/internal/e2e/cross_tenant_isolation_test.go index a224ca7..7217368 100644 --- a/internal/e2e/cross_tenant_isolation_test.go +++ b/internal/e2e/cross_tenant_isolation_test.go @@ -25,7 +25,11 @@ func TestE2E_CrossTenantIsolation(t *testing.T) { if err != nil { t.Fatalf("admin pool: %v", err) } - defer adminPool.Close() + // 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) diff --git a/internal/e2e/tenant_onboarding_test.go b/internal/e2e/tenant_onboarding_test.go index a1a8a0f..7972104 100644 --- a/internal/e2e/tenant_onboarding_test.go +++ b/internal/e2e/tenant_onboarding_test.go @@ -30,7 +30,11 @@ func TestE2E_TenantOnboardingLoginFlow(t *testing.T) { if err != nil { t.Fatalf("admin pool: %v", err) } - defer adminPool.Close() + // 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) diff --git a/internal/kek/kek_test.go b/internal/kek/kek_test.go index 1095ae3..0b996cf 100644 --- a/internal/kek/kek_test.go +++ b/internal/kek/kek_test.go @@ -59,7 +59,14 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) { t.Fatalf("schema: %v", err) } - cleanup := func() { pool.Close() } + // 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 } @@ -81,6 +88,15 @@ func createTenant(t *testing.T, pool *pgxpool.Pool, slug string) string { 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 } diff --git a/internal/migrate/migrate_test.go b/internal/migrate/migrate_test.go index 47c2352..5321472 100644 --- a/internal/migrate/migrate_test.go +++ b/internal/migrate/migrate_test.go @@ -61,7 +61,9 @@ func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tena db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + previous_status TEXT, + deletion_scheduled_at TIMESTAMPTZ )`); err != nil { t.Fatalf("registry-schema: %v", err) } diff --git a/internal/pentest/qa04_pentest_test.go b/internal/pentest/qa04_pentest_test.go new file mode 100644 index 0000000..01c2c18 --- /dev/null +++ b/internal/pentest/qa04_pentest_test.go @@ -0,0 +1,732 @@ +// Package pentest enthaelt QA-04 (Sicherheits- & Penetrationspruefung Core): +// gezielte Angriffsversuche gegen die sensibelsten Core-Bereiche - SSO/OIDC, +// Rate-Limiting/Account-Lockout im Mehrinstanz-Betrieb, zentrale +// Policy-Durchsetzung und Master-Key-/Tenant-KEK-Verwaltung - jeweils gegen +// die echten Produktionspakete, nicht gegen Mocks. +package pentest + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/kek" + "gitea.perlbach24.de/scripte/nexarch/internal/lockout" + "gitea.perlbach24.de/scripte/nexarch/internal/oidc" + "gitea.perlbach24.de/scripte/nexarch/internal/policy" + "gitea.perlbach24.de/scripte/nexarch/internal/ratelimit" + "gitea.perlbach24.de/scripte/nexarch/internal/rbac" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" +) + +// --------------------------------------------------------------------- +// Testinfrastruktur (identisch zum Muster aus internal/e2e/helpers_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, Pentest uebersprungen") + } + return dsn +} + +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + return filepath.Join(wd, "..", "..") +} + +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 { + 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) + } + } +} + +func setupRegistryPool(t *testing.T) (*pgxpool.Pool, string) { + t.Helper() + adminDSN := requireAdminDSN(t) + ctx := context.Background() + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("registry pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + applyAllUpSQL(t, ctx, pool, filepath.Join(repoRoot(t), "migrations")) + return pool, adminDSN +} + +func uniqueSlug(prefix string) string { + return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) +} + +// ======================================================================= +// Pruefung 2 / Akzeptanzkriterium 1: SSO/OIDC gegen Token-Manipulation, +// Replay und Claims-Manipulation. +// ======================================================================= + +// TestPentest_OIDC_AlgNoneAttack simuliert den klassischen "alg=none"-Angriff +// gegen den EXTERNEN OIDC-Verifier (IAM-06, internal/oidc.Verifier): ein +// Angreifer stellt sich selbst ein Token mit Signaturalgorithmus "none" aus, +// in der Hoffnung, dass eine naive JWT-Bibliothek die fehlende Signatur +// akzeptiert. internal/oidc.Verifier.Verify prueft explizit auf +// *jwt.SigningMethodRSA - "none" muss zuverlaessig abgewiesen werden. +func TestPentest_OIDC_AlgNoneAttack(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa key: %v", err) + } + verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test") + + claims := jwt.MapClaims{ + "sub": "angreifer@example.test", + "iss": "https://idp.example.test", + "exp": time.Now().Add(time.Hour).Unix(), + } + unsigned := jwt.NewWithClaims(jwt.SigningMethodNone, claims) + unsigned.Header["kid"] = "kid-1" + forged, err := unsigned.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("alg=none token faelschen: %v", err) + } + + if _, err := verifier.Verify(forged); err == nil { + t.Fatal("alg=none-token wurde akzeptiert - kritische luecke") + } +} + +// TestPentest_OIDC_ForeignKeySignature simuliert einen Angreifer, der ein +// Token mit einem EIGENEN RSA-Schluessel signiert und die kid eines +// bekannten, vertrauenswuerdigen Schluessels vorgibt (Schluessel-Confusion). +// Verify darf das Token nur mit dem TATSAECHLICH registrierten Public Key +// pruefen, niemals mit dem im Token mitgelieferten. +func TestPentest_OIDC_ForeignKeySignature(t *testing.T) { + trustedKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("trusted key: %v", err) + } + attackerKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("attacker key: %v", err) + } + verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"trusted-kid": &trustedKey.PublicKey}, "https://idp.example.test") + + claims := jwt.MapClaims{ + "sub": "angreifer@example.test", + "iss": "https://idp.example.test", + "exp": time.Now().Add(time.Hour).Unix(), + } + forgedToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + forgedToken.Header["kid"] = "trusted-kid" // gibt vor, mit dem bekannten Schluessel signiert zu sein + forged, err := forgedToken.SignedString(attackerKey) + if err != nil { + t.Fatalf("token mit fremdem schluessel signieren: %v", err) + } + + if _, err := verifier.Verify(forged); err == nil { + t.Fatal("mit fremdem schluessel signiertes token wurde akzeptiert - kritische luecke") + } +} + +// TestPentest_OIDC_TamperedClaims prueft, dass eine nachtraegliche +// Claims-Manipulation (z.B. sub oder roles aendern, ohne neu zu signieren) +// zuverlaessig an der Signaturpruefung scheitert. +func TestPentest_OIDC_TamperedClaims(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa key: %v", err) + } + verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test") + + claims := jwt.MapClaims{ + "sub": "normaler-benutzer@example.test", + "iss": "https://idp.example.test", + "roles": []string{"user"}, + "exp": time.Now().Add(time.Hour).Unix(), + } + valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + valid.Header["kid"] = "kid-1" + signed, err := valid.SignedString(rsaKey) + if err != nil { + t.Fatalf("token signieren: %v", err) + } + + // Gegenprobe: das unveraenderte, echte Token wird akzeptiert. + if _, err := verifier.Verify(signed); err != nil { + t.Fatalf("unveraendertes, echtes token wurde abgewiesen: %v", err) + } + + // Claims-Manipulation: Payload-Segment durch eine Version mit + // erhoehter Rolle ersetzen, Signatur-Segment unveraendert lassen. + parts := strings.Split(signed, ".") + if len(parts) != 3 { + t.Fatalf("unerwartetes token-format: %d segmente", len(parts)) + } + tamperedClaims := jwt.MapClaims{ + "sub": "normaler-benutzer@example.test", + "iss": "https://idp.example.test", + "roles": []string{"superadmin"}, // Rechteausweitung + "exp": time.Now().Add(time.Hour).Unix(), + } + tamperedHeader := jwt.NewWithClaims(jwt.SigningMethodRS256, tamperedClaims) + tamperedHeader.Header["kid"] = "kid-1" + tamperedUnsigned, err := tamperedHeader.SigningString() + if err != nil { + t.Fatalf("manipulierten payload kodieren: %v", err) + } + tamperedParts := strings.Split(tamperedUnsigned, ".") + forged := tamperedParts[0] + "." + tamperedParts[1] + "." + parts[2] // fremder payload + ALTE signatur + + if _, err := verifier.Verify(forged); err == nil { + t.Fatal("token mit manipulierten claims (rechteausweitung) wurde akzeptiert - kritische luecke") + } +} + +// TestPentest_OIDC_NonceReplay ist Pruefung 2 (Replay-Schutz): ein gueltiges, +// korrekt signiertes ID-Token eines FRUEHEREN Logins darf nicht fuer einen +// neuen Login-Vorgang mit einem anderen erwarteten Nonce wiederverwendet +// werden koennen. +func TestPentest_OIDC_NonceReplay(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa key: %v", err) + } + verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test") + + claims := jwt.MapClaims{ + "sub": "benutzer@example.test", + "iss": "https://idp.example.test", + "nonce": "nonce-aus-login-versuch-1", + "exp": time.Now().Add(time.Hour).Unix(), + } + valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + valid.Header["kid"] = "kid-1" + signed, err := valid.SignedString(rsaKey) + if err != nil { + t.Fatalf("token signieren: %v", err) + } + + // Der urspruengliche Login-Versuch (passender Nonce) ist gueltig. + if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-1"); err != nil { + t.Fatalf("token mit korrektem nonce wurde abgewiesen: %v", err) + } + + // Ein Angreifer faengt das Token ab und spielt es bei einem NEUEN + // Login-Versuch (mit eigenem, anderem erwarteten Nonce) erneut ein. + if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-2"); err == nil { + t.Fatal("token-replay mit abweichendem nonce wurde akzeptiert - kritische luecke") + } +} + +// TestPentest_OIDC_IdPTokenManipulation prueft den Weg, bei dem NEXARCH +// selbst als OIDC-Provider fuer Drittanwendungen auftritt (IAM-13, +// internal/oidc.Handler.JWKS + issueIDToken, Ed25519/EdDSA). Ein Angreifer +// versucht, ein selbst signiertes ID-Token gegen den echten, aus dem +// JWKS-Endpunkt gelesenen Schluessel zu verifizieren. +func TestPentest_OIDC_IdPTokenManipulation(t *testing.T) { + genuinePub, genuinePriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("echtes schluesselpaar erzeugen: %v", err) + } + attackerPub, attackerPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("angreifer-schluesselpaar erzeugen: %v", err) + } + _ = attackerPub + + claims := oidc.IDTokenClaims{ + Subject: "opfer-user-id", + TenantSlug: "opfer-tenant", + Scopes: []string{"openid"}, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://core.nexarch.example", + Audience: jwt.ClaimStrings{"drittanwendung-client-id"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + }, + } + forged := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + forged.Header["kid"] = "genuine-kid" // gibt vor, mit dem echten Schluessel signiert zu sein + forgedSigned, err := forged.SignedString(attackerPriv) + if err != nil { + t.Fatalf("token mit angreiferschluessel signieren: %v", err) + } + + // Verifikation exakt wie eine Drittanwendung sie gegen den echten, + // aus dem JWKS-Endpunkt bezogenen Schluessel durchfuehren wuerde. + verify := func(tokenString string, kid2key map[string]ed25519.PublicKey) error { + parsed, err := jwt.Parse(tokenString, func(tk *jwt.Token) (interface{}, error) { + if _, ok := tk.Method.(*jwt.SigningMethodEd25519); !ok { + return nil, fmt.Errorf("unerwarteter algorithmus: %v", tk.Header["alg"]) + } + kid, _ := tk.Header["kid"].(string) + pub, ok := kid2key[kid] + if !ok { + return nil, fmt.Errorf("unbekannte kid %q", kid) + } + return pub, nil + }) + if err != nil || !parsed.Valid { + return fmt.Errorf("ungueltig: %w", err) + } + return nil + } + + trustedKeys := map[string]ed25519.PublicKey{"genuine-kid": genuinePub} + + if err := verify(forgedSigned, trustedKeys); err == nil { + t.Fatal("mit angreiferschluessel signiertes idp-token wurde akzeptiert - kritische luecke") + } + + // Gegenprobe: ein echtes, mit dem tatsaechlichen Schluessel signiertes + // Token muss weiterhin akzeptiert werden (keine False-Positive-Abwehr). + genuine := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + genuine.Header["kid"] = "genuine-kid" + genuineSigned, err := genuine.SignedString(genuinePriv) + if err != nil { + t.Fatalf("echtes token signieren: %v", err) + } + if err := verify(genuineSigned, trustedKeys); err != nil { + t.Fatalf("echtes, korrekt signiertes idp-token wurde abgewiesen: %v", err) + } +} + +// ======================================================================= +// Pruefung 1 / Akzeptanzkriterium 2: Rate-Limiting/Account-Lockout im +// Mehrinstanz-Betrieb nachweislich nicht umgehbar. +// ======================================================================= + +// TestPentest_RateLimit_MultiInstanceBypassAttempt simuliert einen +// Angreifer, der versucht, das Rate-Limit zu umgehen, indem er seine +// Anfragen ueber MEHRERE Core-Instanzen (hier: mehrere unabhaengige +// pgxpool.Pool-Verbindungen zur selben Postgres-Instanz, wie es getrennte +// Core-Prozesse waeren) verteilt, in der Annahme, jede Instanz fuehre einen +// eigenen In-Process-Zaehler. Da ratelimit.Store atomar gegen dieselbe +// geteilte Tabelle arbeitet (API-03), darf die Gesamtsumme der erlaubten +// Anfragen ueber ALLE "Instanzen" hinweg das konfigurierte Limit nicht +// uebersteigen. +func TestPentest_RateLimit_MultiInstanceBypassAttempt(t *testing.T) { + _, adminDSN := setupRegistryPool(t) + ctx := context.Background() + key := uniqueSlug("pentest-ratelimit") + + const instanceCount = 4 + const requestsPerInstance = 50 + const limit = 100 + + // Eine eigene Konfiguration ueber EINE der "Instanzen" gesetzt - muss + // fuer alle anderen sofort sichtbar sein (geteilter Zustand). + configPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("config pool: %v", err) + } + defer configPool.Close() + if err := ratelimit.NewStore(configPool).SetLimit(ctx, key, limit, time.Minute); err != nil { + t.Fatalf("limit setzen: %v", err) + } + + var allowed int64 + var wg sync.WaitGroup + for i := 0; i < instanceCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // Jede "Instanz" hat einen EIGENEN Pool - simuliert getrennte + // Prozesse, die sich keinen In-Memory-Zustand teilen. + instancePool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Errorf("instanz-pool: %v", err) + return + } + defer instancePool.Close() + store := ratelimit.NewStore(instancePool) + + for j := 0; j < requestsPerInstance; j++ { + res, err := store.Allow(ctx, key) + if err != nil { + t.Errorf("allow: %v", err) + return + } + if res.Allowed { + atomic.AddInt64(&allowed, 1) + } + } + }() + } + wg.Wait() + + totalRequests := int64(instanceCount * requestsPerInstance) + if allowed > limit { + t.Fatalf("mehrinstanz-umgehungsversuch erfolgreich: %d von %d anfragen ueber %d instanzen erlaubt, konfiguriertes limit war %d", + allowed, totalRequests, instanceCount, limit) + } + if allowed != limit { + t.Fatalf("erwartet exakt %d erlaubte anfragen (limit ausgeschoepft, nicht mehr/weniger), habe %d", limit, allowed) + } + t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen, %d anfragen gesamt, exakt %d (=limit) erlaubt", instanceCount, totalRequests, allowed) +} + +// TestPentest_Lockout_MultiInstanceBypassAttempt simuliert denselben +// Umgehungsversuch gegen den Account-Lockout (IAM-07): ein Angreifer verteilt +// Brute-Force-Loginversuche fuer EIN Konto ueber mehrere Instanzen, um zu +// verhindern, dass eine einzelne Instanz die Schwelle erreicht. +func TestPentest_Lockout_MultiInstanceBypassAttempt(t *testing.T) { + registryPool, adminDSN := setupRegistryPool(t) + ctx := context.Background() + + dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) + registry := tenant.NewRegistry(registryPool) + provisioner := tenant.NewProvisioner(registryPool, registry, dsnTemplate) + + slug := uniqueSlug("pentest_lockout") + tn, err := provisioner.Provision(ctx, slug, "Pentest Lockout GmbH") + if err != nil { + t.Fatalf("provision: %v", err) + } + t.Cleanup(func() { + _, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug) + _, _ = registryPool.Exec(ctx, `DROP DATABASE IF EXISTS "tenant_`+slug+`"`) + }) + + tenantPool, err := pgxpool.New(ctx, tn.DBDSN) + if err != nil { + t.Fatalf("tenant pool: %v", err) + } + defer tenantPool.Close() + applyAllUpSQL(t, ctx, tenantPool, filepath.Join(repoRoot(t), "migrations", "tenant")) + + const email = "opfer@pentest.example" + const maxFailed = lockout.DefaultMaxFailedAttempts + const instanceCount = 3 + const attemptsPerInstance = 4 // 3*4=12 > maxFailed(5), muesste ohne umgehung frueh sperren + + var wg sync.WaitGroup + var mu sync.Mutex + var lockedAt []int // Reihenfolge, in der jede "Instanz" eine Sperre beobachtet hat + for i := 0; i < instanceCount; i++ { + wg.Add(1) + instanceID := i + go func() { + defer wg.Done() + instancePool, err := pgxpool.New(ctx, tn.DBDSN) + if err != nil { + t.Errorf("instanz-pool: %v", err) + return + } + defer instancePool.Close() + store := lockout.NewStore(instancePool) + + for j := 0; j < attemptsPerInstance; j++ { + locked, _, err := store.RecordFailure(ctx, email) + if err != nil { + t.Errorf("fehlversuch erfassen: %v", err) + return + } + if locked { + mu.Lock() + lockedAt = append(lockedAt, instanceID) + mu.Unlock() + } + } + }() + } + wg.Wait() + + if len(lockedAt) == 0 { + t.Fatal("mehrinstanz-umgehungsversuch erfolgreich: konto wurde trotz insgesamt mehr als maxFailed fehlversuchen ueber mehrere instanzen NICHT gesperrt") + } + + locked, _, err := lockout.NewStore(tenantPool).IsLocked(ctx, email) + if err != nil { + t.Fatalf("sperrstatus pruefen: %v", err) + } + if !locked { + t.Fatal("konto ist nach ueberschreiten der schwelle ueber mehrere instanzen hinweg nicht als gesperrt markiert") + } + t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen x %d versuche, konto nach ueberschreiten von maxFailed=%d gesperrt", instanceCount, attemptsPerInstance, maxFailed) +} + +// ======================================================================= +// Pruefung 3 / Akzeptanzkriterium 3: zentrale Policy-Durchsetzung gegen +// mindestens einen gezielten Umgehungsversuch je Modul. +// ======================================================================= + +// TestPentest_Policy_PrivilegeEscalationViaWrongRole ist der +// Umgehungsversuch fuer den Identitaets-/Mandantenbereich (TEN/IAM-Module): +// ein normaler Benutzer versucht, ueber den zentralen Enforcer (RBAC-02) ein +// Recht zu nutzen, das ausschliesslich Superadmin vorbehalten ist +// (PermManageTenants). Default-Deny muss greifen. +func TestPentest_Policy_PrivilegeEscalationViaWrongRole(t *testing.T) { + registryPool, _ := setupRegistryPool(t) + ctx := context.Background() + store := policy.NewStore(registryPool) + enforcer := policy.NewEnforcer(store) + + // Nur die Basisrechte gewaehren, wie es die reale Policy vorsieht - + // PermManageTenants bewusst NICHT an user oder tenant_admin vergeben. + if err := store.Grant(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-setup"); err != nil { + t.Fatalf("basisrecht gewaehren: %v", err) + } + t.Cleanup(func() { + _ = store.Revoke(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-cleanup") + }) + + // Angriff: ein Benutzer mit Rolle "user" versucht, eine + // mandantenverwaltende Aktion auszufuehren. + err := enforcer.Authorize(ctx, rbac.RoleUser, rbac.PermManageTenants) + if err == nil { + t.Fatal("privilege-escalation erfolgreich: rolle 'user' durfte platform.manage_tenants ausfuehren") + } + if !errors.Is(err, policy.ErrDenied) { + t.Fatalf("erwartete policy.ErrDenied, habe: %v", err) + } + + // Angriff Variante 2: dieselbe Aktion fuer tenant_admin, der ebenfalls + // nicht ueber PermManageTenants verfuegt. + if err := enforcer.Authorize(ctx, rbac.RoleTenantAdmin, rbac.PermManageTenants); !errors.Is(err, policy.ErrDenied) { + t.Fatalf("privilege-escalation ueber tenant_admin: erwartete policy.ErrDenied, habe: %v", err) + } +} + +// TestPentest_Policy_GuardBypassStructurallyImpossible ist der +// Umgehungsversuch gegen die Durchsetzungsschicht selbst (Akzeptanzkriterium +// 1): policy.Guard ruft die uebergebene Query-Funktion NUR bei Erfolg von +// Authorize auf. Dieser Test beweist das Verhalten aktiv, indem ein +// Zaehler in der Query-Funktion inkrementiert wird - bei einer verweigerten +// Autorisierung darf der Zaehler NIEMALS erhoeht werden, unabhaengig davon, +// wie oft Guard aufgerufen wird. +func TestPentest_Policy_GuardBypassStructurallyImpossible(t *testing.T) { + registryPool, _ := setupRegistryPool(t) + ctx := context.Background() + store := policy.NewStore(registryPool) + enforcer := policy.NewEnforcer(store) + // Bewusst KEINE Regel gewaehrt - default-deny. + + var dataAccessed int32 + query := func(ctx context.Context) (string, error) { + atomic.AddInt32(&dataAccessed, 1) + return "geheime mandantendaten", nil + } + + for i := 0; i < 10; i++ { + _, err := policy.Guard(ctx, enforcer, rbac.RoleUser, rbac.PermManageSettings, query) + if err == nil { + t.Fatal("guard hat query trotz fehlender berechtigung ausgefuehrt") + } + } + + if atomic.LoadInt32(&dataAccessed) != 0 { + t.Fatalf("umgehungsversuch erfolgreich: query wurde %d mal trotz verweigerter autorisierung ausgefuehrt", dataAccessed) + } +} + +// ======================================================================= +// Akzeptanzkriterium 4: Master-Key- und Tenant-KEK-Verwaltung (API-10) +// gegen Schluesselkompromittierung und Rotation geprueft. +// ======================================================================= + +// TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation prueft: nach +// einer Master-Key-Rotation kann der ALTE (potenziell kompromittierte) +// Master-Key die Tenant-KEKs nicht mehr entschluesseln - kompromittiert ein +// Angreifer den alten Master-Key NACH der Rotation, ist der Zugriff bereits +// verwehrt. +func TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation(t *testing.T) { + oldMasterKey := randomMasterKey(t) + newMasterKey := randomMasterKey(t) + + store, pool := setupKEKStore(t) + tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek")) + + plainKEK, err := store.CreateForTenant(context.Background(), tenantID, oldMasterKey) + if err != nil { + t.Fatalf("tenant-kek anlegen: %v", err) + } + + rotated, failed, err := store.RotateMasterKey(context.Background(), oldMasterKey, newMasterKey) + if err != nil { + t.Fatalf("master-key-rotation: %v", err) + } + if rotated != 1 || len(failed) != 0 { + t.Fatalf("rotation unerwartet: rotated=%d failed=%v", rotated, failed) + } + + // Angriff: der (kompromittierte) ALTE Master-Key wird nach der Rotation + // verwendet, um den Tenant-KEK zu entschluesseln. + if _, err := store.GetDecrypted(context.Background(), tenantID, oldMasterKey); err == nil { + t.Fatal("kompromittierter alter master-key konnte tenant-kek nach rotation weiterhin entschluesseln - kritische luecke") + } + + // Gegenprobe: der neue Master-Key liefert weiterhin denselben Plaintext- + // Tenant-KEK (Akzeptanzkriterium 3: Rotation aendert nur die Verpackung). + decrypted, err := store.GetDecrypted(context.Background(), tenantID, newMasterKey) + if err != nil { + t.Fatalf("neuer master-key konnte tenant-kek nicht entschluesseln: %v", err) + } + if string(decrypted) != string(plainKEK) { + t.Fatal("tenant-kek-plaintext hat sich durch master-key-rotation veraendert - widerspricht akzeptanzkriterium 3") + } +} + +// TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants ist der +// Umgehungs-/Kompromittierungstest fuer Tenant-KEK-Rotation: kompromittiert +// ein Angreifer den Tenant-KEK von Mandant A und dieser wird rotiert, darf +// das keinerlei Auswirkung auf Mandant B haben (Fortsetzung der physischen +// Modell-C-Isolation auf Schluesselebene). +func TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants(t *testing.T) { + masterKey := randomMasterKey(t) + store, pool := setupKEKStore(t) + + tenantA := insertTenantRow(t, pool, uniqueSlug("pentest_kek_a")) + tenantB := insertTenantRow(t, pool, uniqueSlug("pentest_kek_b")) + + if _, err := store.CreateForTenant(context.Background(), tenantA, masterKey); err != nil { + t.Fatalf("tenant a kek anlegen: %v", err) + } + kekBBefore, err := store.CreateForTenant(context.Background(), tenantB, masterKey) + if err != nil { + t.Fatalf("tenant b kek anlegen: %v", err) + } + + // Tenant As KEK wird rotiert (z.B. weil kompromittiert). + if _, err := store.RotateTenantKEK(context.Background(), tenantA, masterKey); err != nil { + t.Fatalf("tenant a rotation: %v", err) + } + + kekBAfter, err := store.GetDecrypted(context.Background(), tenantB, masterKey) + if err != nil { + t.Fatalf("tenant b kek nach fremder rotation nicht mehr lesbar: %v", err) + } + if string(kekBAfter) != string(kekBBefore) { + t.Fatal("tenant bs kek hat sich durch rotation von tenant a veraendert - isolationsverletzung") + } +} + +// TestPentest_KEK_TamperedWrappedKeyRejected prueft, dass eine Manipulation +// des in der Datenbank gespeicherten, verpackten Tenant-KEK (z.B. durch +// einen Angreifer mit Datenbankzugriff, aber ohne Master-Key) beim +// Entschluesseln zuverlaessig als ungueltig erkannt wird - AES-GCM ist +// authentifizierend, ein manipulierter Chiffretext darf niemals einen +// (falschen) Plaintext zurueckliefern. +func TestPentest_KEK_TamperedWrappedKeyRejected(t *testing.T) { + masterKey := randomMasterKey(t) + store, pool := setupKEKStore(t) + tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek_tamper")) + + if _, err := store.CreateForTenant(context.Background(), tenantID, masterKey); err != nil { + t.Fatalf("tenant-kek anlegen: %v", err) + } + + // Angriff: ein Byte im gespeicherten, verpackten Schluessel wird + // direkt in der Datenbank veraendert (kein Master-Key noetig fuer diesen + // Schritt). + tag, err := pool.Exec(context.Background(), ` + UPDATE tenant_keks SET wrapped_kek = set_byte(wrapped_kek, 20, get_byte(wrapped_kek, 20) # 255) + WHERE tenant_id = $1 + `, tenantID) + if err != nil { + t.Fatalf("wrapped_kek manipulieren: %v", err) + } + if tag.RowsAffected() != 1 { + t.Fatalf("erwartet 1 manipulierte zeile, habe %d", tag.RowsAffected()) + } + + if _, err := store.GetDecrypted(context.Background(), tenantID, masterKey); !errors.Is(err, kek.ErrUnwrapFailed) { + t.Fatalf("manipulierter wrapped_kek wurde nicht als ErrUnwrapFailed erkannt: %v", err) + } +} + +// --------------------------------------------------------------------- +// KEK-Testhilfsfunktionen (eigenes Minimalschema wie in internal/kek/kek_test.go, +// mit derselben Aufraeum-Disziplin - siehe QA-04-Befund zu +// t.Cleanup/defer-Reihenfolge). +// --------------------------------------------------------------------- + +func randomMasterKey(t *testing.T) kek.MasterKey { + t.Helper() + buf := make([]byte, kek.MasterKeySize) + if _, err := rand.Read(buf); err != nil { + t.Fatalf("master-key erzeugen: %v", err) + } + return kek.MasterKey(buf) +} + +func setupKEKStore(t *testing.T) (*kek.Store, *pgxpool.Pool) { + t.Helper() + adminDSN := requireAdminDSN(t) + ctx := context.Background() + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + // Ueber t.Cleanup registriert (nicht defer), damit spaeter via t.Cleanup + // registrierte Aufraeumfunktionen (insertTenantRow) den Pool beim + // Ausfuehren noch offen vorfinden - siehe QA-04-Befund in + // internal/kek/kek_test.go zur defer/t.Cleanup-Reihenfolge. + t.Cleanup(func() { pool.Close() }) + + if _, err := pool.Exec(ctx, ` + 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 + )`); err != nil { + t.Fatalf("tenant_keks-schema: %v", err) + } + return kek.NewStore(pool), pool +} + +func insertTenantRow(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-zeile anlegen: %v", err) + } + 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 +} diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh index eca7648..4b0058e 100755 --- a/scripts/reset-test-env.sh +++ b/scripts/reset-test-env.sh @@ -13,10 +13,25 @@ ROLE="nexarch_test" export PGPASSWORD="$PASS" +# Alle Registry-Tabellen, die von migrations/*.up.sql angelegt werden - +# inklusive der Tabellen mit FK-Bezug auf tenants (tenant_keks). "DROP TABLE +# tenants CASCADE" entfernt lediglich die FK-CONSTRAINT auf einer +# referenzierenden Tabelle, NICHT deren Zeilen - ohne den expliziten Drop +# hier ueberleben verwaiste tenant_keks-Zeilen jeden Reset und verfaelschen +# spaetere Testlaeufe (QA-04-Befund: internal/pentest kollidierte mit +# Altzeilen aus fruehreren internal/kek-Testlaeufen). +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_keks CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings_history CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS superadmins CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS rate_limit_counters CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS rate_limit_configs CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS policy_rule_changes CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS policy_rules CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS feature_flags CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS module_credentials CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS modules CASCADE;" dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'") for db in $dbs; do