Voller Merge von RBAC-02/IAM-06/IAM-07/API-03/API-10/API-08/API-09/IAM-10/ IAM-11/IAM-13 plus echte Angriffstests (internal/pentest) gegen SSO/OIDC (alg=none, Fremdschluessel, Claims-Manipulation, Nonce-Replay), Rate- Limiting/Lockout im simulierten Mehrinstanz-Betrieb, zentrale Policy- Durchsetzung (Rechteausweitung, struktureller Guard-Bypass) und Master- Key-/Tenant-KEK-Rotation. 29/29 Pakete gruen auf 192.168.1.131. Vier real gefundene Testinfrastruktur-Fehler behoben: reset-test-env.sh liess tenant_keks (und weitere neuere Registry-Tabellen) beim Reset stehen (FK-CASCADE loescht nur die Constraint, keine Zeilen); zwei E2E-Tests und kek_test.go schlossen ihren adminPool per defer VOR ihrer t.Cleanup- Bereinigung (t.Cleanup laeuft immer nach allen defers); migrate_test.go hatte ein Testschema ohne die TEN-04-Lifecycle-Spalten. Alle vier Fixes betreffen ausschliesslich Testcode, kein Produktionscode geaendert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
121 lines
4.7 KiB
Go
121 lines
4.7 KiB
Go
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")
|
|
}
|
|
}
|