// Beweis, dass Row-Level-Security tatsächlich greift — nicht nur, dass // die Store-Methoden mit einer Transaktion statt dem Pool funktionieren // (das würde auch mit einer Superuser-Verbindung "bestehen", ohne dass // RLS irgendetwas tut, siehe CLAUDE.md/Migration 0021: Superuser // umgehen RLS-Policies immer). Diese Tests laufen deshalb NICHT gegen // DATABASE_URL (Superuser, für Migrationen), sondern gegen // DATABASE_URL_APP — die eingeschränkte Rolle "deklarix_app", für die // die Policies tatsächlich wirken. Ohne DATABASE_URL_APP werden sie // übersprungen (die Rolle existiert erst nach manuellem Passwort-Setup, // siehe Migration 0021 und CLAUDE.md). package store_test import ( "context" "errors" "os" "testing" "github.com/netcell-it/deklarix/internal/store" ) // openRestrictedTestStore öffnet eine zweite Verbindung über // DATABASE_URL_APP (die eingeschränkte Rolle) — Migrationen und // Fixture-Aufbau laufen weiterhin über die normale, privilegierte // openTestStore-Verbindung. func openRestrictedTestStore(t *testing.T) *store.Store { t.Helper() url := os.Getenv("DATABASE_URL_APP") if url == "" { t.Skip("DATABASE_URL_APP nicht gesetzt, überspringe RLS-Test (siehe Migration 0021)") } s, err := store.Open(context.Background(), url) if err != nil { t.Fatalf("Open (restricted): %v", err) } t.Cleanup(s.Close) return s } func TestRLSAbteilungIsoliertZwischenMandanten(t *testing.T) { privileged := openTestStore(t) restricted := openRestrictedTestStore(t) ctx := context.Background() accA := testAccountID(t, privileged) accB := testAccountID(t, privileged) abtA, err := privileged.CreateAbteilung(ctx, accA, "Abteilung-A") if err != nil { t.Fatalf("CreateAbteilung A: %v", err) } if _, err := privileged.CreateAbteilung(ctx, accB, "Abteilung-B"); err != nil { t.Fatalf("CreateAbteilung B: %v", err) } // Mit Tenant-Kontext A: nur die eigene Abteilung ist sichtbar, auch // wenn wir explizit nach IDs von B fragen würden — hier geprüft über // die Listen-Methode, die auf accA gefiltert nach RLS zusätzlich // noch mal (redundant) accountID=accA übergibt; der Beweis liegt in // TestRLSVerweigertFremdenAccountAuchBeiFalscherAccountID unten, wo // die Anwendungsschicht bewusst "falsch" fragt. err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error { liste, err := restricted.ListAbteilungenForAccount(scoped, accA) if err != nil { return err } if len(liste) != 1 || liste[0].Name != "Abteilung-A" { t.Fatalf("liste = %+v, want genau [Abteilung-A]", liste) } return nil }) if err != nil { t.Fatalf("WithTenantScope A: %v", err) } // Der eigentliche RLS-Beweis: im Tenant-Kontext A nach ListAbteilungenForAccount(B) // fragen — ein Programmierfehler, der die AccountID nicht prüft, wäre // ohne RLS ein echtes Datenleck. Mit RLS liefert die DB trotzdem 0 Zeilen, // weil die Sitzungsvariable (Kontext A) nicht zu den B-Zeilen passt. err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error { liste, err := restricted.ListAbteilungenForAccount(scoped, accB) if err != nil { return err } if len(liste) != 0 { t.Fatalf("RLS-LECK: Kontext A sieht %d Zeilen von Account B, want 0", len(liste)) } return nil }) if err != nil { t.Fatalf("WithTenantScope A->B: %v", err) } // GetAbteilung(abtA.ID) im Kontext B muss ErrNotFound liefern — RLS // versteckt die fremde Zeile, unabhängig davon, ob die Anwendung die // AccountID selbst vergleicht. err = restricted.WithTenantScope(ctx, accB, false, func(scoped context.Context) error { _, err := restricted.GetAbteilung(scoped, abtA.ID) if !errors.Is(err, store.ErrNotFound) { t.Fatalf("RLS-LECK: GetAbteilung(A) im Kontext B err=%v, want ErrNotFound", err) } return nil }) if err != nil { t.Fatalf("WithTenantScope B: %v", err) } // Betreiber-Kontext sieht beide. err = restricted.WithTenantScope(ctx, "", true, func(scoped context.Context) error { listeA, err := restricted.ListAbteilungenForAccount(scoped, accA) if err != nil { return err } listeB, err := restricted.ListAbteilungenForAccount(scoped, accB) if err != nil { return err } if len(listeA) != 1 || len(listeB) != 1 { t.Fatalf("Betreiber-Kontext: listeA=%+v listeB=%+v, want je 1", listeA, listeB) } return nil }) if err != nil { t.Fatalf("WithTenantScope Betreiber: %v", err) } // Ohne jeden Tenant-Kontext (direkter Pool-Zugriff, keine Transaktion, // keine Sitzungsvariable gesetzt): fail closed, 0 Zeilen — nicht "alle". liste, err := restricted.ListAbteilungenForAccount(ctx, accA) if err != nil { t.Fatalf("ListAbteilungenForAccount ohne Kontext: %v", err) } if len(liste) != 0 { t.Fatalf("RLS-LECK: ohne Tenant-Kontext sichtbar: %+v, want 0 Zeilen (fail closed)", liste) } } func TestRLSVerhindertInsertFuerFremdenAccount(t *testing.T) { privileged := openTestStore(t) restricted := openRestrictedTestStore(t) ctx := context.Background() accA := testAccountID(t, privileged) accB := testAccountID(t, privileged) err := restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error { _, err := restricted.CreateAbteilung(scoped, accB, "Boesartig-Eingeschleust") return err }) if err == nil { t.Fatal("RLS-LECK: INSERT für fremde account_id im Kontext A wurde nicht abgelehnt") } }