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:
104
CLAUDE.md
104
CLAUDE.md
@@ -633,6 +633,99 @@ und live per curl gegen echten Server + Postgres verifiziert.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Row-Level-Security (2026-09-01, Migration 0021)
|
||||||
|
|
||||||
|
**Kritischer Fund vor der Umsetzung:** Sowohl lokal als auch auf dem
|
||||||
|
Produktivserver verbindet sich die Anwendung als `postgres`-Rolle — ein
|
||||||
|
echter Postgres-**Superuser**. Superuser umgehen RLS-Policies *immer*,
|
||||||
|
unabhängig von `FORCE ROW LEVEL SECURITY` (das wirkt nur beim
|
||||||
|
Tabellenbesitzer, nicht bei Superusern — eine harte, nicht
|
||||||
|
überschreibbare Postgres-Regel). Policies allein hätten also nichts
|
||||||
|
bewirkt. Migration 0021 legt deshalb zusätzlich eine neue,
|
||||||
|
eingeschränkte Rolle **`deklarix_app`** an (kein Superuser, kein
|
||||||
|
Tabellenbesitzer, `NOBYPASSRLS`, zunächst `NOLOGIN`) — nur für diese
|
||||||
|
Rolle greifen die Policies tatsächlich.
|
||||||
|
|
||||||
|
**Architektur:** `internal/store/tenant_scope.go` — `Store.db(ctx)`
|
||||||
|
liefert entweder die aktive Transaktion (falls `WithTenantScope` sie
|
||||||
|
gesetzt hat) oder den Pool direkt; alle ~69 vorherigen `s.Pool.Query/
|
||||||
|
QueryRow/Exec`-Aufrufe wurden mechanisch auf `s.db(ctx).…` umgestellt.
|
||||||
|
`WithTenantScope(ctx, accountID, isBetreiber, fn)` öffnet eine
|
||||||
|
Transaktion, setzt zwei Sitzungsvariablen per `set_config(..., true)`
|
||||||
|
(Parameterbindung, kein String-Interpolation — SQL-Injection-frei,
|
||||||
|
automatisch auf die Transaktion begrenzt) — `app.account_id` und
|
||||||
|
`app.is_betreiber` — und committet am Ende. `SetTenantScope` erlaubt,
|
||||||
|
die Variable **mitten in einer laufenden** Transaktion nachzutragen
|
||||||
|
(nötig bei `handleRegister`: die `account_id` ist erst nach
|
||||||
|
`CreateAccount` bekannt). Jede der fünf `require*`-Middlewares
|
||||||
|
(`requirePage/-API/-Admin/-Fachebene/-Betreiber`) umschließt die
|
||||||
|
komplette Handler-Ausführung jetzt mit `withTenantScope` — jeder
|
||||||
|
Request läuft dadurch automatisch in genau einer Transaktion, nicht nur
|
||||||
|
die Store-Aufrufe, die ein Handler zufällig selbst noch mal prüft.
|
||||||
|
**Nebeneffekt (bewusst, positiv):** ein Handler mit mehreren
|
||||||
|
Schreibzugriffen ist jetzt atomar — schlägt ein späterer Schritt fehl,
|
||||||
|
werden frühere Schritte desselben Requests zurückgerollt (vorher: keine
|
||||||
|
Transaktion, ein halb fehlgeschlagener Handler konnte einen verwaisten
|
||||||
|
Account ohne Nutzer hinterlassen).
|
||||||
|
|
||||||
|
**Geschützte Tabellen** (`ENABLE ROW LEVEL SECURITY` + Policy
|
||||||
|
`tenant_isolation`): `antrag`, `registereintrag`, `abteilung`,
|
||||||
|
`werkzeug_sperre`, `genehmiger_rolle`, `freigabe_regel`,
|
||||||
|
`loeschfrist_einstellung` (direkte `account_id`-Prüfung), `werkzeug`
|
||||||
|
(nullable — `NULL` = zentraler Katalog, für alle lesbar, nur Betreiber
|
||||||
|
darf `NULL`-Zeilen schreiben), `bewertung`/`entscheidung`/
|
||||||
|
`freigabeschritt` (kein eigenes `account_id`, Policy prüft
|
||||||
|
`antrag_id IN (SELECT id FROM antrag)` — erbt automatisch dieselbe
|
||||||
|
Grenze wie `antrag`, da diese Unterabfrage in derselben Sitzung
|
||||||
|
läuft), `nutzer_genehmiger_rolle` (analog über `genehmiger_rolle_id`).
|
||||||
|
|
||||||
|
**Bewusst OHNE RLS** (dokumentiert in der Migration, kein Versehen):
|
||||||
|
`account` (keine `account_id`-Spalte, muss bei Registrierung
|
||||||
|
uneingeschränkt INSERT erlauben, bevor die ID bekannt ist), `app_user`
|
||||||
|
(Login/Passwort-Reset suchen per E-Mail über alle Mandanten hinweg —
|
||||||
|
die Ziel-`account_id` ist zu dem Zeitpunkt noch nicht bekannt),
|
||||||
|
`session`/`password_reset_token` (werden nur über einen
|
||||||
|
kryptographisch zufälligen, praktisch unerratbaren Token gesucht, kein
|
||||||
|
sequentiell erratbarer Schlüssel), `audit_log` (plattformweites
|
||||||
|
Protokoll ohne eigene `account_id`, wird ausschließlich vom Betreiber
|
||||||
|
gelesen, der ohnehin alle Mandanten sieht).
|
||||||
|
|
||||||
|
**Live end-to-end verifiziert** (nicht nur Unit-Tests): Rohes SQL
|
||||||
|
gegen `deklarix_app` direkt per `psql` (Isolation, Betreiber-Bypass,
|
||||||
|
Fail-Closed ohne Kontext, INSERT-Schutz gegen fremde `account_id` —
|
||||||
|
alle vier Fälle bestätigt), danach derselbe Beweis noch mal über den
|
||||||
|
echten laufenden HTTP-Server mit `DATABASE_URL_APP` auf `deklarix_app`
|
||||||
|
gesetzt: zwei Firmen registriert, Abteilung/Antrag/Bewertung einer
|
||||||
|
Firma über die andere nicht erreichbar, zentraler Werkzeugkatalog
|
||||||
|
(NULL-`account_id`) für beide sichtbar. Dabei einen echten Bug gefunden
|
||||||
|
und behoben: `handleRegister`s `startSession`-Aufruf nutzte noch
|
||||||
|
`r.Context()` statt der lokalen, transaktionsbehafteten `ctx`-Variable
|
||||||
|
— die Session-INSERT lief dadurch auf einer anderen (Pool-)Verbindung
|
||||||
|
als der gerade erst angelegte, noch nicht committete Nutzer, was einen
|
||||||
|
Fremdschlüssel-Konflikt auslöste und die gesamte Registrierung
|
||||||
|
zurückrollte. Zwei dedizierte Tests
|
||||||
|
(`internal/store/tenant_scope_test.go`, `TestRLSAbteilungIsoliertZwischenMandanten`,
|
||||||
|
`TestRLSVerhindertInsertFuerFremdenAccount`) laufen gegen die echte
|
||||||
|
eingeschränkte Rolle und überspringen sich selbst sauber, wenn
|
||||||
|
`DATABASE_URL_APP` nicht gesetzt ist (analog zum bestehenden
|
||||||
|
`DATABASE_URL`-Skip-Muster).
|
||||||
|
|
||||||
|
**Produktivbetrieb — noch ausstehender manueller Schritt.** Die
|
||||||
|
Migration allein bewirkt in Produktion noch NICHTS (die App verbindet
|
||||||
|
weiterhin als `postgres`-Superuser, `DATABASE_URL_APP` ist nicht
|
||||||
|
gesetzt). Um RLS tatsächlich scharf zu schalten:
|
||||||
|
1. `ALTER ROLE deklarix_app WITH LOGIN PASSWORD '<neu generiertes Secret>';`
|
||||||
|
einmalig auf dem Zielserver ausführen (das Passwort steht nicht im
|
||||||
|
Code/in Migrationen — Secrets gehören nicht in ein versioniertes
|
||||||
|
Repo).
|
||||||
|
2. `DATABASE_URL_APP=postgres://deklarix_app:<secret>@<host>/deklarix?...`
|
||||||
|
in `/etc/deklarix/deklarix.env` eintragen.
|
||||||
|
3. Dienst neu starten.
|
||||||
|
`DATABASE_URL` (Migrationen, DDL-Rechte) bleibt unverändert auf der
|
||||||
|
bisherigen, privilegierten Verbindung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Löschfristen je Datenklasse (2026-09-01, Migration 0020)
|
## Löschfristen je Datenklasse (2026-09-01, Migration 0020)
|
||||||
|
|
||||||
Schließt `rules/OPEN.md`, Punkt 4. Recherche ergab: die DSGVO nennt
|
Schließt `rules/OPEN.md`, Punkt 4. Recherche ergab: die DSGVO nennt
|
||||||
@@ -1289,13 +1382,10 @@ journalctl -u deklarix -f
|
|||||||
- ~~Löschfristen je Datenklasse~~ — **erledigt 2026-09-01**, siehe
|
- ~~Löschfristen je Datenklasse~~ — **erledigt 2026-09-01**, siehe
|
||||||
„Löschfristen je Datenklasse" weiter oben: pro Mandant einstellbar
|
„Löschfristen je Datenklasse" weiter oben: pro Mandant einstellbar
|
||||||
statt hartkodiert, da die DSGVO selbst keine festen Fristen nennt.
|
statt hartkodiert, da die DSGVO selbst keine festen Fristen nennt.
|
||||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
- ~~Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)~~
|
||||||
— die Frontend-Spezifikation fordert das explizit ("nicht in der
|
— **Code/Migration/Tests erledigt 2026-09-01, Produktivbetrieb noch
|
||||||
Anwendungslogik"), aktuell ist Isolation ausschließlich
|
ausstehend** (siehe „Row-Level-Security" weiter oben für den vollen
|
||||||
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS
|
Stand und was auf dem Produktivserver noch manuell nachzuziehen ist).
|
||||||
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
|
||||||
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
|
||||||
entstehen, die sonst nachträglich migriert werden müssten.
|
|
||||||
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
|
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
|
||||||
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
|
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
|
||||||
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
|
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
|
||||||
|
|||||||
@@ -22,7 +22,19 @@ func main() {
|
|||||||
log.Fatalf("migrate: %v", err)
|
log.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := store.Open(context.Background(), databaseURL)
|
// Migrationen laufen immer über DATABASE_URL (braucht DDL-Rechte,
|
||||||
|
// z. B. CREATE TABLE/ALTER TABLE). Die laufende Anwendung verbindet
|
||||||
|
// sich dagegen möglichst über die eingeschränkte Row-Level-Security-
|
||||||
|
// Rolle "deklarix_app" (siehe Migration 0021) — DATABASE_URL_APP,
|
||||||
|
// falls gesetzt. Ohne DATABASE_URL_APP fällt sie auf DATABASE_URL
|
||||||
|
// zurück (bisheriges Verhalten, Postgres-Superuser umgeht RLS-
|
||||||
|
// Policies dann vollständig — kein Fehler, aber auch keine
|
||||||
|
// zusätzliche Isolation auf DB-Ebene, siehe CLAUDE.md).
|
||||||
|
appDatabaseURL := os.Getenv("DATABASE_URL_APP")
|
||||||
|
if appDatabaseURL == "" {
|
||||||
|
appDatabaseURL = databaseURL
|
||||||
|
}
|
||||||
|
db, err := store.Open(context.Background(), appDatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open store: %v", err)
|
log.Fatalf("open store: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type Abteilung struct {
|
|||||||
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
||||||
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
||||||
var a Abteilung
|
var a Abteilung
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
||||||
RETURNING id, account_id, name, created_at
|
RETURNING id, account_id, name, created_at
|
||||||
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||||
@@ -33,7 +33,7 @@ func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Ab
|
|||||||
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
|
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
|
||||||
// alphabetisch — als Auswahlliste für den Fragebogen.
|
// alphabetisch — als Auswahlliste für den Fragebogen.
|
||||||
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
|
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT id, account_id, name, created_at FROM abteilung
|
SELECT id, account_id, name, created_at FROM abteilung
|
||||||
WHERE account_id = $1 ORDER BY name
|
WHERE account_id = $1 ORDER BY name
|
||||||
`, accountID)
|
`, accountID)
|
||||||
@@ -59,7 +59,7 @@ func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string)
|
|||||||
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
||||||
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
||||||
var a Abteilung
|
var a Abteilung
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
|
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
|
||||||
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -74,7 +74,7 @@ func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error)
|
|||||||
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
|
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
|
||||||
// angelegt).
|
// angelegt).
|
||||||
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
|
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
|
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: delete abteilung: %w", err)
|
return fmt.Errorf("store: delete abteilung: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func scanAccount(row interface {
|
|||||||
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
||||||
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
||||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO account (name) VALUES ($1)
|
INSERT INTO account (name) VALUES ($1)
|
||||||
RETURNING `+accountColumns,
|
RETURNING `+accountColumns,
|
||||||
name,
|
name,
|
||||||
@@ -48,7 +48,7 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
|
|||||||
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||||
// Betreiber-gestützten Anlage korrigieren).
|
// Betreiber-gestützten Anlage korrigieren).
|
||||||
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||||
a, err := scanAccount(row)
|
a, err := scanAccount(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Account{}, ErrNotFound
|
return Account{}, ErrNotFound
|
||||||
@@ -61,7 +61,7 @@ func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, er
|
|||||||
|
|
||||||
// GetAccount liest einen Mandanten anhand seiner ID.
|
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||||
a, err := scanAccount(row)
|
a, err := scanAccount(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Account{}, ErrNotFound
|
return Account{}, ErrNotFound
|
||||||
@@ -77,7 +77,7 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
|||||||
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
|
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
|
||||||
// oder bereits erneuertem (damit ungültig gewordenem) Token.
|
// oder bereits erneuertem (damit ungültig gewordenem) Token.
|
||||||
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
|
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
|
||||||
a, err := scanAccount(row)
|
a, err := scanAccount(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Account{}, ErrNotFound
|
return Account{}, ErrNotFound
|
||||||
@@ -92,7 +92,7 @@ func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (A
|
|||||||
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
|
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
|
||||||
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
|
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
|
||||||
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: regenerate einladung token: %w", err)
|
return fmt.Errorf("store: regenerate einladung token: %w", err)
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToke
|
|||||||
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
|
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
|
||||||
// Admin-Bereich (Accounts-Verwaltung).
|
// Admin-Bereich (Accounts-Verwaltung).
|
||||||
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
|
rows, err := s.db(ctx).Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func scanAntrag(row interface {
|
|||||||
|
|
||||||
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
|
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
|
||||||
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
|
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING `+antragColumns,
|
RETURNING `+antragColumns,
|
||||||
@@ -62,7 +62,7 @@ func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID str
|
|||||||
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
|
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
|
||||||
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
||||||
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
|
||||||
a, err := scanAntrag(row)
|
a, err := scanAntrag(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Antrag{}, ErrNotFound
|
return Antrag{}, ErrNotFound
|
||||||
@@ -78,7 +78,7 @@ func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
|||||||
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
|
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
|
||||||
// Status hier nicht).
|
// Status hier nicht).
|
||||||
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
|
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
UPDATE antrag SET
|
UPDATE antrag SET
|
||||||
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -99,7 +99,7 @@ func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung,
|
|||||||
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
|
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
|
||||||
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
||||||
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: set antrag status: %w", err)
|
return fmt.Errorf("store: set antrag status: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
|||||||
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
|
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
|
||||||
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
||||||
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
|
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
|
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
|
||||||
`, erstellerUserID)
|
`, erstellerUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,7 +139,7 @@ func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string)
|
|||||||
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||||
// zuerst.
|
// zuerst.
|
||||||
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
|
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
|
||||||
`, accountID)
|
`, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type AuditEntry struct {
|
|||||||
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
||||||
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
||||||
var e AuditEntry
|
var e AuditEntry
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
||||||
@@ -37,7 +37,7 @@ func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targe
|
|||||||
|
|
||||||
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
||||||
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
||||||
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
||||||
`, limit)
|
`, limit)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertu
|
|||||||
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO bewertung (
|
INSERT INTO bewertung (
|
||||||
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||||
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
|
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
|
||||||
@@ -124,7 +124,7 @@ func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertu
|
|||||||
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
|
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
|
||||||
// existiert.
|
// existiert.
|
||||||
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
SELECT `+bewertungColumns+`
|
SELECT `+bewertungColumns+`
|
||||||
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
`, antragID)
|
`, antragID)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (E
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO entscheidung (
|
INSERT INTO entscheidung (
|
||||||
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
@@ -96,7 +96,7 @@ func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (E
|
|||||||
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
|
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
|
||||||
// Entscheidung existiert.
|
// Entscheidung existiert.
|
||||||
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
SELECT `+entscheidungColumns+`
|
SELECT `+entscheidungColumns+`
|
||||||
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
`, antragID)
|
`, antragID)
|
||||||
@@ -118,7 +118,7 @@ func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID str
|
|||||||
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
|
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
|
||||||
// zum Mandanten läuft über den zugehörigen antrag.
|
// zum Mandanten läuft über den zugehörigen antrag.
|
||||||
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
|
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
|
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
|
||||||
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
|
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
|
||||||
FROM entscheidung e
|
FROM entscheidung e
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type GenehmigerRolle struct {
|
|||||||
// Bedingung, das bleibt Sache von freigabe_regel.
|
// Bedingung, das bleibt Sache von freigabe_regel.
|
||||||
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
|
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
|
||||||
var g GenehmigerRolle
|
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)
|
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
|
||||||
RETURNING id, account_id, name, beschreibung, created_at
|
RETURNING id, account_id, name, beschreibung, created_at
|
||||||
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
`, 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.
|
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||||
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||||
var g GenehmigerRolle
|
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
|
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)
|
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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
|
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||||
// Mandanten.
|
// Mandanten.
|
||||||
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
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
|
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
|
||||||
WHERE account_id = $1 ORDER BY name
|
WHERE account_id = $1 ORDER BY name
|
||||||
`, accountID)
|
`, 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
|
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
|
||||||
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
||||||
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
|
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.
|
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||||
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
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)
|
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||||
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
||||||
`, userID, genehmigerRolleID)
|
`, userID, genehmigerRolleID)
|
||||||
@@ -106,7 +106,7 @@ func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmiger
|
|||||||
|
|
||||||
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||||
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
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
|
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
|
||||||
`, userID, genehmigerRolleID)
|
`, userID, genehmigerRolleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -118,7 +118,7 @@ func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmi
|
|||||||
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
||||||
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
||||||
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
|
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
|
SELECT gr.id, gr.account_id, gr.name, gr.created_at
|
||||||
FROM genehmiger_rolle gr
|
FROM genehmiger_rolle gr
|
||||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
|
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
|
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||||
// bestimmten Genehmiger-Rolle.
|
// bestimmten Genehmiger-Rolle.
|
||||||
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
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
|
SELECT u.id, u.email
|
||||||
FROM app_user u
|
FROM app_user u
|
||||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
|
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) {
|
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||||
var f FreigabeRegel
|
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)
|
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
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) {
|
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
|
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
||||||
`, accountID)
|
`, accountID)
|
||||||
@@ -224,7 +224,7 @@ func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("store: delete freigabe regel: %w", err)
|
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.
|
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
|
||||||
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
||||||
var f FreigabeRegel
|
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
|
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
FROM freigabe_regel WHERE id = $1
|
FROM freigabe_regel WHERE id = $1
|
||||||
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
|
`, 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) {
|
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||||
var f Freigabeschritt
|
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)
|
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
|
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
`, antragID, genehmigerRolleID).Scan(
|
`, 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) {
|
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||||
var f Freigabeschritt
|
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
|
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
FROM freigabeschritt WHERE id = $1
|
FROM freigabeschritt WHERE id = $1
|
||||||
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
|
`, 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) {
|
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
|
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
|
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
|
||||||
`, antragID)
|
`, antragID)
|
||||||
@@ -321,7 +321,7 @@ func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID stri
|
|||||||
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
||||||
// innehat — Grundlage für "Meine Freigaben".
|
// innehat — Grundlage für "Meine Freigaben".
|
||||||
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
|
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
|
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
|
FROM freigabeschritt f
|
||||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
|
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).
|
// generischer Fehler statt eines Sentinels).
|
||||||
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||||
var f Freigabeschritt
|
var f Freigabeschritt
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
UPDATE freigabeschritt
|
UPDATE freigabeschritt
|
||||||
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
||||||
WHERE id = $1 AND status = 'ausstehend'
|
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
|
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
|
||||||
// hängen zu bleiben.
|
// hängen zu bleiben.
|
||||||
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||||
_, err := s.Pool.Exec(ctx, `
|
_, err := s.db(ctx).Exec(ctx, `
|
||||||
UPDATE freigabeschritt
|
UPDATE freigabeschritt
|
||||||
SET status = 'abgelehnt', entschieden_am = now(),
|
SET status = 'abgelehnt', entschieden_am = now(),
|
||||||
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type LoeschfristEinstellung struct {
|
|||||||
// Datenklasse (UNIQUE-Constraint).
|
// Datenklasse (UNIQUE-Constraint).
|
||||||
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
|
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
|
||||||
var e LoeschfristEinstellung
|
var e LoeschfristEinstellung
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage)
|
INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (account_id, datenklasse_id)
|
ON CONFLICT (account_id, datenklasse_id)
|
||||||
@@ -45,7 +45,7 @@ func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, dat
|
|||||||
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
|
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
|
||||||
// Fristen eines Mandanten.
|
// Fristen eines Mandanten.
|
||||||
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
|
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT id, account_id, datenklasse_id, max_tage, updated_at
|
SELECT id, account_id, datenklasse_id, max_tage, updated_at
|
||||||
FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id
|
FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id
|
||||||
`, accountID)
|
`, accountID)
|
||||||
|
|||||||
40
internal/store/migrations/0021_row_level_security.down.sql
Normal file
40
internal/store/migrations/0021_row_level_security.down.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
ALTER TABLE antrag DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON antrag;
|
||||||
|
|
||||||
|
ALTER TABLE registereintrag DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON registereintrag;
|
||||||
|
|
||||||
|
ALTER TABLE abteilung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON abteilung;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug_sperre DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON werkzeug_sperre;
|
||||||
|
|
||||||
|
ALTER TABLE genehmiger_rolle DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON genehmiger_rolle;
|
||||||
|
|
||||||
|
ALTER TABLE freigabe_regel DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON freigabe_regel;
|
||||||
|
|
||||||
|
ALTER TABLE loeschfrist_einstellung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON loeschfrist_einstellung;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON werkzeug;
|
||||||
|
|
||||||
|
ALTER TABLE bewertung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON bewertung;
|
||||||
|
|
||||||
|
ALTER TABLE entscheidung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON entscheidung;
|
||||||
|
|
||||||
|
ALTER TABLE freigabeschritt DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON freigabeschritt;
|
||||||
|
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON nutzer_genehmiger_rolle;
|
||||||
|
|
||||||
|
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM deklarix_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM deklarix_app;
|
||||||
|
REVOKE USAGE ON SCHEMA public FROM deklarix_app;
|
||||||
|
DROP ROLE IF EXISTS deklarix_app;
|
||||||
164
internal/store/migrations/0021_row_level_security.up.sql
Normal file
164
internal/store/migrations/0021_row_level_security.up.sql
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
-- Mandantenisolation auf Datenbankebene (Postgres Row-Level Security).
|
||||||
|
-- WICHTIG: RLS-Policies wirken NIE bei Postgres-Superusern und NIE beim
|
||||||
|
-- Tabellenbesitzer ohne FORCE ROW LEVEL SECURITY — und FORCE wirkt
|
||||||
|
-- seinerseits NICHT bei Superusern (das ist eine harte, nicht
|
||||||
|
-- überschreibbare Postgres-Regel). Migrationen und die bisherige
|
||||||
|
-- Anwendungs-DATABASE_URL laufen als Superuser "postgres" (Tabellen-
|
||||||
|
-- besitzer) — für diese Rolle ist RLS wirkungslos, ganz gleich wie die
|
||||||
|
-- Policies aussehen. Deshalb legt diese Migration zusätzlich eine neue,
|
||||||
|
-- eingeschränkte Rolle "deklarix_app" an (kein Superuser, kein
|
||||||
|
-- Tabellenbesitzer, NOBYPASSRLS) — NUR für diese Rolle greifen die
|
||||||
|
-- Policies unten tatsächlich. Migrationen laufen weiterhin über die
|
||||||
|
-- bisherige privilegierte DATABASE_URL; die laufende Anwendung muss auf
|
||||||
|
-- die neue, eingeschränkte Rolle umgestellt werden (neue Umgebungs-
|
||||||
|
-- variable DATABASE_URL_APP, siehe cmd/deklarix/main.go) — ohne diesen
|
||||||
|
-- Wechsel ist diese Migration reine Dokumentation ohne Wirkung.
|
||||||
|
--
|
||||||
|
-- Die Rolle wird bewusst OHNE Passwort angelegt (NOLOGIN) — ein
|
||||||
|
-- Klartext-Passwort gehört nicht in eine versionierte, für jeden mit
|
||||||
|
-- Repo-Zugriff lesbare Migrationsdatei. Login-Fähigkeit + Passwort
|
||||||
|
-- werden einmalig manuell je Umgebung gesetzt:
|
||||||
|
-- ALTER ROLE deklarix_app WITH LOGIN PASSWORD '<generiertes Secret>';
|
||||||
|
-- Danach DATABASE_URL_APP in der jeweiligen deklarix.env eintragen.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
|
||||||
|
CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA public TO deklarix_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO deklarix_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO deklarix_app;
|
||||||
|
|
||||||
|
-- ─── Tabellen MIT direkter account_id-Spalte ───────────────────────────
|
||||||
|
-- Eine einzelne Policy (FOR ALL) pro Tabelle deckt SELECT/UPDATE/DELETE
|
||||||
|
-- (USING) und INSERT/UPDATE (WITH CHECK) ab. NULLIF(..., '')::uuid
|
||||||
|
-- verhindert einen harten Cast-Fehler, wenn app.account_id nie gesetzt
|
||||||
|
-- oder auf '' steht (z. B. vor der eigentlichen Anmeldung) — die
|
||||||
|
-- Bedingung wird dann einfach UNKNOWN/false statt eines SQL-Fehlers.
|
||||||
|
CREATE POLICY tenant_isolation ON antrag FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE antrag ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE registereintrag ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON abteilung FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE abteilung ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE werkzeug_sperre ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE genehmiger_rolle ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE freigabe_regel ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE loeschfrist_einstellung ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ─── werkzeug: account_id NULLABLE (NULL = zentraler Katalog) ──────────
|
||||||
|
-- Lesen: jeder sieht zentrale (NULL) Einträge plus die eigenen. NUR der
|
||||||
|
-- Betreiber darf einen zentralen (NULL) Eintrag anlegen/ändern, ein
|
||||||
|
-- Mandant nur seine eigenen — sonst könnte ein Mandant über einen
|
||||||
|
-- vergessenen Anwendungscheck einen zentralen Katalogeintrag verändern.
|
||||||
|
CREATE POLICY tenant_isolation ON werkzeug FOR ALL USING (
|
||||||
|
account_id IS NULL
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
);
|
||||||
|
ALTER TABLE werkzeug ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ─── Tabellen OHNE eigene account_id, über Fremdschlüssel abgeleitet ───
|
||||||
|
-- antrag/genehmiger_rolle sind selbst schon RLS-geschützt (s. o.) — eine
|
||||||
|
-- Unterabfrage gegen sie erbt in derselben Sitzung automatisch dieselbe
|
||||||
|
-- Mandantengrenze, ohne die Bedingung hier zu duplizieren.
|
||||||
|
CREATE POLICY tenant_isolation ON bewertung FOR ALL USING (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
) WITH CHECK (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
);
|
||||||
|
ALTER TABLE bewertung ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
) WITH CHECK (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
);
|
||||||
|
ALTER TABLE entscheidung ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
) WITH CHECK (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
);
|
||||||
|
ALTER TABLE freigabeschritt ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING (
|
||||||
|
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
|
||||||
|
) WITH CHECK (
|
||||||
|
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
|
||||||
|
);
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ─── Bewusst OHNE RLS ───────────────────────────────────────────────────
|
||||||
|
-- account: hat keine account_id-Spalte (ist selbst der Mandant) und
|
||||||
|
-- muss bei der Registrierung uneingeschränkt INSERT erlauben, bevor
|
||||||
|
-- die neue ID überhaupt bekannt ist.
|
||||||
|
-- app_user: Login/Passwort-Zurücksetzen suchen per E-Mail über ALLE
|
||||||
|
-- Mandanten hinweg (die Ziel-account_id ist zu diesem Zeitpunkt noch
|
||||||
|
-- nicht bekannt) — kein Datenleck, da E-Mail-Adressen exakt und nicht
|
||||||
|
-- in Bulk abgefragt werden, kein sequentiell erratbarer Schlüssel.
|
||||||
|
-- session, password_reset_token: werden ausschließlich über einen
|
||||||
|
-- kryptographisch zufälligen, praktisch unerratbaren Token gesucht,
|
||||||
|
-- nicht über eine sequentielle ID — dieselbe Begründung wie app_user.
|
||||||
|
-- audit_log: plattformweites Protokoll, wird ausschließlich vom
|
||||||
|
-- Betreiber (Ebene 5, sieht ohnehin alle Mandanten) gelesen, hat keine
|
||||||
|
-- eigene account_id-Spalte.
|
||||||
@@ -32,7 +32,7 @@ type PasswordResetToken struct {
|
|||||||
// sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst.
|
// sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst.
|
||||||
func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) {
|
func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) {
|
||||||
var t PasswordResetToken
|
var t PasswordResetToken
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO password_reset_token (user_id, token, expires_at)
|
INSERT INTO password_reset_token (user_id, token, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id, user_id, token, expires_at, used_at, created_at
|
RETURNING id, user_id, token, expires_at, used_at, created_at
|
||||||
@@ -49,7 +49,7 @@ func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token stri
|
|||||||
// noch nicht abgelaufen und noch nicht verwendet ist.
|
// noch nicht abgelaufen und noch nicht verwendet ist.
|
||||||
func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) {
|
func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) {
|
||||||
var t PasswordResetToken
|
var t PasswordResetToken
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
SELECT id, user_id, token, expires_at, used_at, created_at
|
SELECT id, user_id, token, expires_at, used_at, created_at
|
||||||
FROM password_reset_token
|
FROM password_reset_token
|
||||||
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
|
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
|
||||||
@@ -66,7 +66,7 @@ func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (P
|
|||||||
// MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe
|
// MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe
|
||||||
// Link kein zweites Mal ein Passwort setzen kann.
|
// Link kein zweites Mal ein Passwort setzen kann.
|
||||||
func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id)
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: mark password reset token used: %w", err)
|
return fmt.Errorf("store: mark password reset token used: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInp
|
|||||||
if in.Auflagen == nil {
|
if in.Auflagen == nil {
|
||||||
in.Auflagen = []string{}
|
in.Auflagen = []string{}
|
||||||
}
|
}
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO registereintrag (
|
INSERT INTO registereintrag (
|
||||||
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||||
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
|
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
|
||||||
@@ -83,7 +83,7 @@ func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInp
|
|||||||
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
|
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
|
||||||
// Mandanten, neueste zuerst.
|
// Mandanten, neueste zuerst.
|
||||||
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
|
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
|
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
|
||||||
`, accountID)
|
`, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func scanSession(row interface {
|
|||||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO session (token, user_id, expires_at)
|
INSERT INTO session (token, user_id, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING `+sessionColumns,
|
RETURNING `+sessionColumns,
|
||||||
@@ -55,7 +55,7 @@ func (s *Store) CreateSession(ctx context.Context, token, userID string, expires
|
|||||||
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
||||||
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
||||||
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING `+sessionColumns,
|
RETURNING `+sessionColumns,
|
||||||
@@ -74,7 +74,7 @@ func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, im
|
|||||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||||
sess, err := scanSession(row)
|
sess, err := scanSession(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Session{}, ErrNotFound
|
return Session{}, ErrNotFound
|
||||||
@@ -87,7 +87,7 @@ func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
|||||||
|
|
||||||
// DeleteSession beendet eine Sitzung (Logout).
|
// DeleteSession beendet eine Sitzung (Logout).
|
||||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||||
if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
if _, err := s.db(ctx).Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
||||||
return fmt.Errorf("store: delete session: %w", err)
|
return fmt.Errorf("store: delete session: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
104
internal/store/tenant_scope.go
Normal file
104
internal/store/tenant_scope.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
152
internal/store/tenant_scope_test.go
Normal file
152
internal/store/tenant_scope_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ func scanUser(row interface {
|
|||||||
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||||
// speichert nur, es hasht nicht selbst.
|
// speichert nur, es hasht nicht selbst.
|
||||||
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING `+userColumns,
|
RETURNING `+userColumns,
|
||||||
@@ -59,7 +59,7 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash,
|
|||||||
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||||
u, err := scanUser(row)
|
u, err := scanUser(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return User{}, ErrNotFound
|
return User{}, ErrNotFound
|
||||||
@@ -72,7 +72,7 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error)
|
|||||||
|
|
||||||
// GetUser liest einen Nutzer anhand seiner ID.
|
// GetUser liest einen Nutzer anhand seiner ID.
|
||||||
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||||
u, err := scanUser(row)
|
u, err := scanUser(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return User{}, ErrNotFound
|
return User{}, ErrNotFound
|
||||||
@@ -86,7 +86,7 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
|||||||
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
||||||
// Admin-Bereich (Account-Detailansicht).
|
// Admin-Bereich (Account-Detailansicht).
|
||||||
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
|
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||||
`, accountID)
|
`, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -112,7 +112,7 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
|||||||
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
|
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
|
||||||
// CreateUser.
|
// CreateUser.
|
||||||
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: set user password: %w", err)
|
return fmt.Errorf("store: set user password: %w", err)
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) er
|
|||||||
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||||
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
||||||
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
|
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: set user active: %w", err)
|
return fmt.Errorf("store: set user active: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
|||||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||||
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
||||||
normalizeWerkzeugSlices(&in)
|
normalizeWerkzeugSlices(&in)
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO werkzeug (
|
INSERT INTO werkzeug (
|
||||||
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||||
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||||
@@ -137,7 +137,7 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
|
|||||||
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
|
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
|
||||||
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
||||||
normalizeWerkzeugSlices(&in)
|
normalizeWerkzeugSlices(&in)
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
UPDATE werkzeug SET
|
UPDATE werkzeug SET
|
||||||
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
|
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||||
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
|
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
|
||||||
@@ -161,7 +161,7 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
|
|||||||
|
|
||||||
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
||||||
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
|
||||||
w, err := scanWerkzeug(row)
|
w, err := scanWerkzeug(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Werkzeug{}, ErrNotFound
|
return Werkzeug{}, ErrNotFound
|
||||||
@@ -174,7 +174,7 @@ func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
|||||||
|
|
||||||
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
||||||
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
|
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: delete werkzeug: %w", err)
|
return fmt.Errorf("store: delete werkzeug: %w", err)
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
|||||||
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
||||||
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
||||||
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
|
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+werkzeugColumns+` FROM werkzeug w
|
SELECT `+werkzeugColumns+` FROM werkzeug w
|
||||||
WHERE (w.account_id IS NULL AND NOT EXISTS (
|
WHERE (w.account_id IS NULL AND NOT EXISTS (
|
||||||
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
|
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
|
||||||
@@ -218,7 +218,7 @@ func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) (
|
|||||||
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
||||||
// Mandanten-Sperrungen.
|
// Mandanten-Sperrungen.
|
||||||
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
|
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -248,7 +248,7 @@ func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
|||||||
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||||
var count int
|
var count int
|
||||||
var lastUpdate time.Time
|
var lastUpdate time.Time
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
|
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
|
||||||
`).Scan(&count, &lastUpdate)
|
`).Scan(&count, &lastUpdate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -271,7 +271,7 @@ type WerkzeugSperre struct {
|
|||||||
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
||||||
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
||||||
var sp WerkzeugSperre
|
var sp WerkzeugSperre
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
||||||
@@ -284,7 +284,7 @@ func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID,
|
|||||||
|
|
||||||
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
||||||
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||||
tag, err := s.Pool.Exec(ctx, `
|
tag, err := s.db(ctx).Exec(ctx, `
|
||||||
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
|
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
|
||||||
`, accountID, werkzeugID)
|
`, accountID, werkzeugID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -298,7 +298,7 @@ func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID
|
|||||||
|
|
||||||
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
||||||
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
|
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
|
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
|
||||||
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
||||||
`, accountID)
|
`, accountID)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -59,29 +60,52 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
// Die account_id ist zu Beginn noch nicht bekannt (die Firma entsteht
|
||||||
|
// ja gerade erst) — WithTenantScope startet die RLS-Transaktion
|
||||||
|
// zunächst ohne Mandant, SetTenantScope trägt die neue ID nach,
|
||||||
|
// sobald CreateAccount sie liefert. Ohne das würden die anschließenden
|
||||||
|
// Schreibzugriffe auf genehmiger_rolle/loeschfrist_einstellung (beide
|
||||||
|
// RLS-geschützt) von der Policy abgelehnt.
|
||||||
|
var errMsg string
|
||||||
|
err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error {
|
||||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
errMsg = "Konto konnte nicht angelegt werden"
|
||||||
return
|
return err
|
||||||
|
}
|
||||||
|
if err := s.store.SetTenantScope(ctx, acc.ID, false); err != nil {
|
||||||
|
errMsg = "Interner Fehler"
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
|
errMsg = "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Genehmiger-Rollen konnten nicht angelegt werden"})
|
errMsg = "Genehmiger-Rollen konnten nicht angelegt werden"
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Löschfristen konnten nicht angelegt werden"})
|
errMsg = "Löschfristen konnten nicht angelegt werden"
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
|
// r.WithContext(ctx): startSession muss in DERSELBEN Transaktion
|
||||||
if err := s.startSession(w, r, user.ID); err != nil {
|
// laufen wie CreateUser darüber — sonst sieht die Session-Zeile
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
// (andere Verbindung über s.Pool, da r.Context() sonst ohne Tx
|
||||||
|
// wäre) den gerade erst angelegten, noch nicht committeten
|
||||||
|
// Nutzer nicht und die Fremdschlüsselprüfung schlägt fehl.
|
||||||
|
if err := s.startSession(w, r.WithContext(ctx), user.ID); err != nil {
|
||||||
|
errMsg = "Sitzung konnte nicht gestartet werden"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errMsg == "" {
|
||||||
|
errMsg = "Registrierung fehlgeschlagen"
|
||||||
|
}
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: errMsg})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ func withAuthContext(r *http.Request, user store.User, impersonator string) *htt
|
|||||||
return r.WithContext(ctx)
|
return r.WithContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withTenantScope führt next innerhalb einer Postgres-Transaktion aus,
|
||||||
|
// die die Sitzungsvariablen für Row-Level-Security setzt (siehe
|
||||||
|
// store.WithTenantScope) — jede require*-Middleware ruft das anstelle
|
||||||
|
// eines direkten next(w, r) auf, damit JEDE Store-Anfrage innerhalb
|
||||||
|
// dieses Requests automatisch auf den angemeldeten Mandanten begrenzt
|
||||||
|
// ist, nicht nur die, die ein Handler zufällig selbst noch mal prüft.
|
||||||
|
// Ein Fehler hier ist ein Infrastrukturproblem (z. B. DB down), kein
|
||||||
|
// normaler Handler-Fehlerpfad — 500 statt eines spezifischeren Codes.
|
||||||
|
func (s *Server) withTenantScope(w http.ResponseWriter, r *http.Request, user store.User, next http.HandlerFunc) {
|
||||||
|
err := s.store.WithTenantScope(r.Context(), user.AccountID, user.Role == "betreiber", func(ctx context.Context) error {
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Datenbankfehler: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||||
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||||
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||||
@@ -72,7 +90,7 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, withAuthContext(r, user, impersonator))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +105,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, withAuthContext(r, user, impersonator))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +126,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, withAuthContext(r, user, impersonator))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +156,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, withAuthContext(r, user, impersonator))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +174,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, withAuthContext(r, user, impersonator))
|
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ var staticFS embed.FS
|
|||||||
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
||||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
|
// WithTenantScope/SetTenantScope: siehe internal/store/tenant_scope.go
|
||||||
|
// (Row-Level-Security-Sitzungskontext). fakeStore implementiert beide
|
||||||
|
// als reinen Passthrough — Tests laufen ohne echtes Postgres/RLS,
|
||||||
|
// die Isolation wird dort weiterhin (wie bisher) auf Anwendungsebene
|
||||||
|
// geprüft; die tatsächliche RLS-Durchsetzung hat eigene Tests gegen
|
||||||
|
// echtes Postgres, siehe internal/store/tenant_scope_test.go.
|
||||||
|
WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error
|
||||||
|
SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error
|
||||||
|
|
||||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||||
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
|
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
|
||||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ func newFakeStore() *fakeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTenantScope/SetTenantScope sind im fakeStore reine Passthroughs —
|
||||||
|
// fakeStore hat kein RLS-Äquivalent, Tests prüfen Isolation weiterhin
|
||||||
|
// wie bisher auf Anwendungsebene (AccountID-Vergleich in den Handlern).
|
||||||
|
func (f *fakeStore) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error {
|
||||||
|
return fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) newID() string {
|
func (f *fakeStore) newID() string {
|
||||||
f.nextID++
|
f.nextID++
|
||||||
return fmt.Sprintf("id-%d", f.nextID)
|
return fmt.Sprintf("id-%d", f.nextID)
|
||||||
|
|||||||
Reference in New Issue
Block a user