Compare commits

...

3 Commits

Author SHA1 Message Date
noroot
fa5e68c892 refactor: E-Mail-Vorlagen als Liste + eigene Bearbeiten-Seite statt Formular-Stapel
Auf Nutzerfeedback: die erste Version zeigte alle bekannten Vorlagen als
gestapelte Formulare auf einer Seite. Jetzt wie Werkzeugkatalog/
Nutzerverwaltung: eine Liste (GET .../email-vorlagen) mit Klick auf eine
eigene Bearbeiten-Seite je Typ (GET/POST .../email-vorlagen/{typ}) -
skaliert sauber, wenn mit der Zeit weitere Benachrichtigungstypen
dazukommen, statt eine immer länger werdende Formular-Stapel-Seite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:10:46 +02:00
noroot
d3b171f721 feat: pro Mandant editierbare E-Mail-Vorlagen
Zweistufig wie der Werkzeugkatalog: Betreiber pflegt den plattformweiten
Standard (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
übersteuern (/verwaltung/email-vorlagen) - ResolveEmailVorlage nutzt die
eigene Vorlage falls vorhanden, sonst fällt sie auf den Plattform-
Standard zurück. Passwort-Zurücksetzen ist die einzige aktuell
existierende E-Mail und nutzt jetzt diese Vorlage statt Hardcoding.

Bug beim Live-Verifizieren gefunden: UNIQUE(account_id, typ) verhindert
bei NULLABLE account_id keine Duplikate (NULL != NULL in SQL) - jedes
Speichern des Plattform-Standards erzeugte eine neue Zeile statt sie zu
aktualisieren. Fix: zwei partielle Unique-Indizes statt eines
gemeinsamen Constraints, mit Regressionstest abgesichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 10:06:09 +02:00
noroot
b0d6b00045 fix: Migration 0021 crashte in Produktion — falsche Rollen-Annahme
v0.38.0 verursachte einen ~3-minütigen Ausfall: Migration 0021 nahm an,
die Anwendung verbinde überall als Postgres-Superuser "postgres" (nur
lokal via sudo geprüft, nicht die tatsächliche Produktions-DATABASE_URL)
und versuchte dort eine neue Rolle anzulegen - production verbindet
aber über die nicht-privilegierte, tabellenbesitzende Rolle "deklarix"
ohne CREATEROLE, das INSERT/CREATE ROLE schlug fehl und der Dienst
crash-loopte im "dirty migration"-Zustand. Kein Datenverlust (Postgres
hat die DDL-Transaktion selbst zurückgerollt), Dienst wurde auf v0.37.0
zurückgestuft und stabilisiert.

Fix: Rollen-Anlage ist jetzt an eine CREATEROLE-Prüfung gekoppelt und
wird bei fehlender Berechtigung übersprungen statt zu scheitern.
Zusätzlich FORCE ROW LEVEL SECURITY auf jeder Tabelle - das bindet auch
den Tabellenbesitzer (wie Productions "deklarix"), ganz ohne die
zusätzliche Rolle. Produktivbetrieb braucht dadurch jetzt gar keinen
manuellen Schritt mehr. Erneut end-to-end verifiziert, diesmal
zusätzlich produktionsgetreu simuliert (temporäre nicht-privilegierte,
tabellenbesitzende Rolle lokal).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 09:38:51 +02:00
16 changed files with 956 additions and 56 deletions

137
CLAUDE.md
View File

@@ -633,18 +633,70 @@ und live per curl gegen echten Server + Postgres verifiziert.
---
## E-Mail-Vorlagen (2026-09-01, Migration 0022)
Auf Nutzerwunsch: alle vom System versendeten E-Mails sollen editierbar
sein — für E-Mails, die an die eigenen Mitarbeiter einer Firma gehen,
durch den Mandanten selbst; für E-Mails, die die Plattform an
Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der
Werkzeugkatalog: `email_vorlage.account_id` NULL = plattformweiter
Standard (`/betreiber/email-vorlagen`, nur Betreiber), gesetzt =
mandantenspezifische Übersteuerung (`/verwaltung/email-vorlagen`, nur
Admin) — jeweils ein eigener Nav-Punkt (nicht als weiterer Button auf
einer bestehenden Seite), mit Liste + eigener Bearbeiten-Seite je Typ
(`GET .../email-vorlagen` Liste, `GET/POST .../email-vorlagen/{typ}`
Bearbeiten) statt eines einzigen, mit allen Formularen gestapelten
Screens — auf ausdrücklichen Nutzerwunsch, da mit der Zeit weitere
Benachrichtigungstypen dazukommen sollen und eine gestapelte Liste dann
unübersichtlich würde. `ResolveEmailVorlage` liefert die eigene
Übersteuerung, falls vorhanden, sonst den Plattform-Standard — ein
Mandant, der nie etwas anpasst, bekommt automatisch jede künftige
Änderung des Plattform-Standards. `emailVorlageTypen` in
`internal/web/email_vorlage_handlers.go` ist die feste, bekannte Liste
der vom System versendeten E-Mails — aktuell nur
`passwort_zuruecksetzen` (die einzige E-Mail, die es bisher gibt),
bewusst keine generische "beliebige E-Mail anlegen"-UI, da jeder Typ an
eine echte Code-Stelle gebunden ist, die ihn tatsächlich versendet
(Platzhalter wie `{{link}}` sind pro Typ verschieden und müssten sonst
geraten werden).
**Bug beim Live-Verifizieren gefunden und behoben:** `UNIQUE
(account_id, typ)` als einzelner Tabellen-Constraint reicht bei
NULLABLE `account_id` NICHT — SQL behandelt NULL nie als gleich zu
NULL, ein Mandant/Betreiber hätte also bei jedem Speichern eine neue
Plattform-Standard-Zeile statt eines Updates bekommen (genau das ist
beim ersten Testlauf passiert: zwei "Version 1"/"Version 2"-Zeilen
gleichzeitig, `ResolveEmailVorlage` griff die falsche). Fix: zwei
partielle Unique-Indizes (`... WHERE account_id IS NULL` /
`... WHERE account_id IS NOT NULL`) statt eines gemeinsamen Constraints
`UpsertEmailVorlage` braucht dafür zwei unterschiedliche
`ON CONFLICT`-Ziele (SQL erlaubt nur ein Ziel je INSERT-Anweisung), mit
dediziertem Regressionstest (`TestUpsertPlattformStandardAktualisiertStattZuDuplizieren`)
abgesichert. Live per curl mit echtem SMTP-Test-Server erneut
verifiziert: zweimaliges Speichern des Plattform-Standards ergibt eine
Zeile mit dem aktuellen Stand, eine neu registrierte Firma ohne eigene
Übersteuerung bekommt automatisch den zuletzt gesetzten Plattform-Text.
---
## 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.
**Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und
nie beim Tabellenbesitzer ohne `FORCE ROW LEVEL SECURITY` (beides harte,
nicht überschreibbare Postgres-Regeln). Lokal verbindet die Anwendung
als echter Superuser `postgres` (Docker-Testumgebung) — dort hätte
`FORCE` allein nichts bewirkt. Auf dem Produktivserver verbindet sie
dagegen über eine eigene, **nicht-privilegierte** Rolle `deklarix`
(kein Superuser), die zugleich Eigentümerin der Tabellen ist — dort
reicht `FORCE` aus. **Diese Erkenntnis kam erst nach einem
Fehlversuch** (siehe "Incident" weiter unten) — die ursprüngliche
Annahme, auch Produktion verbinde als Superuser, war falsch und beruhte
auf einer Prüfung der falschen Rolle. Migration 0021 deckt seit der
Korrektur beide Fälle ab: `FORCE ROW LEVEL SECURITY` auf jeder Tabelle
(reicht für Produktion), plus optional eine neue, eingeschränkte Rolle
**`deklarix_app`** (kein Superuser, kein Tabellenbesitzer,
`NOBYPASSRLS`, zunächst `NOLOGIN`) für Umgebungen mit einer
Superuser-Verbindung wie lokal.
**Architektur:** `internal/store/tenant_scope.go``Store.db(ctx)`
liefert entweder die aktive Transaktion (falls `WithTenantScope` sie
@@ -668,7 +720,7 @@ 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
**Geschützte Tabellen** (`ENABLE` + `FORCE ROW LEVEL SECURITY` + Policy
`tenant_isolation`): `antrag`, `registereintrag`, `abteilung`,
`werkzeug_sperre`, `genehmiger_rolle`, `freigabe_regel`,
`loeschfrist_einstellung` (direkte `account_id`-Prüfung), `werkzeug`
@@ -710,19 +762,56 @@ 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.
**Incident 2026-09-01 (~3 Minuten Downtime) und Korrektur.** Die erste
Fassung der Migration ging fälschlich davon aus, dass die Anwendung
überall als Postgres-**Superuser** `postgres` verbindet (das hatte ich
nur lokal und via `sudo -u postgres psql` auf dem Server geprüft — das
ist aber ein SSH/OS-Login-Check, nicht die tatsächliche
`DATABASE_URL`-Rolle der Anwendung). Tatsächlich verbindet Produktion
über eine eigene, **nicht-privilegierte** Rolle `deklarix` (kein
Superuser, kein `CREATEROLE`), die zugleich Eigentümerin aller Tabellen
ist. Die Migration versuchte dort `CREATE ROLE deklarix_app`
auszuführen, scheiterte mit "permission denied to create role", blieb
im `dirty`-Zustand hängen und der Dienst crash-loopte beim Start
(09:2709:30 Uhr). Behoben durch: `schema_migrations` manuell auf
Version 20 zurückgesetzt, Paket auf v0.37.0 zurückgestuft, Dienst
stabilisiert — kein Datenverlust, Postgres hatte die fehlgeschlagene
Migration als DDL-Transaktion bereits sauber selbst zurückgerollt, nur
golang-migrates eigene Versions-Buchführung musste von Hand korrigiert
werden.
**Korrigierte, robustere Migration:** das Anlegen von `deklarix_app` ist
jetzt an eine Prüfung gekoppelt (`SELECT ... WHERE rolname = current_user
AND rolcreaterole`) und wird bei fehlendem `CREATEROLE` übersprungen
(`RAISE NOTICE`, kein Fehler) statt die ganze Migration scheitern zu
lassen. Zusätzlich bekommt jede Tabelle jetzt **`FORCE ROW LEVEL
SECURITY`** (vorher nur `ENABLE`) — das bindet auch den **Tabellen-
besitzer** an die Policies, sofern er kein Superuser ist. Damit deckt
eine einzige Migration beide Fälle ab:
- **Produktion** (`deklarix`, Tabellenbesitzer, kein Superuser): `FORCE`
allein reicht bereits aus. **Kein manueller Schritt nötig** — nach dem
Deploy dieser Migration ist RLS dort sofort aktiv.
- **Lokales Docker-Postgres** (`postgres`-Superuser, für den `FORCE`
wirkungslos bleibt): `deklarix_app` wird zusätzlich angelegt, für
lokale Tests weiterhin per `DATABASE_URL_APP` nutzbar.
**Erneut end-to-end verifiziert nach der Korrektur**, diesmal
zusätzlich produktionsgetreu: eine zweite, temporäre lokale Rolle
(`deklarix_sim`, kein Superuser, kein `CREATEROLE`, Eigentümerin einer
frischen Test-Datenbank — exakt Produktions-Rechtemodell) durchlief die
komplette Migrationskette 00010021 fehlerfrei, **und** die Isolation
griff nachweislich auch für sie als Tabellenbesitzerin (Kontext A sah
ausschließlich Zeile A, trotz voller Tabellen-Eigentümerschaft). Vorher
war nur der `deklarix_app`-Pfad (Nicht-Eigentümer-Rolle) bewiesen, nicht
der tatsächliche Produktions-Pfad (Eigentümer-Rolle + FORCE) — genau die
Lücke, die den Incident verursachte.
**Lehre für künftige Prüfungen dieser Art:** "welche DB-Rolle verwendet
die Anwendung" per `sudo -u postgres psql` zu beantworten prüft die
falsche Sache — maßgeblich ist ausschließlich die Rolle **in der
tatsächlichen `DATABASE_URL`** (hier: `cat /etc/deklarix/deklarix.env`
bzw. `SELECT rolname, rolsuper, rolcreaterole FROM pg_roles WHERE
rolname = 'deklarix'`, nicht `current_user` über einen andere Anmeldung).
---

View File

@@ -0,0 +1,116 @@
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type EmailVorlage struct {
ID string
AccountID *string
Typ string
Betreff string
Text string
UpdatedAt time.Time
}
// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
// accountID nil schreibt den plattformweiten Standard (nur für den
// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
//
// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
// Standards eine neue Zeile statt die bestehende zu aktualisieren).
func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
var v EmailVorlage
var err error
if accountID == nil {
err = s.db(ctx).QueryRow(ctx, `
INSERT INTO email_vorlage (account_id, typ, betreff, text)
VALUES (NULL, $1, $2, $3)
ON CONFLICT (typ) WHERE account_id IS NULL
DO UPDATE SET betreff = $2, text = $3, updated_at = now()
RETURNING id, account_id, typ, betreff, text, updated_at
`, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
} else {
err = s.db(ctx).QueryRow(ctx, `
INSERT INTO email_vorlage (account_id, typ, betreff, text)
VALUES ($1, $2, $3, $4)
ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
DO UPDATE SET betreff = $3, text = $4, updated_at = now()
RETURNING id, account_id, typ, betreff, text, updated_at
`, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
}
return v, nil
}
// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
// accountID nil sucht den plattformweiten Standard.
func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
var v EmailVorlage
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return EmailVorlage{}, ErrNotFound
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
}
return v, nil
}
// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
// Plattform-Standard wird per Migration angelegt).
func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
var v EmailVorlage
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
ORDER BY account_id NULLS LAST
LIMIT 1
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return EmailVorlage{}, ErrNotFound
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
}
return v, nil
}
// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
// wieder auf den plattformweiten Standard zurück), beim Betreiber
// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
tag, err := s.db(ctx).Exec(ctx, `
DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
`, accountID, typ)
if err != nil {
return fmt.Errorf("store: delete email vorlage: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,117 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestEmailVorlagePlattformStandardExistiertNachMigration(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
v, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("GetEmailVorlage (Plattform): %v", err)
}
if v.AccountID != nil {
t.Fatalf("AccountID = %v, want nil (Plattform-Standard)", v.AccountID)
}
if v.Betreff == "" || v.Text == "" {
t.Fatalf("Plattform-Standard unvollständig: %+v", v)
}
}
func TestResolveEmailVorlageFaelltAufPlattformStandardZurueck(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
// Ohne eigene Übersteuerung liefert Resolve den Plattform-Standard.
resolved, err := s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (kein eigener Override): %v", err)
}
if resolved.AccountID != nil {
t.Fatalf("AccountID = %v, want nil (geerbt vom Plattform-Standard)", resolved.AccountID)
}
// Eigene Übersteuerung anlegen — Resolve muss jetzt die eigene liefern.
eigene, err := s.UpsertEmailVorlage(ctx, &accID, "passwort_zuruecksetzen", "Eigener Betreff", "Eigener Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage: %v", err)
}
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (mit Override): %v", err)
}
if resolved.ID != eigene.ID || resolved.Betreff != "Eigener Betreff" {
t.Fatalf("resolved = %+v, want eigene Vorlage", resolved)
}
// Zurücksetzen entfernt die Übersteuerung, Resolve fällt wieder zurück.
if err := s.DeleteEmailVorlage(ctx, &accID, "passwort_zuruecksetzen"); err != nil {
t.Fatalf("DeleteEmailVorlage: %v", err)
}
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (nach Reset): %v", err)
}
if resolved.AccountID != nil {
t.Fatalf("AccountID nach Reset = %v, want nil (wieder Plattform-Standard)", resolved.AccountID)
}
}
func TestUpsertPlattformStandardAktualisiertStattZuDuplizieren(t *testing.T) {
// Regressionstest: SQL behandelt NULL nie als gleich zu NULL, ein
// naiver UNIQUE(account_id, typ)-Constraint hätte beliebig viele
// Plattform-Standard-Zeilen je typ zugelassen — jedes erneute
// Speichern hätte eine neue Zeile erzeugt statt die bestehende zu
// aktualisieren (genau das ist beim Live-Verifizieren passiert).
s := openTestStore(t)
ctx := context.Background()
first, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Erster Betreff", "Erster Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage (1): %v", err)
}
second, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Zweiter Betreff", "Zweiter Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage (2): %v", err)
}
if first.ID != second.ID {
t.Fatalf("zweites Upsert erzeugte eine neue Zeile (ID %s statt %s) statt zu aktualisieren", second.ID, first.ID)
}
got, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("GetEmailVorlage: %v", err)
}
if got.Betreff != "Zweiter Betreff" {
t.Fatalf("Betreff = %q, want %q (aktueller Stand)", got.Betreff, "Zweiter Betreff")
}
}
func TestEmailVorlageZweierMandantenIsoliert(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
accB := testAccountID(t, s)
if _, err := s.UpsertEmailVorlage(ctx, &accA, "passwort_zuruecksetzen", "Vorlage A", "Text A {{link}}"); err != nil {
t.Fatalf("UpsertEmailVorlage A: %v", err)
}
resolvedB, err := s.ResolveEmailVorlage(ctx, accB, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage B: %v", err)
}
if resolvedB.Betreff == "Vorlage A" {
t.Fatal("Mandant B sieht die Vorlage von Mandant A — Isolation defekt")
}
if _, err := s.GetEmailVorlage(ctx, &accB, "passwort_zuruecksetzen"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("GetEmailVorlage B (kein eigener Override) err=%v, want ErrNotFound", err)
}
}

