Postgres-RLS-Policies auf allen Tabellen mit echten Mandanten- Geschäftsdaten (antrag und alles darüber verkettete, abteilung, werkzeug/werkzeug_sperre, genehmiger_rolle/freigabe_regel, loeschfrist_einstellung). Kritischer Fund vor der Umsetzung: die Anwendung verbindet als postgres-Superuser, der RLS immer umgeht - Migration 0021 legt deshalb zusätzlich eine eingeschränkte Rolle "deklarix_app" an, nur für die greifen die Policies tatsächlich. internal/store/tenant_scope.go: WithTenantScope öffnet eine Transaktion und setzt Sitzungsvariablen (app.account_id/app.is_betreiber) per set_config mit Parameterbindung; alle Store-Methoden laufen jetzt über s.db(ctx) statt direkt s.Pool. Jede require*-Middleware umschließt die komplette Handler-Ausführung damit - jeder Request läuft dadurch auch atomar in einer Transaktion (positiver Nebeneffekt). Live end-to-end verifiziert (echter HTTP-Server + DATABASE_URL_APP auf die eingeschränkte Rolle gesetzt): zwei Firmen registriert, Isolation über Abteilung/Antrag/Bewertung bestätigt, zentraler NULL-Katalog für beide sichtbar. Produktivbetrieb braucht noch einen manuellen Schritt (Passwort für deklarix_app setzen + DATABASE_URL_APP konfigurieren, siehe CLAUDE.md) - die Migration allein aktiviert noch nichts, solange die App weiter als Superuser verbindet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
105 lines
4.5 KiB
Go
105 lines
4.5 KiB
Go
// 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
|
|
}
|