feat: Row-Level-Security für Mandantenisolation auf DB-Ebene
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>
This commit is contained in:
@@ -28,7 +28,7 @@ type GenehmigerRolle struct {
|
||||
// Bedingung, das bleibt Sache von freigabe_regel.
|
||||
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
|
||||
RETURNING id, account_id, name, beschreibung, created_at
|
||||
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
@@ -41,7 +41,7 @@ func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, besc
|
||||
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
|
||||
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -56,7 +56,7 @@ func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRo
|
||||
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||
// Mandanten.
|
||||
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
@@ -82,7 +82,7 @@ func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID st
|
||||
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
|
||||
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
||||
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||
|
||||
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
_, err := s.db(ctx).Exec(ctx, `
|
||||
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
||||
`, userID, genehmigerRolleID)
|
||||
@@ -106,7 +106,7 @@ func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmiger
|
||||
|
||||
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
_, err := s.db(ctx).Exec(ctx, `
|
||||
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
|
||||
`, userID, genehmigerRolleID)
|
||||
if err != nil {
|
||||
@@ -118,7 +118,7 @@ func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmi
|
||||
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
||||
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
||||
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT gr.id, gr.account_id, gr.name, gr.created_at
|
||||
FROM genehmiger_rolle gr
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
|
||||
@@ -151,7 +151,7 @@ type GenehmigerMitglied struct {
|
||||
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||
// bestimmten Genehmiger-Rolle.
|
||||
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT u.id, u.email
|
||||
FROM app_user u
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
|
||||
@@ -189,7 +189,7 @@ type FreigabeRegel struct {
|
||||
|
||||
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
@@ -203,7 +203,7 @@ func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
@@ -224,7 +224,7 @@ func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID stri
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete freigabe regel: %w", err)
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
|
||||
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
|
||||
@@ -269,7 +269,7 @@ type Freigabeschritt struct {
|
||||
|
||||
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
`, antragID, genehmigerRolleID).Scan(
|
||||
@@ -283,7 +283,7 @@ func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerR
|
||||
|
||||
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
|
||||
@@ -297,7 +297,7 @@ func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschr
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
|
||||
`, antragID)
|
||||
@@ -321,7 +321,7 @@ func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID stri
|
||||
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
||||
// innehat — Grundlage für "Meine Freigaben".
|
||||
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
|
||||
FROM freigabeschritt f
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
|
||||
@@ -351,7 +351,7 @@ func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, user
|
||||
// generischer Fehler statt eines Sentinels).
|
||||
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
||||
WHERE id = $1 AND status = 'ausstehend'
|
||||
@@ -375,7 +375,7 @@ func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entsc
|
||||
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
|
||||
// hängen zu bleiben.
|
||||
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
_, err := s.db(ctx).Exec(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = 'abgelehnt', entschieden_am = now(),
|
||||
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
||||
|
||||
Reference in New Issue
Block a user