View File

@@ -1,40 +1,58 @@
ALTER TABLE antrag NO FORCE ROW LEVEL SECURITY;
ALTER TABLE antrag DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON antrag;
ALTER TABLE registereintrag NO FORCE ROW LEVEL SECURITY;
ALTER TABLE registereintrag DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON registereintrag;
ALTER TABLE abteilung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE abteilung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON abteilung;
ALTER TABLE werkzeug_sperre NO FORCE ROW LEVEL SECURITY;
ALTER TABLE werkzeug_sperre DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON werkzeug_sperre;
ALTER TABLE genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
ALTER TABLE genehmiger_rolle DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON genehmiger_rolle;
ALTER TABLE freigabe_regel NO FORCE ROW LEVEL SECURITY;
ALTER TABLE freigabe_regel DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON freigabe_regel;
ALTER TABLE loeschfrist_einstellung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE loeschfrist_einstellung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON loeschfrist_einstellung;
ALTER TABLE werkzeug NO FORCE ROW LEVEL SECURITY;
ALTER TABLE werkzeug DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON werkzeug;
ALTER TABLE bewertung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE bewertung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON bewertung;
ALTER TABLE entscheidung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE entscheidung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON entscheidung;
ALTER TABLE freigabeschritt NO FORCE ROW LEVEL SECURITY;
ALTER TABLE freigabeschritt DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON freigabeschritt;
ALTER TABLE nutzer_genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
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;
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
EXECUTE 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM deklarix_app';
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM deklarix_app';
EXECUTE 'REVOKE USAGE ON SCHEMA public FROM deklarix_app';
DROP ROLE deklarix_app;
END IF;
END
$$;

