// Row-Level-Security-Unterstützung. Isolation zwischen Mandanten lief // bisher ausschließlich anwendungsseitig (Handler vergleichen AccountID, // siehe CLAUDE.md) — das ist die einzige Stelle, die einen vergessenen // WHERE-account_id-Filter in einer neuen Store-Methode nicht auffängt. // WithTenantScope öffnet für die Dauer eines Requests eine Transaktion // und setzt zwei Postgres-Sitzungsvariablen (SET LOCAL, per set_config // mit Parameterbindung statt String-Interpolation — SQL-Injection-frei // und automatisch auf die Transaktion begrenzt, kein manuelles Zurück- // setzen nötig): // // - app.account_id — der Mandant, für den dieser Request angemeldet ist // - app.is_betreiber — "true" für Ebene-5-Zugriff (sieht alle Mandanten) // // Migration 0021 aktiviert FORCE ROW LEVEL SECURITY auf den Tabellen mit // echten Mandanten-Geschäftsdaten und legt Policies an, die genau diese // beiden Variablen auswerten. Bewusst NICHT auf account/app_user/ // session/password_reset_token (siehe Migration 0021 für die Begründung // — diese vier brauchen unmandantierte Lookups, z. B. Login per E-Mail). package store import ( "context" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // pgxIface wird sowohl von *pgxpool.Pool als auch von pgx.Tx erfüllt — // db(ctx) kann so transparent zwischen "kein Tenant-Kontext" (Pool, // z. B. in Store-Tests ohne WithTenantScope) und "innerhalb eines // Requests" (Tx mit gesetzten Sitzungsvariablen) wählen, ohne dass jede // einzelne Store-Methode das selbst unterscheiden müsste. type pgxIface interface { Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) } type tenantTxKey struct{} // db liefert die für ctx passende Ausführungsschnittstelle: die aktive // Transaktion, falls WithTenantScope sie gesetzt hat, sonst den Pool // direkt (z. B. für Store-Tests, die ohne Tenant-Kontext laufen — diese // Tabellen sind dann nicht durch RLS geschützt, was für White-Box-Tests // der Store-Logik selbst unkritisch ist, siehe dedizierte RLS-Tests in // tenant_scope_test.go für den tatsächlichen Isolationsnachweis). func (s *Store) db(ctx context.Context) pgxIface { if tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx); ok { return tx } return s.Pool } // WithTenantScope führt fn in einer Transaktion aus, die die Sitzungs- // variablen für RLS setzt. accountID kann leer sein (z. B. während der // Registrierung, bevor der neue Account existiert) — SetTenantScope // erlaubt, die Variable mitten in derselben Transaktion nachträglich zu // setzen, sobald die ID bekannt ist. func (s *Store) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error { tx, err := s.Pool.Begin(ctx) if err != nil { return fmt.Errorf("store: begin tenant scope: %w", err) } defer tx.Rollback(ctx) //nolint:errcheck // no-op nach erfolgreichem Commit scopedCtx := context.WithValue(ctx, tenantTxKey{}, tx) if err := setTenantSessionVars(scopedCtx, tx, accountID, isBetreiber); err != nil { return err } if err := fn(scopedCtx); err != nil { return err } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("store: commit tenant scope: %w", err) } return nil } // SetTenantScope aktualisiert die Sitzungsvariablen innerhalb einer // bereits laufenden WithTenantScope-Transaktion — nötig, wenn eine neue // Firma erst mitten im Request entsteht (die account_id ist vorher // nicht bekannt, siehe handleRegister/handleBetreiberAccountCreate). // Ruft man es außerhalb von WithTenantScope auf, ist es ein No-op ohne // Effekt (kein Tx im Context) — daher immer den Rückgabewert prüfen, // falls das je außerhalb eines Handlers genutzt wird. func (s *Store) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error { tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx) if !ok { return fmt.Errorf("store: SetTenantScope außerhalb von WithTenantScope aufgerufen") } return setTenantSessionVars(ctx, tx, accountID, isBetreiber) } func setTenantSessionVars(ctx context.Context, tx pgx.Tx, accountID string, isBetreiber bool) error { betreiberFlag := "false" if isBetreiber { betreiberFlag = "true" } if _, err := tx.Exec(ctx, `SELECT set_config('app.account_id', $1, true), set_config('app.is_betreiber', $2, true)`, accountID, betreiberFlag); err != nil { return fmt.Errorf("store: set tenant scope: %w", err) } return nil }