View File

@@ -1,36 +1,47 @@
-- 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.
-- WICHTIG, per Incident am 2026-09-01 gelernt: RLS-Policies wirken NIE
-- bei Postgres-Superusern, und NIE beim Tabellenbesitzer ohne FORCE ROW
-- LEVEL SECURITY. Welcher Fall zutrifft, hängt von der Umgebung ab:
-- - Produktion verbindet als eigene, nicht-privilegierte Rolle (z. B.
-- "deklarix"), die zugleich Eigentümerin der Tabellen ist (sie hat
-- sie über die Migrationen selbst angelegt) — für sie reicht FORCE
-- ROW LEVEL SECURITY völlig aus, keine weitere Rolle nötig.
-- - Manche Entwicklungs-/Testumgebungen verbinden dagegen als
-- echter Postgres-Superuser (z. B. lokales Docker-Postgres mit
-- "postgres") — für den wirkt FORCE nicht (Superuser sind davon
-- laut Postgres-Dokumentation ausdrücklich ausgenommen). Dort kann
-- zusätzlich eine eingeschränkte Rolle "deklarix_app" angelegt
-- werden, für die die Policies unabhängig von FORCE gelten.
--
-- Diese Migration deckt BEIDE Fälle ab, ohne bei fehlendem CREATEROLE
-- fehlzuschlagen (das brachte den Dienst am 2026-09-01 für ~3 Minuten
-- zum Absturz, siehe CLAUDE.md) — das Anlegen von "deklarix_app" ist
-- rein optional und wird übersprungen, wenn die aktuelle Rolle dafür
-- keine Berechtigung hat.
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN;
IF EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND rolcreaterole) THEN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN;
END IF;
EXECUTE 'GRANT USAGE ON SCHEMA public TO deklarix_app';
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO deklarix_app';
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO deklarix_app';
ELSE
RAISE NOTICE 'Rolle % hat kein CREATEROLE — deklarix_app wird übersprungen, FORCE ROW LEVEL SECURITY schützt stattdessen direkt die bestehende (Tabellenbesitzer-)Rolle.', current_user;
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;
-- Login-Fähigkeit + Passwort für deklarix_app (falls angelegt) werden
-- einmalig manuell je Umgebung gesetzt, NIE in einer versionierten
-- Migration (Klartext-Secret gehört nicht ins Repo):
-- ALTER ROLE deklarix_app WITH LOGIN PASSWORD '<generiertes Secret>';
-- Danach optional DATABASE_URL_APP in der jeweiligen deklarix.env
-- eintragen. Für Umgebungen, in denen die Anwendung bereits als
-- Tabellenbesitzer (nicht-Superuser) verbindet, ist das NICHT nötig —
-- FORCE ROW LEVEL SECURITY unten reicht dort aus.
-- ─── Tabellen MIT direkter account_id-Spalte ───────────────────────────
-- Eine einzelne Policy (FOR ALL) pro Tabelle deckt SELECT/UPDATE/DELETE
@@ -46,6 +57,7 @@ CREATE POLICY tenant_isolation ON antrag FOR ALL USING (
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE antrag ENABLE ROW LEVEL SECURITY;
ALTER TABLE antrag FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
@@ -55,6 +67,7 @@ CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING (
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE registereintrag ENABLE ROW LEVEL SECURITY;
ALTER TABLE registereintrag FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON abteilung FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
@@ -64,6 +77,7 @@ CREATE POLICY tenant_isolation ON abteilung FOR ALL USING (
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE abteilung ENABLE ROW LEVEL SECURITY;
ALTER TABLE abteilung FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
@@ -73,6 +87,7 @@ CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING (
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE werkzeug_sperre ENABLE ROW LEVEL SECURITY;
ALTER TABLE werkzeug_sperre FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
@@ -82,6 +97,7 @@ CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING (
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE genehmiger_rolle ENABLE ROW LEVEL SECURITY;
ALTER TABLE genehmiger_rolle FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
@@ -91,6 +107,7 @@ CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING (
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE freigabe_regel ENABLE ROW LEVEL SECURITY;
ALTER TABLE freigabe_regel FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
@@ -100,6 +117,7 @@ CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING (
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE loeschfrist_einstellung ENABLE ROW LEVEL SECURITY;
ALTER TABLE loeschfrist_einstellung FORCE ROW LEVEL SECURITY;
-- ─── werkzeug: account_id NULLABLE (NULL = zentraler Katalog) ──────────
-- Lesen: jeder sieht zentrale (NULL) Einträge plus die eigenen. NUR der
@@ -115,6 +133,7 @@ CREATE POLICY tenant_isolation ON werkzeug FOR ALL USING (
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
);
ALTER TABLE werkzeug ENABLE ROW LEVEL SECURITY;
ALTER TABLE werkzeug FORCE ROW LEVEL SECURITY;
-- ─── Tabellen OHNE eigene account_id, über Fremdschlüssel abgeleitet ───
-- antrag/genehmiger_rolle sind selbst schon RLS-geschützt (s. o.) — eine
@@ -126,6 +145,7 @@ CREATE POLICY tenant_isolation ON bewertung FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
);
ALTER TABLE bewertung ENABLE ROW LEVEL SECURITY;
ALTER TABLE bewertung FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
@@ -133,6 +153,7 @@ CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
);
ALTER TABLE entscheidung ENABLE ROW LEVEL SECURITY;
ALTER TABLE entscheidung FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
@@ -140,6 +161,7 @@ CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
);
ALTER TABLE freigabeschritt ENABLE ROW LEVEL SECURITY;
ALTER TABLE freigabeschritt FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING (
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
@@ -147,6 +169,7 @@ CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING (
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
);
ALTER TABLE nutzer_genehmiger_rolle ENABLE ROW LEVEL SECURITY;
ALTER TABLE nutzer_genehmiger_rolle FORCE ROW LEVEL SECURITY;
-- ─── Bewusst OHNE RLS ───────────────────────────────────────────────────
-- account: hat keine account_id-Spalte (ist selbst der Mandant) und

View File

@@ -0,0 +1,4 @@
ALTER TABLE email_vorlage NO FORCE ROW LEVEL SECURITY;
ALTER TABLE email_vorlage DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON email_vorlage;
DROP TABLE email_vorlage;

View File

@@ -0,0 +1,55 @@
-- Editierbare E-Mail-Vorlagen, zweistufig wie der Werkzeugkatalog:
-- account_id NULL = plattformweiter Standard (nur vom Betreiber
-- editierbar, z. B. künftige E-Mails, die die Plattform selbst an
-- Mandanten-Admins schickt), account_id gesetzt = mandantenspezifische
-- Übersteuerung (vom Mandanten-Admin editierbar, z. B. eigener Ton/
-- Branding für eine E-Mail, die an die eigenen Mitarbeiter geht).
-- "typ" identifiziert, welche vom System versendete E-Mail gemeint ist
-- (aktuell nur "passwort_zuruecksetzen" — die einzige E-Mail, die das
-- System bisher verschickt, siehe internal/mail). Text darf Platzhalter
-- wie "{{link}}" enthalten, die beim Versand ersetzt werden (siehe
-- internal/web/email_vorlage_handlers.go).
CREATE TABLE email_vorlage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES account(id),
typ TEXT NOT NULL,
betreff TEXT NOT NULL,
text TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ACHTUNG: ein einfaches UNIQUE (account_id, typ) würde NICHT reichen —
-- SQL behandelt NULL nie als gleich zu NULL, ein normaler UNIQUE-
-- Constraint hätte also beliebig viele Plattform-Standard-Zeilen
-- (account_id IS NULL) je typ zugelassen. Zwei partielle Unique-Indizes
-- statt eines gemeinsamen Constraints, dafür braucht UpsertEmailVorlage
-- zwei unterschiedliche ON-CONFLICT-Ziele (siehe internal/store/email_vorlage.go).
CREATE UNIQUE INDEX email_vorlage_plattform_uidx ON email_vorlage (typ) WHERE account_id IS NULL;
CREATE UNIQUE INDEX email_vorlage_mandant_uidx ON email_vorlage (account_id, typ) WHERE account_id IS NOT NULL;
-- Plattformweiter Standard für die einzige aktuell existierende
-- E-Mail — ohne diese Zeile gäbe es nichts, worauf ResolveEmailVorlage
-- zurückfallen könnte, solange ein Mandant keine eigene Vorlage hat.
INSERT INTO email_vorlage (account_id, typ, betreff, text) VALUES (
NULL,
'passwort_zuruecksetzen',
'Deklarix — Passwort zurücksetzen',
'Hallo,' || E'\n\n' ||
'über diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):' || E'\n' ||
'{{link}}' || E'\n\n' ||
'Falls du das nicht angefordert hast, ignoriere diese E-Mail.'
);
-- Wie werkzeug (account_id NULLABLE): NULL-Zeilen sind für alle lesbar,
-- aber nur vom Betreiber schreibbar; ein Mandant darf nur seine eigene
-- account_id-Zeile anlegen/ändern.
CREATE POLICY tenant_isolation ON email_vorlage 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 email_vorlage ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_vorlage FORCE ROW LEVEL SECURITY;

View File

@@ -0,0 +1,207 @@
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
// der Betreiber pflegt den plattformweiten Standard
// (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
// übersteuern (/verwaltung/email-vorlagen). emailVorlageTypen ist die
// feste, bekannte Liste der vom System versendeten E-Mails — aktuell
// nur Passwort-Zurücksetzen, die einzige E-Mail, die es bisher gibt,
// aber bewusst als Liste-mit-Bearbeiten-Seite gebaut (wie Werkzeugkatalog/
// Nutzerverwaltung), nicht als eine Seite mit allen Formularen gestapelt
// — mit der Zeit kommen weitere Benachrichtigungstypen dazu.
package web
import (
"errors"
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/store"
)
type emailVorlageTypInfo struct {
Typ string
Label string
Platzhalter string
}
var emailVorlageTypen = []emailVorlageTypInfo{
{
Typ: "passwort_zuruecksetzen",
Label: "Passwort zurücksetzen",
Platzhalter: "{{link}} — der Zurücksetzen-Link, wird beim Versand eingesetzt.",
},
}
func findEmailVorlageTyp(typ string) (emailVorlageTypInfo, bool) {
for _, t := range emailVorlageTypen {
if t.Typ == typ {
return t, true
}
}
return emailVorlageTypInfo{}, false
}
// renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer
// Vorlage — aktuell nur {{link}} (Passwort-Zurücksetzen-Link).
func renderEmailVorlage(text, link string) string {
return strings.ReplaceAll(text, "{{link}}", link)
}
// ─── Mandant (Ebene 4, admin-only): eigene Übersteuerung ───────────────
type emailVorlageZeile struct {
Typ string
Label string
IstUebersteuert bool // eigene Vorlage vorhanden statt Plattform-Standard geerbt
}
type emailVorlagenListeData struct {
Title string
Nav navData
Zeilen []emailVorlageZeile
ActionBase string // unterscheidet Mandant- von Betreiber-Ziel-URL im gemeinsamen Template
}
func (s *Server) handleEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen", Nav: navFor(r), ActionBase: "/verwaltung/email-vorlagen"}
for _, t := range emailVorlageTypen {
_, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ)
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label, IstUebersteuert: err == nil})
}
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type emailVorlageBearbeitenData struct {
Title string
Nav navData
Typ string
Label string
Platzhalter string
Betreff string
Text string
IstUebersteuert bool
ActionBase string
Error string
Gespeichert bool
}
func (s *Server) handleEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
info, ok := findEmailVorlageTyp(typ)
if !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
accountID := currentUser(r).AccountID
resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, typ)
if err != nil {
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
_, uebersteuert := s.store.GetEmailVorlage(r.Context(), &accountID, typ)
data := emailVorlageBearbeitenData{
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
Betreff: resolved.Betreff, Text: resolved.Text, IstUebersteuert: uebersteuert == nil,
ActionBase: "/verwaltung/email-vorlagen", Gespeichert: r.URL.Query().Get("gespeichert") == "1",
}
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
if _, ok := findEmailVorlageTyp(typ); !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountID := currentUser(r).AccountID
betreff := strings.TrimSpace(r.FormValue("betreff"))
text := strings.TrimSpace(r.FormValue("text"))
if betreff == "" || text == "" {
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
return
}
if _, err := s.store.UpsertEmailVorlage(r.Context(), &accountID, typ, betreff, text); err != nil {
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
}
func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
if _, ok := findEmailVorlageTyp(typ); !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
accountID := currentUser(r).AccountID
if err := s.store.DeleteEmailVorlage(r.Context(), &accountID, typ); err != nil && !errors.Is(err, store.ErrNotFound) {
http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
}
// ─── Betreiber (Ebene 5): plattformweiter Standard ─────────────────────
func (s *Server) handleBetreiberEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r), ActionBase: "/betreiber/email-vorlagen"}
for _, t := range emailVorlageTypen {
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label})
}
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleBetreiberEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
info, ok := findEmailVorlageTyp(typ)
if !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
v, err := s.store.GetEmailVorlage(r.Context(), nil, typ)
if err != nil {
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := emailVorlageBearbeitenData{
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
Betreff: v.Betreff, Text: v.Text, ActionBase: "/betreiber/email-vorlagen",
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
}
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
if _, ok := findEmailVorlageTyp(typ); !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
betreff := strings.TrimSpace(r.FormValue("betreff"))
text := strings.TrimSpace(r.FormValue("text"))
if betreff == "" || text == "" {
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
return
}
if _, err := s.store.UpsertEmailVorlage(r.Context(), nil, typ, betreff, text); err != nil {
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/betreiber/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
}

View File

@@ -0,0 +1,113 @@
package web_test
import (
"net/http"
"net/url"
"regexp"
"strings"
"testing"
)
func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
listResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
if listResp.Code != http.StatusOK {
t.Fatalf("Liste-Status = %d, want 200", listResp.Code)
}
if !strings.Contains(listResp.Body.String(), "/verwaltung/email-vorlagen/passwort_zuruecksetzen") {
t.Fatalf("erwartet Link zur Bearbeiten-Seite, body: %s", listResp.Body.String())
}
getResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
if getResp.Code != http.StatusOK {
t.Fatalf("GET Bearbeiten-Seite status = %d, want 200", getResp.Code)
}
if !strings.Contains(getResp.Body.String(), "Plattform-Standard, nicht angepasst") {
t.Fatalf("erwartet Hinweis auf geerbten Plattform-Standard, body: %s", getResp.Body.String())
}
saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
"betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"},
})
if saveResp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
}
afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
body := afterResp.Body.String()
if !strings.Contains(body, "Mein eigener Betreff") {
t.Fatalf("gespeicherter Betreff fehlt, body: %s", body)
}
if !strings.Contains(body, "(eigene Vorlage)") {
t.Fatalf("erwartet Hinweis auf eigene Vorlage, body: %s", body)
}
resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen/zuruecksetzen", url.Values{})
if resetResp.Code != http.StatusSeeOther {
t.Fatalf("reset status = %d, want 303", resetResp.Code)
}
finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
if strings.Contains(finalResp.Body.String(), "Mein eigener Betreff") {
t.Fatal("nach Zurücksetzen ist die eigene Vorlage immer noch aktiv")
}
}
func TestBetreiberKannPlattformStandardBearbeiten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Betreiber-Firma", "betreiber@example.com", "betreiber")
saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen", url.Values{
"betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"},
})
if saveResp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
}
getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen")
if !strings.Contains(getResp.Body.String(), "Neuer Plattform-Betreff") {
t.Fatalf("neuer Plattform-Standard fehlt, body: %s", getResp.Body.String())
}
}
func TestNichtAdminKannEmailVorlagenNichtBearbeiten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.Code)
}
}
func TestPasswortVergessenNutztMandantenspezifischeVorlage(t *testing.T) {
fs := newFakeStore()
s, fm := newServerWithMailer(t, fs)
cookie := seedAccountWithRole(t, fs, "Vorlage-Firma", "vorlage-admin@example.com", "admin")
if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
"betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."},
}); resp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303", resp.Code)
}
if resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"vorlage-admin@example.com"}}); resp.Code != http.StatusOK {
t.Fatalf("passwort-vergessen status = %d, want 200", resp.Code)
}
if len(fm.Sent) != 1 {
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
}
if fm.Sent[0].Subject != "Firmenspezifischer Betreff" {
t.Fatalf("Subject = %q, want die firmenspezifische Vorlage", fm.Sent[0].Subject)
}
if !strings.Contains(fm.Sent[0].Body, "Hallo! Link: ") || strings.Contains(fm.Sent[0].Body, "{{link}}") {
t.Fatalf("Body = %q, want Platzhalter ersetzt und Rest der eigenen Vorlage erhalten", fm.Sent[0].Body)
}
if !regexp.MustCompile(`/passwort-zuruecksetzen/[a-f0-9]+`).MatchString(fm.Sent[0].Body) {
t.Fatalf("kein Reset-Link im Body gefunden: %q", fm.Sent[0].Body)
}
}

View File

@@ -36,13 +36,16 @@ func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request)
scheme = "https"
}
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
body := "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n" + link + "\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
betreff, text := "Deklarix — Passwort zurücksetzen", "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
if vorlage, err := s.store.ResolveEmailVorlage(r.Context(), user.AccountID, "passwort_zuruecksetzen"); err == nil {
betreff, text = vorlage.Betreff, vorlage.Text
}
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
// (keine Enumeration über unterschiedliches Verhalten bei
// Versandfehlern), aber serverseitig geloggt statt
// verschluckt — sonst bleibt ein dauerhaft falsch
// konfigurierter SMTP-Server unbemerkt.
if err := s.mailer.Send(user.Email, "Deklarix — Passwort zurücksetzen", body); err != nil {
if err := s.mailer.Send(user.Email, betreff, renderEmailVorlage(text, link)); err != nil {
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
}
}

View File

@@ -52,6 +52,10 @@ type Store interface {
SetUserPassword(ctx context.Context, id, passwordHash string) error
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error)
UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error)
GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error)
ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error)
DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
MarkPasswordResetTokenUsed(ctx context.Context, id string) error
@@ -162,6 +166,13 @@ func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, erro
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
mux.HandleFunc("GET /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlagenListe))
mux.HandleFunc("GET /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageBearbeitenForm))
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageSpeichern))
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen))
mux.HandleFunc("GET /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlagenListe))
mux.HandleFunc("GET /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageBearbeitenForm))
mux.HandleFunc("POST /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageSpeichern))
mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm)
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)

View File

@@ -45,7 +45,18 @@ type fakeStore struct {
freigabeRegeln map[string]store.FreigabeRegel
freigabeschritte map[string]store.Freigabeschritt
passwordResetTokens map[string]store.PasswordResetToken
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
emailVorlagen map[string]store.EmailVorlage // key: accountKeyFor(accountID)+"|"+typ
}
// accountKeyFor macht nil und "" für die Plattform-Vorlage im
// fakeStore-Key ununterscheidbar von einer echten, aber leeren
// account_id — genügt für Tests, da echte account_ids nie leer sind.
func accountKeyFor(accountID *string) string {
if accountID == nil {
return ""
}
return *accountID
}
func newFakeStore() *fakeStore {
@@ -67,6 +78,16 @@ func newFakeStore() *fakeStore {
freigabeschritte: map[string]store.Freigabeschritt{},
passwordResetTokens: map[string]store.PasswordResetToken{},
loeschfristen: map[string]map[string]int{},
emailVorlagen: map[string]store.EmailVorlage{
// Platzhalter für den plattformweiten Standard, wie ihn
// Migration 0022 für echtes Postgres seedet — sonst würde
// ResolveEmailVorlage im fakeStore ErrNotFound liefern,
// wo die echte DB immer einen Treffer hat.
"|passwort_zuruecksetzen": {
Typ: "passwort_zuruecksetzen", Betreff: "Deklarix — Passwort zurücksetzen",
Text: "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail.",
},
},
}
}
@@ -81,6 +102,48 @@ func (f *fakeStore) SetTenantScope(ctx context.Context, accountID string, isBetr
return nil
}
func (f *fakeStore) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error) {
f.mu.Lock()
defer f.mu.Unlock()
key := accountKeyFor(accountID) + "|" + typ
v := store.EmailVorlage{ID: f.newID(), AccountID: accountID, Typ: typ, Betreff: betreff, Text: text, UpdatedAt: time.Now()}
f.emailVorlagen[key] = v
return v, nil
}
func (f *fakeStore) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.emailVorlagen[accountKeyFor(accountID)+"|"+typ]
if !ok {
return store.EmailVorlage{}, store.ErrNotFound
}
return v, nil
}
func (f *fakeStore) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error) {
f.mu.Lock()
defer f.mu.Unlock()
if v, ok := f.emailVorlagen[accountID+"|"+typ]; ok {
return v, nil
}
if v, ok := f.emailVorlagen["|"+typ]; ok {
return v, nil
}
return store.EmailVorlage{}, store.ErrNotFound
}
func (f *fakeStore) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
f.mu.Lock()
defer f.mu.Unlock()
key := accountKeyFor(accountID) + "|" + typ
if _, ok := f.emailVorlagen[key]; !ok {
return store.ErrNotFound
}
delete(f.emailVorlagen, key)
return nil
}
func (f *fakeStore) newID() string {
f.nextID++
return fmt.Sprintf("id-%d", f.nextID)

View File

@@ -24,6 +24,7 @@
<span class="stat-card-label">Audit-Log (neueste)</span>
</a>
</li>
<li><a class="stat-card stat-card-action" href="/betreiber/email-vorlagen"><span class="stat-card-label">E-Mail-Vorlagen &rarr;</span></a></li>
</ul>
</div>
</body>

View File

@@ -0,0 +1,49 @@
{{define "email-vorlage-bearbeiten"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<p><a href="{{.ActionBase}}">&larr; E-Mail-Vorlagen</a></p>
<div class="page-header">
<div>
<h1>{{.Label}}</h1>
<p class="hinweis">
Platzhalter in geschweiften Klammern werden beim Versand ersetzt.
Verfügbar: {{.Platzhalter}}
</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}}
<div class="form-card">
<form method="post" action="{{.ActionBase}}/{{.Typ}}">
<fieldset class="form-section">
<legend>{{.Label}}{{if .IstUebersteuert}} <span class="hinweis">(eigene Vorlage)</span>{{else if eq .ActionBase "/verwaltung/email-vorlagen"}} <span class="hinweis">(Plattform-Standard, nicht angepasst)</span>{{end}}</legend>
<div class="form-grid">
<div class="form-full">
<label for="betreff">Betreff</label>
<input type="text" id="betreff" name="betreff" value="{{.Betreff}}" required>
</div>
<div class="form-full">
<label for="text">Text</label>
<textarea id="text" name="text" rows="10" required>{{.Text}}</textarea>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
{{if and .IstUebersteuert (eq .ActionBase "/verwaltung/email-vorlagen")}}
<div class="form-gefahrenzone">
<form method="post" action="/verwaltung/email-vorlagen/{{.Typ}}/zuruecksetzen" onsubmit="return confirm('Eigene Vorlage löschen und zum Plattform-Standard zurückkehren?')">
<button type="submit" class="entfernen">Auf Plattform-Standard zurücksetzen</button>
</form>
</div>
{{end}}
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,30 @@
{{define "email-vorlagen-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<div class="page-header">
<div>
<h1>{{.Title}}</h1>
<p class="hinweis">Betreff und Text der vom System versendeten E-Mails.</p>
</div>
</div>
{{if not .Zeilen}}
<p class="hinweis">Es gibt noch keine vom System versendeten E-Mails.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Zeilen}}
<li>
<a href="{{$.ActionBase}}/{{.Typ}}">
<strong>{{.Label}}</strong>
{{if .IstUebersteuert}}<span class="status status-published">eigene Vorlage</span>{{else}}<span class="status status-mittel">Plattform-Standard</span>{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -35,6 +35,7 @@
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/loeschfristen">Löschfristen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/email-vorlagen">E-Mail-Vorlagen</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout">
<button type="submit">Abmelden</button>