Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe28278615 | ||
|
|
0fe29f8c80 | ||
|
|
eccf02050b | ||
|
|
8a8295dacd | ||
|
|
690660b655 |
265
CLAUDE.md
265
CLAUDE.md
@@ -377,12 +377,25 @@ Das öffnet jetzt auch DeepSeek (Verarbeitung in China) für einen
|
||||
künftigen Katalogeintrag — die Sperre lag ausschließlich am alten
|
||||
Wertebereich, nicht an einer fachlichen Entscheidung.
|
||||
|
||||
**Offen:** `dpf_zertifiziert` ist bei allen 19 Einträgen noch `false` —
|
||||
für keinen wurde bisher gezielt recherchiert, ob der jeweilige
|
||||
US-Anbieter tatsächlich DPF-zertifiziert ist (das Feld wurde erst mit
|
||||
dieser Migration eingeführt, `false` heißt hier "nicht recherchiert",
|
||||
nicht "nachweislich nicht zertifiziert"). Nachrecherche pro Anbieter
|
||||
noch zu tun.
|
||||
**`dpf_zertifiziert` recherchiert (2026-09-01), Offener Punkt erledigt.**
|
||||
Für alle 20 Einträge wurde gezielt gegen das offizielle Register
|
||||
(dataprivacyframework.gov, per Participant-ID einzeln nachvollziehbar)
|
||||
geprüft — nicht gegen die JS-Suchseite selbst (nicht scrapebar), sondern
|
||||
gegen das dahinterliegende, von ihr selbst genutzte API. Ergebnis: 12
|
||||
zertifiziert (Microsoft 365 Copilot, GitHub Copilot Business, Google
|
||||
Gemini, Perplexity Enterprise Pro, Adobe Firefly, Otter.ai Business,
|
||||
Notion AI, Grammarly Business, ElevenLabs, Zendesk AI, Canva Magic
|
||||
Studio, Zapier), 5 recherchiert aber nicht im Register gefunden
|
||||
(ChatGPT Enterprise, Claude for Work, Claude Code, Midjourney, Julius
|
||||
AI — `false` heißt hier jetzt "geprüft, kein Eintrag gefunden", nicht
|
||||
mehr "nicht recherchiert"), 3 nicht anwendbar (DeepL, Le Chat/Mistral,
|
||||
Synthesia — reine EU-Verarbeitung ohne US-Rechtsträger im Register, DPF
|
||||
regelt EU→USA-Transfers). Besonderheiten als `einschraenkungen`-Eintrag
|
||||
je Werkzeug festgehalten: Perplexity hat nur EU-US/UK-, keine
|
||||
Swiss-US-Zertifizierung; Notion AIs Zertifizierung ist als "Active" mit
|
||||
bereits abgelaufenem Datum in Re-Zertifizierung; Grammarly ist im
|
||||
Register unter dem fusionierten Rechtsträger "Superhuman Platform Inc."
|
||||
gelistet, nicht mehr unter "Grammarly, Inc.".
|
||||
|
||||
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||
@@ -452,8 +465,11 @@ Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
|
||||
|
||||
**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt**
|
||||
(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
|
||||
3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst
|
||||
eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort,
|
||||
3). Zum Zeitpunkt dieser Umsetzung gab es noch keine E-Mail-Infrastruktur
|
||||
(die kam erst 2026-09-01 mit `internal/mail`, siehe „Passwort-
|
||||
Zurücksetzen und E-Mail-Versand" weiter unten, bisher nur für
|
||||
Passwort-Zurücksetzen genutzt) — "Benachrichtigung" ist deshalb bewusst
|
||||
weiterhin eine In-App-Liste, kein E-Mail-Versand. Eine aktive Genehmigung erscheint dort,
|
||||
wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
|
||||
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
||||
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
|
||||
@@ -588,6 +604,203 @@ wie spezifiziert. Chromium-Headless-Screenshots (Desktop 1440×900 +
|
||||
Mobile 390×844) aller vier neuen/geänderten Seiten bestätigen zusätzlich
|
||||
das responsive Layout nach dem enconf-Card-Pattern.
|
||||
|
||||
**Standard-Genehmiger-Rollen bei Firmenanlage (2026-08-31, Migration
|
||||
0018).** Jede neue Firma bekommt jetzt automatisch vier leere
|
||||
Genehmiger-Rollen mit erklärender Beschreibung (`standardGenehmigerRollen`
|
||||
in `internal/web/freigabe_handlers.go`, aufgerufen aus `handleRegister`
|
||||
und `handleBetreiberAccountCreate` direkt nach `CreateAccount`):
|
||||
|
||||
- **Datenschutzbeauftragter** — "Prüft Anträge mit besonderen
|
||||
Datenschutzanforderungen, z. B. wenn eine Datenschutz-Folgenabschätzung
|
||||
erforderlich ist."
|
||||
- **Geschäftsführer** — "Trifft die unternehmerische Entscheidung bei
|
||||
besonders risikobehafteten Vorhaben."
|
||||
- **KI-Manager** — "Bewertet den Antrag fachlich."
|
||||
- **CISO** — "Prüft den Antrag aus Sicherheitssicht (Auditor)."
|
||||
|
||||
Bewusst **ohne** Mitglieder und **ohne** Freigabe-Regel — der Admin muss
|
||||
nur noch Personen zuordnen (`/verwaltung/genehmiger-rollen`) und
|
||||
optional eine auslösende Bedingung definieren
|
||||
(`/verwaltung/freigabe-regeln`), statt bei null anzufangen. Welche
|
||||
Bedingung welche Rolle tatsächlich auslöst, bleibt **vollständig
|
||||
Sache der Firma** — das System schreibt keine feste fachliche Bindung
|
||||
vor (z. B. keine hartkodierte Kopplung "Geschäftsführer ⇒ hochrisiko");
|
||||
die Beschreibung ist reine UI-Orientierungshilfe, kein Ableitungsregel-
|
||||
Ersatz. `genehmiger_rolle.beschreibung` ist ein freies, optionales
|
||||
Textfeld (`NOT NULL DEFAULT ''`) — auch für vom Admin selbst angelegte
|
||||
zusätzliche Rollen nutzbar. Getestet via `TestRegisterSeedsStandardGenehmigerRollen`
|
||||
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)
|
||||
|
||||
Schließt `rules/OPEN.md`, Punkt 4. Recherche ergab: die DSGVO nennt
|
||||
**keine** festen Speicherfristen (Art. 5 Abs. 1 lit. e verlangt nur "so
|
||||
lange wie für den Zweck nötig") — es gibt also keinen fest im Regelwerk
|
||||
hinterlegbaren "richtigen" Zahlenwert. Lösung: **pro Mandant einstellbar**
|
||||
(`loeschfrist_einstellung`, `GET/POST /verwaltung/loeschfristen`,
|
||||
admin-only wie jede andere `/verwaltung/*`-Seite — bewusst NICHT an den
|
||||
Namen der Genehmiger-Rolle "Datenschutzbeauftragter" gekoppelt, siehe
|
||||
Freigabe-Workflow oben: Genehmiger-Rollen sind explizit ohne eigenes
|
||||
Zugriffsrecht). Jede neue Firma wird mit risikogestaffelten
|
||||
Vorschlagswerten vorbelegt (`seedStandardLoeschfristen`, aufgerufen wie
|
||||
`seedStandardGenehmigerRollen` direkt nach `CreateAccount`):
|
||||
`oeffentlich` 365 / `intern` 180 / `auftragsdaten` 90 / `personenbezogen`
|
||||
90 / `berufsgeheimnis` 30 / `besondere_kategorie` 30 Tage — eigene
|
||||
Einschätzung nach Sensibilität gestaffelt, **keine Rechtsvorgabe**, frei
|
||||
editierbar (Formular-Hinweistext sagt das ausdrücklich).
|
||||
|
||||
**Harte Filterung jetzt aktiv** (vorher, siehe `rules/OPEN.md` Punkt 4
|
||||
alt, wurde `loeschfrist_max_tage` gar nicht geprüft): `ErfuelltAnforderung`
|
||||
und `FilterWerkzeuge` (`internal/rules/evaluate.go`) haben einen neuen
|
||||
Parameter `loeschfristMaxTage *int`. `nil` (keine Frist konfiguriert)
|
||||
verhält sich wie vorher (nicht gefiltert) — Rückwärtskompatibilität.
|
||||
Ist eine Frist gesetzt, erfüllt ein Werkzeug ohne bezifferte
|
||||
Aufbewahrungsdauer (`werkzeug.aufbewahrung_tage IS NULL`) sie NICHT
|
||||
(fail closed, analog zur leeren Länderliste bei `eu_verarbeitung`) —
|
||||
dafür wurde `rules.WerkzeugEigenschaften.AufbewahrungTage` von `int` auf
|
||||
`*int` geändert, sonst wäre "unbekannt" nicht von "0 Tage" unterscheidbar
|
||||
gewesen. `internal/web/antrag_handlers.go:bewerteAntrag` liest die
|
||||
Einstellung für die aktuelle Datenklasse des Antrags aus
|
||||
`ListLoeschfristEinstellungenForAccount` und reicht sie durch.
|
||||
|
||||
---
|
||||
|
||||
## Passwort-Zurücksetzen und E-Mail-Versand (2026-09-01)
|
||||
|
||||
Neues Paket `internal/mail`: `Mailer`-Interface (`Send(to, subject, body)
|
||||
error`), `SMTPMailer` als echte Implementierung (stdlib `net/smtp`,
|
||||
STARTTLS falls vom Server angeboten — deckt den Standard-Submission-Port
|
||||
587 ab, implizites TLS auf Port 465 wird von `net/smtp` nicht
|
||||
unterstützt) und `FakeMailer` als Test-Doppel (sammelt gesendete Mails
|
||||
statt sie zu verschicken). Konfiguration über `SMTP_HOST`/`SMTP_PORT`/
|
||||
`SMTP_USER`/`SMTP_PASSWORD`/`SMTP_FROM` (alle optional — ohne
|
||||
`SMTP_HOST` startet der Dienst normal, nur der tatsächliche Versand
|
||||
schlägt dann mit einem klaren Fehler fehl statt einer nie ankommenden
|
||||
Mail vorzutäuschen; bewusst kein stiller No-op).
|
||||
|
||||
**Erste Anwendung: Passwort-Zurücksetzen** (Ebene 1, öffentlich, bisher
|
||||
in der Rollentabelle nur als Absicht genannt, nie gebaut). `GET/POST
|
||||
/passwort-vergessen` nimmt eine E-Mail-Adresse entgegen und zeigt
|
||||
**immer** dieselbe Erfolgsmeldung ("Falls diese E-Mail-Adresse bei uns
|
||||
registriert ist, wurde ein Link verschickt") — unabhängig davon, ob die
|
||||
Adresse existiert, aktiv ist, oder der Versand tatsächlich geklappt hat.
|
||||
Das ist derselbe User-Enumeration-Schutz wie beim Login (identische
|
||||
Fehlermeldung bei falscher E-Mail/falschem Passwort); ein Versandfehler
|
||||
wird serverseitig geloggt (`log.Printf`, nicht verschluckt), aber nicht
|
||||
an den Client durchgereicht. `password_reset_token` (Migration 0019):
|
||||
ein Token ist 1 Stunde gültig, genau einmal verwendbar (`used_at`),
|
||||
`GetValidPasswordResetToken` liefert für "nicht gefunden", "abgelaufen"
|
||||
und "schon verwendet" identisch `ErrNotFound` — auch hier kein
|
||||
Unterschied, der einem Angreifer etwas verraten könnte. `GET/POST
|
||||
/passwort-zuruecksetzen/{token}` prüft den Token, setzt bei Erfolg das
|
||||
neue Passwort (`store.SetUserPassword`) und verbraucht den Token.
|
||||
|
||||
**Live end-to-end verifiziert** — nicht nur gegen `mail.FakeMailer` in
|
||||
Tests, sondern gegen einen echten, minimalen SMTP-Server (selbst
|
||||
geschriebener TCP-Listener, der die EHLO/MAIL FROM/RCPT TO/DATA-Sequenz
|
||||
korrekt beantwortet): Registrierung → Anfrage → tatsächlich empfangene
|
||||
Mail mit funktionierendem Link → Formular → neues Passwort → Login mit
|
||||
altem Passwort abgelehnt, mit neuem Passwort erfolgreich. Dabei einen
|
||||
echten Bug gefunden und behoben: der Versandfehler wurde zunächst mit
|
||||
`_ = s.mailer.Send(...)` still verschluckt (Verstoß gegen die
|
||||
"keine stillen Fallbacks"-Regel) — beim Debuggen selbst schmerzhaft
|
||||
aufgefallen, jetzt geloggt statt verworfen.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding
|
||||
@@ -1166,15 +1379,13 @@ journalctl -u deklarix -f
|
||||
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
|
||||
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
|
||||
Rückmeldung dazu bisher eingeholt.
|
||||
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
||||
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||
— die Frontend-Spezifikation fordert das explizit ("nicht in der
|
||||
Anwendungslogik"), aktuell ist Isolation ausschließlich
|
||||
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS
|
||||
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.
|
||||
- ~~Löschfristen je Datenklasse~~ — **erledigt 2026-09-01**, siehe
|
||||
„Löschfristen je Datenklasse" weiter oben: pro Mandant einstellbar
|
||||
statt hartkodiert, da die DSGVO selbst keine festen Fristen nennt.
|
||||
- ~~Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)~~
|
||||
— **Code/Migration/Tests erledigt 2026-09-01, Produktivbetrieb noch
|
||||
ausstehend** (siehe „Row-Level-Security" weiter oben für den vollen
|
||||
Stand und was auf dem Produktivserver noch manuell nachzuziehen ist).
|
||||
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
|
||||
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
|
||||
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
|
||||
@@ -1189,15 +1400,13 @@ journalctl -u deklarix -f
|
||||
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
|
||||
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
|
||||
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
|
||||
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
|
||||
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
|
||||
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
|
||||
anlegen, `GET/POST /betreiber/werkzeuge/{id}` bearbeiten, `POST
|
||||
.../loeschen` löschen — siehe `internal/web/betreiber_werkzeug_handlers.go`.
|
||||
Bearbeitet ausschließlich `account_id IS NULL`-Einträge, ein
|
||||
mandantenspezifischer Eintrag ist über diese Seiten nicht erreichbar
|
||||
[404]). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma-
|
||||
Onboarding bleibt trotzdem nicht automatisiert — der Katalog ist
|
||||
inhaltlich weiterhin leer, bis der Betreiber tatsächlich Einträge
|
||||
pflegt.
|
||||
- ~~Zentraler Werkzeugkatalog muss noch befüllt werden~~ — **erledigt**,
|
||||
20 recherchierte Einträge seit 2026-08-31 (siehe „Werkzeugkatalog"
|
||||
weiter oben). ~~`dpf_zertifiziert` recherchieren~~ — **erledigt
|
||||
2026-09-01** (siehe dort für Details je Anbieter). "Werkzeugkatalog aus
|
||||
der Plattform vorbelegt" beim Firma-Onboarding bleibt weiterhin nicht
|
||||
automatisiert — jede neue Firma sieht sofort den vollen zentralen
|
||||
Katalog (kein Kopiervorgang nötig, `account_id IS NULL` ist für alle
|
||||
Mandanten gleich sichtbar), es gibt nur keine mandantenspezifische
|
||||
Vorauswahl/Kuratierung beim Onboarding.
|
||||
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/mail"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
@@ -21,7 +22,19 @@ func main() {
|
||||
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 {
|
||||
log.Fatalf("open store: %v", err)
|
||||
}
|
||||
@@ -51,9 +64,17 @@ func main() {
|
||||
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
||||
|
||||
mailer := mail.NewSMTPMailer(mail.Config{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: os.Getenv("SMTP_PORT"),
|
||||
User: os.Getenv("SMTP_USER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: os.Getenv("SMTP_FROM"),
|
||||
})
|
||||
|
||||
server, err := web.NewServer(db, web.Regelwerk{
|
||||
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||
})
|
||||
}, mailer)
|
||||
if err != nil {
|
||||
log.Fatalf("web server: %v", err)
|
||||
}
|
||||
|
||||
78
internal/mail/mail.go
Normal file
78
internal/mail/mail.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Package mail versendet E-Mails (aktuell: Passwort-Zurücksetzen-Links)
|
||||
// über einen konfigurierten SMTP-Server. Reine Versandlogik, kein
|
||||
// Template-Rendering — der Aufrufer (internal/web) baut Betreff/Text
|
||||
// selbst zusammen, damit dieses Paket unabhängig vom Web-Layer bleibt.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
// Mailer ist die Schnittstelle, die internal/web nutzt — austauschbar
|
||||
// gegen eine Test-Doppel (siehe FakeMailer), damit Tests keinen echten
|
||||
// SMTP-Server brauchen.
|
||||
type Mailer interface {
|
||||
Send(to, subject, body string) error
|
||||
}
|
||||
|
||||
// Config sind die SMTP-Zugangsdaten, aus Umgebungsvariablen gelesen
|
||||
// (siehe cmd/deklarix/main.go) — kein neues Geheimnis im Code.
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
// SMTPMailer versendet über einen echten SMTP-Server (STARTTLS, falls
|
||||
// vom Server angeboten — das deckt den Standard-Submission-Port 587 ab;
|
||||
// implizites TLS auf Port 465 wird von net/smtp nicht unterstützt).
|
||||
type SMTPMailer struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func NewSMTPMailer(cfg Config) *SMTPMailer {
|
||||
return &SMTPMailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Send verschickt eine reine Text-E-Mail. Liefert einen Fehler, wenn
|
||||
// kein SMTP-Host konfiguriert ist — bewusst kein stiller No-op, ein
|
||||
// fehlgeschlagener Versand (z. B. Passwort-Reset) muss sichtbar
|
||||
// scheitern statt eine E-Mail vorzutäuschen, die nie ankommt.
|
||||
func (m *SMTPMailer) Send(to, subject, body string) error {
|
||||
if m.cfg.Host == "" {
|
||||
return fmt.Errorf("mail: SMTP nicht konfiguriert (SMTP_HOST fehlt)")
|
||||
}
|
||||
addr := m.cfg.Host + ":" + m.cfg.Port
|
||||
msg := fmt.Sprintf(
|
||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||
m.cfg.From, to, subject, body,
|
||||
)
|
||||
|
||||
var auth smtp.Auth
|
||||
if m.cfg.User != "" {
|
||||
auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
|
||||
}
|
||||
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg)); err != nil {
|
||||
return fmt.Errorf("mail: senden an %s: %w", to, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SentMail protokolliert einen Versand für Tests.
|
||||
type SentMail struct {
|
||||
To, Subject, Body string
|
||||
}
|
||||
|
||||
// FakeMailer ist das Test-Doppel für Mailer — sammelt gesendete Mails
|
||||
// statt sie zu verschicken.
|
||||
type FakeMailer struct {
|
||||
Sent []SentMail
|
||||
}
|
||||
|
||||
func (f *FakeMailer) Send(to, subject, body string) error {
|
||||
f.Sent = append(f.Sent, SentMail{To: to, Subject: subject, Body: body})
|
||||
return nil
|
||||
}
|
||||
@@ -194,7 +194,10 @@ type WerkzeugEigenschaften struct {
|
||||
Verarbeitungslaender []string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
// AufbewahrungTage ist nil, wenn der Anbieter keine Aufbewahrungsdauer
|
||||
// beziffert — das erfüllt eine gesetzte Löschfrist-Anforderung NICHT
|
||||
// (fail closed), analog zur leeren Länderliste bei eu_verarbeitung.
|
||||
AufbewahrungTage *int
|
||||
}
|
||||
|
||||
// euEwrLaender sind die Staaten, für die eine Verarbeitung nicht als
|
||||
@@ -234,10 +237,15 @@ func alleLaenderInEUEWR(laender []string) bool {
|
||||
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
||||
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
||||
// Werkzeug selbst. loeschfrist_max_tage hat noch keine fachlich
|
||||
// festgelegten Tageswerte (rules/OPEN.md, Punkt 4) und wird deshalb
|
||||
// ebenfalls noch nicht hart gefiltert.
|
||||
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
||||
// Werkzeug selbst.
|
||||
//
|
||||
// loeschfristMaxTage ist die vom Mandanten für die aktuelle Datenklasse
|
||||
// konfigurierte Frist (siehe store.LoeschfristEinstellung) — nil
|
||||
// bedeutet "für diesen Mandanten/diese Datenklasse nicht konfiguriert"
|
||||
// und wird NICHT hart gefiltert (Rückwärtskompatibilität, siehe
|
||||
// rules/OPEN.md, Punkt 4); ist eine Frist gesetzt, erfüllt ein Werkzeug
|
||||
// ohne bezifferte Aufbewahrungsdauer sie NICHT (fail closed).
|
||||
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften, loeschfristMaxTage *int) bool {
|
||||
switch anforderungID {
|
||||
case "avv_erforderlich":
|
||||
return w.AVVVerfuegbar
|
||||
@@ -245,6 +253,14 @@ func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
||||
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
||||
case "kein_training_auf_eingabe":
|
||||
return w.TrainingStandard
|
||||
case "loeschfrist_max_tage":
|
||||
if loeschfristMaxTage == nil {
|
||||
return true
|
||||
}
|
||||
if w.AufbewahrungTage == nil {
|
||||
return false
|
||||
}
|
||||
return *w.AufbewahrungTage <= *loeschfristMaxTage
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -266,12 +282,13 @@ type WerkzeugKandidat struct {
|
||||
|
||||
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
||||
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
||||
// Werkzeug fest, welche Anforderungen fehlten.
|
||||
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||
// Werkzeug fest, welche Anforderungen fehlten. loeschfristMaxTage siehe
|
||||
// ErfuelltAnforderung.
|
||||
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string, loeschfristMaxTage *int) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||
for _, k := range kandidaten {
|
||||
var fehlend []string
|
||||
for _, reqID := range anforderungIDs {
|
||||
if !ErfuelltAnforderung(reqID, k.Eigenschaften) {
|
||||
if !ErfuelltAnforderung(reqID, k.Eigenschaften, loeschfristMaxTage) {
|
||||
fehlend = append(fehlend, reqID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,31 +150,52 @@ func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungAVV(t *testing.T) {
|
||||
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}) {
|
||||
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt ohne AVV")
|
||||
}
|
||||
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}) {
|
||||
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}, nil) {
|
||||
t.Error("erwartet: erfuellt mit AVV")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
|
||||
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}) {
|
||||
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}, nil) {
|
||||
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
|
||||
}
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}) {
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt bei USA - die USA sind ein Drittland wie jedes andere")
|
||||
}
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland", "USA"}}) {
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland", "USA"}}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
|
||||
}
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}) {
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungLoeschfrist(t *testing.T) {
|
||||
dreissig := 30
|
||||
neunzig := 90
|
||||
// Keine Frist konfiguriert -> nicht hart gefiltert (Rückwärtskompatibilität).
|
||||
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, nil) {
|
||||
t.Error("erwartet: erfuellt ohne konfigurierte Frist")
|
||||
}
|
||||
// Frist gesetzt, Werkzeug bleibt innerhalb -> erfuellt.
|
||||
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &dreissig}, &neunzig) {
|
||||
t.Error("erwartet: erfuellt, wenn Aufbewahrung <= Frist")
|
||||
}
|
||||
// Frist gesetzt, Werkzeug überschreitet -> nicht erfuellt.
|
||||
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &neunzig}, &dreissig) {
|
||||
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrung > Frist")
|
||||
}
|
||||
// Frist gesetzt, Werkzeug beziffert Aufbewahrung nicht -> fail closed.
|
||||
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, &dreissig) {
|
||||
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrungsdauer unbekannt ist ('wissen wir nicht' darf nicht als 'ist okay' durchgehen)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}, nil) {
|
||||
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||
}
|
||||
}
|
||||
@@ -184,7 +205,7 @@ func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"}, nil)
|
||||
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
||||
}
|
||||
@@ -198,7 +219,7 @@ func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||
|
||||
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
||||
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil)
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil, nil)
|
||||
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
||||
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type Abteilung struct {
|
||||
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
||||
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
||||
var a Abteilung
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
||||
RETURNING id, account_id, name, created_at
|
||||
`, 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,
|
||||
// alphabetisch — als Auswahlliste für den Fragebogen.
|
||||
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
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
@@ -59,7 +59,7 @@ func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string)
|
||||
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
||||
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
||||
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
|
||||
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||
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
|
||||
// angelegt).
|
||||
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 {
|
||||
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
|
||||
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
||||
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)
|
||||
RETURNING `+accountColumns,
|
||||
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
|
||||
// Betreiber-gestützten Anlage korrigieren).
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
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.
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
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
|
||||
// oder bereits erneuertem (damit ungültig gewordenem) Token.
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
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.
|
||||
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
|
||||
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 {
|
||||
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
|
||||
// Admin-Bereich (Accounts-Verwaltung).
|
||||
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 {
|
||||
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.
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
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,
|
||||
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
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
|
||||
// Status hier nicht).
|
||||
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
|
||||
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
||||
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
|
||||
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
||||
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 {
|
||||
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
|
||||
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
||||
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
|
||||
`, erstellerUserID)
|
||||
if err != nil {
|
||||
@@ -139,7 +139,7 @@ func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string)
|
||||
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||
// zuerst.
|
||||
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
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,7 +22,7 @@ type AuditEntry struct {
|
||||
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
||||
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
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.
|
||||
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
|
||||
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
||||
`, 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)
|
||||
}
|
||||
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO bewertung (
|
||||
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||
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
|
||||
// existiert.
|
||||
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT `+bewertungColumns+`
|
||||
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`, 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 (
|
||||
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
||||
) 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 existiert.
|
||||
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT `+entscheidungColumns+`
|
||||
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`, 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
|
||||
// zum Mandanten läuft über den zugehörigen antrag.
|
||||
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,
|
||||
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
|
||||
FROM entscheidung e
|
||||
|
||||
@@ -15,20 +15,23 @@ import (
|
||||
)
|
||||
|
||||
type GenehmigerRolle struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
Beschreibung string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
|
||||
// Mandanten an.
|
||||
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (GenehmigerRolle, error) {
|
||||
// Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des
|
||||
// Admins (z. B. "Prüft den Antrag aus Sicherheitssicht") — bindet keine
|
||||
// Bedingung, das bleibt Sache von freigabe_regel.
|
||||
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO genehmiger_rolle (account_id, name) VALUES ($1, $2)
|
||||
RETURNING id, account_id, name, created_at
|
||||
`, accountID, name).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt)
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
|
||||
RETURNING id, account_id, name, beschreibung, created_at
|
||||
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
if err != nil {
|
||||
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
|
||||
}
|
||||
@@ -38,9 +41,9 @@ func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name strin
|
||||
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM genehmiger_rolle WHERE id = $1
|
||||
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt)
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
|
||||
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GenehmigerRolle{}, ErrNotFound
|
||||
}
|
||||
@@ -53,8 +56,8 @@ func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRo
|
||||
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||
// Mandanten.
|
||||
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM genehmiger_rolle
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
@@ -65,7 +68,7 @@ func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID st
|
||||
var out []GenehmigerRolle
|
||||
for rows.Next() {
|
||||
var g GenehmigerRolle
|
||||
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
@@ -79,7 +82,7 @@ func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID st
|
||||
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
|
||||
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
||||
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
|
||||
}
|
||||
@@ -91,7 +94,7 @@ func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||
|
||||
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
_, err := s.db(ctx).Exec(ctx, `
|
||||
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
||||
`, userID, genehmigerRolleID)
|
||||
@@ -103,7 +106,7 @@ func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmiger
|
||||
|
||||
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
_, err := s.db(ctx).Exec(ctx, `
|
||||
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
|
||||
`, userID, genehmigerRolleID)
|
||||
if err != nil {
|
||||
@@ -115,7 +118,7 @@ func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmi
|
||||
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
||||
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
||||
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT gr.id, gr.account_id, gr.name, gr.created_at
|
||||
FROM genehmiger_rolle gr
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
|
||||
@@ -148,7 +151,7 @@ type GenehmigerMitglied struct {
|
||||
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||
// bestimmten Genehmiger-Rolle.
|
||||
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT u.id, u.email
|
||||
FROM app_user u
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
|
||||
@@ -186,7 +189,7 @@ type FreigabeRegel struct {
|
||||
|
||||
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
@@ -200,7 +203,7 @@ func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
@@ -221,7 +224,7 @@ func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID stri
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete freigabe regel: %w", err)
|
||||
}
|
||||
@@ -235,7 +238,7 @@ func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
|
||||
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
|
||||
@@ -266,7 +269,7 @@ type Freigabeschritt struct {
|
||||
|
||||
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
`, antragID, genehmigerRolleID).Scan(
|
||||
@@ -280,7 +283,7 @@ func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerR
|
||||
|
||||
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
|
||||
@@ -294,7 +297,7 @@ func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschr
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
|
||||
`, antragID)
|
||||
@@ -318,7 +321,7 @@ func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID stri
|
||||
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
||||
// innehat — Grundlage für "Meine Freigaben".
|
||||
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
|
||||
FROM freigabeschritt f
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
|
||||
@@ -348,7 +351,7 @@ func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, user
|
||||
// generischer Fehler statt eines Sentinels).
|
||||
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
||||
WHERE id = $1 AND status = 'ausstehend'
|
||||
@@ -372,7 +375,7 @@ func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entsc
|
||||
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
|
||||
// hängen zu bleiben.
|
||||
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
_, err := s.db(ctx).Exec(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = 'abgelehnt', entschieden_am = now(),
|
||||
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter")
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func TestFreigabeRegelCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung")
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
@@ -113,8 +113,8 @@ func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
|
||||
}
|
||||
antragID := antrag.ID
|
||||
|
||||
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter")
|
||||
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung")
|
||||
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||
|
||||
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
|
||||
if err != nil {
|
||||
|
||||
69
internal/store/loeschfrist.go
Normal file
69
internal/store/loeschfrist.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Löschfristen je Datenklasse (Migration 0020) — schließt die in
|
||||
// rules/OPEN.md, Punkt 4 dokumentierte Lücke: loeschfrist_max_tage war
|
||||
// bisher nur als Anforderung "vorhanden", ohne Tageswerte, und wurde
|
||||
// deshalb nicht hart gefiltert. Die konkreten Tageswerte sind KEINE
|
||||
// gesetzliche Vorgabe (die DSGVO nennt keine festen Fristen, nur den
|
||||
// Grundsatz "so lange wie für den Zweck nötig", Art. 5 Abs. 1 lit. e) —
|
||||
// deshalb pro Mandant einstellbar statt hartkodiert, vom
|
||||
// Datenschutzbeauftragten hinterlegt. Bei Firmenanlage werden
|
||||
// risikogestaffelte Vorschlagswerte vorbelegt (siehe
|
||||
// internal/web/loeschfrist_handlers.go), frei editierbar.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoeschfristEinstellung struct {
|
||||
ID string
|
||||
AccountID string
|
||||
DatenklasseID string
|
||||
MaxTage int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertLoeschfristEinstellung legt eine Einstellung an oder
|
||||
// aktualisiert sie — ein Mandant hat höchstens eine Frist je
|
||||
// Datenklasse (UNIQUE-Constraint).
|
||||
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
|
||||
var e LoeschfristEinstellung
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (account_id, datenklasse_id)
|
||||
DO UPDATE SET max_tage = $3, updated_at = now()
|
||||
RETURNING id, account_id, datenklasse_id, max_tage, updated_at
|
||||
`, accountID, datenklasseID, maxTage).Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt)
|
||||
if err != nil {
|
||||
return LoeschfristEinstellung{}, fmt.Errorf("store: upsert loeschfrist einstellung: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
|
||||
// Fristen eines Mandanten.
|
||||
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, datenklasse_id, max_tage, updated_at
|
||||
FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LoeschfristEinstellung
|
||||
for rows.Next() {
|
||||
var e LoeschfristEinstellung
|
||||
if err := rows.Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan loeschfrist einstellung: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
41
internal/store/loeschfrist_test.go
Normal file
41
internal/store/loeschfrist_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoeschfristEinstellungUpsert(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
e, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 90)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||
}
|
||||
if e.MaxTage != 90 {
|
||||
t.Fatalf("MaxTage = %d, want 90", e.MaxTage)
|
||||
}
|
||||
|
||||
// Erneutes Upsert für dieselbe Datenklasse überschreibt statt zu duplizieren.
|
||||
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 30); err != nil {
|
||||
t.Fatalf("UpsertLoeschfristEinstellung (Update): %v", err)
|
||||
}
|
||||
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "besondere_kategorie", 14); err != nil {
|
||||
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||
}
|
||||
|
||||
liste, err := s.ListLoeschfristEinstellungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLoeschfristEinstellungenForAccount: %v", err)
|
||||
}
|
||||
if len(liste) != 2 {
|
||||
t.Fatalf("got %d Einstellungen, want 2 (Upsert darf nicht duplizieren)", len(liste))
|
||||
}
|
||||
for _, e := range liste {
|
||||
if e.DatenklasseID == "personenbezogen" && e.MaxTage != 30 {
|
||||
t.Fatalf("personenbezogen MaxTage = %d, want 30 nach Update", e.MaxTage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE password_reset_token;
|
||||
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE password_reset_token (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES app_user(id),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX password_reset_token_user_id_idx ON password_reset_token(user_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE loeschfrist_einstellung;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE loeschfrist_einstellung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account(id),
|
||||
datenklasse_id TEXT NOT NULL,
|
||||
max_tage INTEGER NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, datenklasse_id)
|
||||
);
|
||||
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.
|
||||
77
internal/store/password_reset.go
Normal file
77
internal/store/password_reset.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Passwort-Zurücksetzen-Tokens (Migration 0019). Ein Token ist einmal
|
||||
// verwendbar (used_at) und läuft ab (expires_at) — GetValidPasswordResetToken
|
||||
// liefert ErrNotFound für "nicht gefunden", "abgelaufen" und "schon
|
||||
// verwendet" gleichermaßen, damit ein Angreifer über die Fehlermeldung
|
||||
// nichts über den Zustand eines geratenen Tokens lernt.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// PasswordResetTokenDuration ist die Gültigkeitsdauer eines frisch
|
||||
// erzeugten Zurücksetzen-Links.
|
||||
const PasswordResetTokenDuration = 1 * time.Hour
|
||||
|
||||
type PasswordResetToken struct {
|
||||
ID string
|
||||
UserID string
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreatePasswordResetToken legt einen neuen Zurücksetzen-Token für
|
||||
// einen Nutzer an. token muss bereits kryptographisch zufällig erzeugt
|
||||
// sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst.
|
||||
func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) {
|
||||
var t PasswordResetToken
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO password_reset_token (user_id, token, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, user_id, token, expires_at, used_at, created_at
|
||||
`, userID, token, time.Now().Add(PasswordResetTokenDuration)).Scan(
|
||||
&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return PasswordResetToken{}, fmt.Errorf("store: create password reset token: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetValidPasswordResetToken liest einen Token nur, wenn er existiert,
|
||||
// noch nicht abgelaufen und noch nicht verwendet ist.
|
||||
func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) {
|
||||
var t PasswordResetToken
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, user_id, token, expires_at, used_at, created_at
|
||||
FROM password_reset_token
|
||||
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
|
||||
`, token).Scan(&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PasswordResetToken{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return PasswordResetToken{}, fmt.Errorf("store: get valid password reset token: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe
|
||||
// Link kein zweites Mal ein Passwort setzen kann.
|
||||
func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: mark password reset token used: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
internal/store/password_reset_test.go
Normal file
58
internal/store/password_reset_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestPasswordResetTokenLifecycle(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
tok, err := s.CreatePasswordResetToken(ctx, userID, "test-token-123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePasswordResetToken: %v", err)
|
||||
}
|
||||
if tok.UserID != userID || tok.Token != "test-token-123" {
|
||||
t.Fatalf("PasswordResetToken = %+v, unerwartete Werte", tok)
|
||||
}
|
||||
|
||||
got, err := s.GetValidPasswordResetToken(ctx, "test-token-123")
|
||||
if err != nil || got.ID != tok.ID {
|
||||
t.Fatalf("GetValidPasswordResetToken = %+v, err=%v", got, err)
|
||||
}
|
||||
|
||||
if err := s.MarkPasswordResetTokenUsed(ctx, tok.ID); err != nil {
|
||||
t.Fatalf("MarkPasswordResetTokenUsed: %v", err)
|
||||
}
|
||||
|
||||
// Ein verbrauchter Token gilt nicht mehr als gültig.
|
||||
if _, err := s.GetValidPasswordResetToken(ctx, "test-token-123"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Verbrauch = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Ein unbekannter Token ist von Anfang an ErrNotFound.
|
||||
if _, err := s.GetValidPasswordResetToken(ctx, "existiert-nicht"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err für unbekannten Token = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserPassword(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
if err := s.SetUserPassword(ctx, userID, "neuer-hash"); err != nil {
|
||||
t.Fatalf("SetUserPassword: %v", err)
|
||||
}
|
||||
got, err := s.GetUser(ctx, userID)
|
||||
if err != nil || got.PasswordHash != "neuer-hash" {
|
||||
t.Fatalf("GetUser nach SetUserPassword = %+v, err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInp
|
||||
if in.Auflagen == nil {
|
||||
in.Auflagen = []string{}
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO registereintrag (
|
||||
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||
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
|
||||
// Mandanten, neueste zuerst.
|
||||
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
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
|
||||
@@ -36,7 +36,7 @@ func scanSession(row interface {
|
||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||
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)
|
||||
VALUES ($1, $2, $3)
|
||||
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
|
||||
// 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) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
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
|
||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
@@ -87,7 +87,7 @@ func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||
|
||||
// DeleteSession beendet eine Sitzung (Logout).
|
||||
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 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
|
||||
// speichert nur, es hasht nicht selbst.
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
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.
|
||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
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.
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
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
|
||||
// Admin-Bereich (Account-Detailansicht).
|
||||
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
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
@@ -108,12 +108,26 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetUserPassword ersetzt den Passwort-Hash eines Nutzers (Passwort-
|
||||
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
|
||||
// CreateUser.
|
||||
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set user password: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
||||
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
||||
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
||||
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 {
|
||||
return fmt.Errorf("store: set user active: %w", err)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO werkzeug (
|
||||
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||
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.
|
||||
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
UPDATE werkzeug SET
|
||||
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,
|
||||
@@ -161,7 +161,7 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
|
||||
|
||||
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Werkzeug{}, ErrNotFound
|
||||
@@ -174,7 +174,7 @@ func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
||||
|
||||
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
||||
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 {
|
||||
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
|
||||
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
// Mandanten-Sperrungen.
|
||||
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
|
||||
`)
|
||||
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) {
|
||||
var count int
|
||||
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
|
||||
`).Scan(&count, &lastUpdate)
|
||||
if err != nil {
|
||||
@@ -271,7 +271,7 @@ type WerkzeugSperre struct {
|
||||
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
||||
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
||||
var sp WerkzeugSperre
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
||||
VALUES ($1, $2, $3)
|
||||
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.
|
||||
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
|
||||
`, accountID, werkzeugID)
|
||||
if err != nil {
|
||||
@@ -298,7 +298,7 @@ func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID
|
||||
|
||||
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
||||
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
|
||||
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
||||
`, accountID)
|
||||
|
||||
@@ -290,16 +290,25 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
|
||||
}
|
||||
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||
for i, w := range werkzeuge {
|
||||
aufbewahrungTage := 0
|
||||
if w.AufbewahrungTage != nil {
|
||||
aufbewahrungTage = *w.AufbewahrungTage
|
||||
}
|
||||
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||
}}
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||
|
||||
var loeschfristMaxTage *int
|
||||
loeschfristen, err := s.store.ListLoeschfristEinstellungenForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range loeschfristen {
|
||||
if l.DatenklasseID == datenklasse.ID {
|
||||
maxTage := l.MaxTage
|
||||
loeschfristMaxTage = &maxTage
|
||||
}
|
||||
}
|
||||
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs, loeschfristMaxTage)
|
||||
in.ZulaessigeWerkzeuge = zulaessig
|
||||
for _, a := range ausgeschlossen {
|
||||
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
||||
|
||||
@@ -248,6 +248,51 @@ func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragCreateFiltertNachLoeschfrist(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
sess := fs.sessions[cookie.Value]
|
||||
accountID := fs.users[sess.UserID].AccountID
|
||||
// fullAntragForm loest Datenklasse "personenbezogen" aus (b1=ja).
|
||||
fs.loeschfristen[accountID] = map[string]int{"personenbezogen": 30}
|
||||
|
||||
fs.werkzeuge["kurze-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "kurze-aufbewahrung", Name: "Kurz-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(14),
|
||||
}
|
||||
fs.werkzeuge["lange-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "lange-aufbewahrung", Name: "Lang-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(90),
|
||||
}
|
||||
fs.werkzeuge["unbekannte-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "unbekannte-aufbewahrung", Name: "Unbekannt-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true,
|
||||
}
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "kurze-aufbewahrung" {
|
||||
t.Fatalf("ZulaessigeWerkzeuge = %v, want [kurze-aufbewahrung]", b.ZulaessigeWerkzeuge)
|
||||
}
|
||||
if len(b.AusgeschlosseneWerkzeuge) != 2 {
|
||||
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want 2 ausgeschlossene", b.AusgeschlosseneWerkzeuge)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -8,8 +9,9 @@ import (
|
||||
)
|
||||
|
||||
type authPageData struct {
|
||||
Title string
|
||||
Error string
|
||||
Title string
|
||||
Error string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
||||
@@ -23,7 +25,11 @@ func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
||||
data := authPageData{Title: "Anmelden"}
|
||||
if r.URL.Query().Get("reset") == "1" {
|
||||
data.Message = "Passwort erfolgreich geändert. Bitte melde dich mit dem neuen Passwort an."
|
||||
}
|
||||
s.renderAuthPage(w, "login", data)
|
||||
}
|
||||
|
||||
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
||||
@@ -54,20 +60,52 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||
// 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)
|
||||
if err != nil {
|
||||
errMsg = "Konto konnte nicht angelegt werden"
|
||||
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")
|
||||
if err != nil {
|
||||
errMsg = "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"
|
||||
return err
|
||||
}
|
||||
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||
errMsg = "Genehmiger-Rollen konnten nicht angelegt werden"
|
||||
return err
|
||||
}
|
||||
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||
errMsg = "Löschfristen konnten nicht angelegt werden"
|
||||
return err
|
||||
}
|
||||
// r.WithContext(ctx): startSession muss in DERSELBEN Transaktion
|
||||
// laufen wie CreateUser darüber — sonst sieht die Session-Zeile
|
||||
// (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 {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
||||
if err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
if errMsg == "" {
|
||||
errMsg = "Registrierung fehlgeschlagen"
|
||||
}
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: errMsg})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
@@ -147,6 +147,20 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Genehmiger-Rollen konnten nicht angelegt werden: " + err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Löschfristen konnten nicht angelegt werden: " + err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -9,18 +9,48 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// standardGenehmigerRollen wird bei jeder neuen Firma automatisch angelegt
|
||||
// (ohne Mitglieder/Freigabe-Regeln) — der Admin muss nur noch Personen
|
||||
// zuordnen und bei Bedarf Freigabe-Regeln ergänzen, statt bei null
|
||||
// anzufangen. Die Beschreibung ist reine Orientierungshilfe im UI; welche
|
||||
// Bedingung welche Rolle tatsächlich auslöst, entscheidet weiterhin jede
|
||||
// Firma selbst über /verwaltung/freigabe-regeln — das System schreibt
|
||||
// keine fachliche Bindung vor.
|
||||
var standardGenehmigerRollen = []struct {
|
||||
Name string
|
||||
Beschreibung string
|
||||
}{
|
||||
{"Datenschutzbeauftragter", "Prüft Anträge mit besonderen Datenschutzanforderungen, z. B. wenn eine Datenschutz-Folgenabschätzung erforderlich ist."},
|
||||
{"Geschäftsführer", "Trifft die unternehmerische Entscheidung bei besonders risikobehafteten Vorhaben."},
|
||||
{"KI-Manager", "Bewertet den Antrag fachlich."},
|
||||
{"CISO", "Prüft den Antrag aus Sicherheitssicht (Auditor)."},
|
||||
}
|
||||
|
||||
// seedStandardGenehmigerRollen wird direkt nach dem Anlegen einer neuen
|
||||
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage).
|
||||
func (s *Server) seedStandardGenehmigerRollen(ctx context.Context, accountID string) error {
|
||||
for _, r := range standardGenehmigerRollen {
|
||||
if _, err := s.store.CreateGenehmigerRolle(ctx, accountID, r.Name, r.Beschreibung); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Admin: Genehmiger-Rollen ───────────────────────────────────────
|
||||
|
||||
type genehmigerRolleItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Mitglieder []nutzerOption
|
||||
ID string
|
||||
Name string
|
||||
Beschreibung string
|
||||
Mitglieder []nutzerOption
|
||||
}
|
||||
|
||||
type nutzerOption struct {
|
||||
@@ -53,7 +83,7 @@ func (s *Server) handleGenehmigerRollenListe(w http.ResponseWriter, r *http.Requ
|
||||
data.Nutzer = append(data.Nutzer, nutzerOption{ID: u.ID, Email: u.Email})
|
||||
}
|
||||
for _, g := range rollen {
|
||||
item := genehmigerRolleItem{ID: g.ID, Name: g.Name}
|
||||
item := genehmigerRolleItem{ID: g.ID, Name: g.Name, Beschreibung: g.Beschreibung}
|
||||
mitglieder, err := s.store.ListNutzerForGenehmigerRolle(r.Context(), g.ID)
|
||||
if err == nil {
|
||||
for _, m := range mitglieder {
|
||||
@@ -77,7 +107,8 @@ func (s *Server) handleGenehmigerRolleCreate(w http.ResponseWriter, r *http.Requ
|
||||
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateGenehmigerRolle(r.Context(), currentUser(r).AccountID, name); err != nil {
|
||||
beschreibung := strings.TrimSpace(r.FormValue("beschreibung"))
|
||||
if _, err := s.store.CreateGenehmigerRolle(r.Context(), currentUser(r).AccountID, name, beschreibung); err != nil {
|
||||
http.Error(w, "Genehmiger-Rolle konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestFreigabeWorkflowGatesBisAlleZugestimmtHaben(t *testing.T) {
|
||||
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||
|
||||
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
|
||||
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
@@ -166,8 +166,8 @@ func TestFreigabeAblehnungKipptGesamtenAntrag(t *testing.T) {
|
||||
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||
|
||||
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
|
||||
gf, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Geschaeftsfuehrung")
|
||||
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||
gf, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Geschaeftsfuehrung", "")
|
||||
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
|
||||
fs.CreateFreigabeRegel(context.Background(), accountID, "datenklasse", "besondere_kategorie", gf.ID)
|
||||
|
||||
@@ -223,7 +223,7 @@ func TestFreigabeEntscheidenOhneRolleWirdAbgelehnt(t *testing.T) {
|
||||
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||
|
||||
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
|
||||
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
|
||||
|
||||
fremdCookie := seedUserInAccount(t, fs, accountID, "fremd@example.com", "mitarbeiter")
|
||||
|
||||
98
internal/web/loeschfrist_handlers.go
Normal file
98
internal/web/loeschfrist_handlers.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Löschfristen je Datenklasse (Migration 0020) — schließt rules/OPEN.md,
|
||||
// Punkt 4. Die Tageswerte sind KEINE gesetzliche Vorgabe (die DSGVO
|
||||
// nennt keine festen Fristen, nur den Grundsatz "so lange wie für den
|
||||
// Zweck nötig", Art. 5 Abs. 1 lit. e) — deshalb pro Mandant einstellbar,
|
||||
// vom Datenschutzbeauftragten hinterlegt, mit risikogestaffelten
|
||||
// Vorschlagswerten vorbelegt. Wie alle anderen /verwaltung/*-Seiten
|
||||
// bewusst admin-only (konsistent mit Nutzer-/Abteilungs-/Genehmiger-
|
||||
// Rollen-Verwaltung) statt an den Namen einer Genehmiger-Rolle
|
||||
// gekoppelt — Genehmiger-Rollen sind laut Architektur explizit ohne
|
||||
// eigenes Zugriffsrecht (siehe CLAUDE.md, Freigabe-Workflow).
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// standardLoeschfristen sind risikogestaffelte Vorschlagswerte, mit
|
||||
// denen jede neue Firma vorbelegt wird — je sensibler die Datenklasse,
|
||||
// desto kürzer die vorgeschlagene Frist. Frei editierbar unter
|
||||
// /verwaltung/loeschfristen, keine Rechtsvorgabe.
|
||||
var standardLoeschfristen = []struct {
|
||||
DatenklasseID string
|
||||
MaxTage int
|
||||
}{
|
||||
{"oeffentlich", 365},
|
||||
{"intern", 180},
|
||||
{"auftragsdaten", 90},
|
||||
{"personenbezogen", 90},
|
||||
{"berufsgeheimnis", 30},
|
||||
{"besondere_kategorie", 30},
|
||||
}
|
||||
|
||||
// seedStandardLoeschfristen wird direkt nach dem Anlegen einer neuen
|
||||
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage),
|
||||
// analog zu seedStandardGenehmigerRollen.
|
||||
func (s *Server) seedStandardLoeschfristen(ctx context.Context, accountID string) error {
|
||||
for _, l := range standardLoeschfristen {
|
||||
if _, err := s.store.UpsertLoeschfristEinstellung(ctx, accountID, l.DatenklasseID, l.MaxTage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type loeschfristZeile struct {
|
||||
DatenklasseID string
|
||||
MaxTage int
|
||||
}
|
||||
|
||||
type loeschfristenListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Zeilen []loeschfristZeile
|
||||
Error string
|
||||
Gespeichert bool
|
||||
}
|
||||
|
||||
func (s *Server) handleLoeschfristenListe(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
einstellungen, err := s.store.ListLoeschfristEinstellungenForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Löschfristen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := loeschfristenListeData{Title: "Löschfristen", Nav: navFor(r), Gespeichert: r.URL.Query().Get("gespeichert") == "1"}
|
||||
for _, e := range einstellungen {
|
||||
data.Zeilen = append(data.Zeilen, loeschfristZeile{DatenklasseID: e.DatenklasseID, MaxTage: e.MaxTage})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "loeschfristen-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLoeschfristenSpeichern(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountID := currentUser(r).AccountID
|
||||
for _, l := range standardLoeschfristen {
|
||||
raw := r.FormValue("max_tage_" + l.DatenklasseID)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
maxTage, err := strconv.Atoi(raw)
|
||||
if err != nil || maxTage < 0 {
|
||||
http.Error(w, "ungültiger Wert für "+l.DatenklasseID, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpsertLoeschfristEinstellung(r.Context(), accountID, l.DatenklasseID, maxTage); err != nil {
|
||||
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/loeschfristen?gespeichert=1", http.StatusSeeOther)
|
||||
}
|
||||
@@ -63,6 +63,24 @@ func withAuthContext(r *http.Request, user store.User, impersonator string) *htt
|
||||
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
|
||||
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
next(w, withAuthContext(r, user, impersonator))
|
||||
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
103
internal/web/passwort_reset_handlers.go
Normal file
103
internal/web/passwort_reset_handlers.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Passwort-Zurücksetzen (Ebene 1, öffentlich) — nutzt internal/mail für
|
||||
// den Versand. Wie beim Login verrät keine Fehlermeldung, ob eine
|
||||
// E-Mail-Adresse überhaupt existiert (User-Enumeration-Schutz): die
|
||||
// Anfrage-Seite zeigt immer dieselbe Erfolgsmeldung, unabhängig davon,
|
||||
// ob tatsächlich eine Mail verschickt wurde.
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
const passwortVergessenHinweis = "Falls diese E-Mail-Adresse bei uns registriert ist, wurde ein Link zum Zurücksetzen verschickt."
|
||||
|
||||
func (s *Server) handlePasswortVergessenForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Error: "ungültiges Formular"})
|
||||
return
|
||||
}
|
||||
email := r.FormValue("email")
|
||||
|
||||
user, err := s.store.GetUserByEmail(r.Context(), email)
|
||||
if err == nil && user.Active {
|
||||
token, tokenErr := auth.NewSessionToken()
|
||||
if tokenErr == nil {
|
||||
if _, createErr := s.store.CreatePasswordResetToken(r.Context(), user.ID, token); createErr == nil {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
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."
|
||||
// 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 {
|
||||
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Message: passwortVergessenHinweis})
|
||||
}
|
||||
|
||||
type passwortZuruecksetzenData struct {
|
||||
Title string
|
||||
Token string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswortZuruecksetzenForm(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
if _, err := s.store.GetValidPasswordResetToken(r.Context(), token); err != nil {
|
||||
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token}); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswortZuruecksetzen(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
resetToken, err := s.store.GetValidPasswordResetToken(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, "ungültiges Formular")
|
||||
return
|
||||
}
|
||||
passwordHash, err := auth.HashPassword(r.FormValue("password"))
|
||||
if err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserPassword(r.Context(), resetToken.UserID, passwordHash); err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, "Passwort konnte nicht gesetzt werden")
|
||||
return
|
||||
}
|
||||
if err := s.store.MarkPasswordResetTokenUsed(r.Context(), resetToken.ID); err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, "Passwort wurde gesetzt, Token konnte aber nicht als verwendet markiert werden")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) renderPasswortZuruecksetzenError(w http.ResponseWriter, token, msg string) {
|
||||
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token, Error: msg}); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
94
internal/web/passwort_reset_handlers_test.go
Normal file
94
internal/web/passwort_reset_handlers_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var resetLinkPattern = regexp.MustCompile(`/passwort-zuruecksetzen/([a-f0-9]+)`)
|
||||
|
||||
func TestPasswortVergessenSendetLinkUndSetztNeuesPasswort(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s, fm := newServerWithMailer(t, fs)
|
||||
|
||||
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||
"account_name": {"Reset-Firma"}, "email": {"reset@example.com"},
|
||||
"password": {"altes-passwort"},
|
||||
})
|
||||
if regResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status = %d, want 303", regResp.Code)
|
||||
}
|
||||
|
||||
vergessenResp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"reset@example.com"}})
|
||||
if vergessenResp.Code != http.StatusOK {
|
||||
t.Fatalf("passwort-vergessen status = %d, want 200", vergessenResp.Code)
|
||||
}
|
||||
if len(fm.Sent) != 1 {
|
||||
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
|
||||
}
|
||||
match := resetLinkPattern.FindStringSubmatch(fm.Sent[0].Body)
|
||||
if match == nil {
|
||||
t.Fatalf("kein Reset-Link in Mail-Body gefunden: %q", fm.Sent[0].Body)
|
||||
}
|
||||
token := match[1]
|
||||
|
||||
getResp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/"+token)
|
||||
if getResp.Code != http.StatusOK {
|
||||
t.Fatalf("get reset form status = %d, want 200", getResp.Code)
|
||||
}
|
||||
|
||||
resetResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"neues-passwort-123"}})
|
||||
if resetResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("reset status = %d, want 303, body: %s", resetResp.Code, resetResp.Body.String())
|
||||
}
|
||||
|
||||
// Derselbe Link darf kein zweites Mal funktionieren.
|
||||
reuseResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"noch-ein-passwort"}})
|
||||
if reuseResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("reuse status = %d, want 404 (Token bereits verwendet)", reuseResp.Code)
|
||||
}
|
||||
|
||||
// Altes Passwort funktioniert nicht mehr.
|
||||
oldLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"altes-passwort"}})
|
||||
if oldLoginResp.Code != http.StatusOK {
|
||||
t.Fatalf("login mit altem Passwort status = %d, want 200 (abgelehnt)", oldLoginResp.Code)
|
||||
}
|
||||
|
||||
// Neues Passwort funktioniert.
|
||||
newLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"neues-passwort-123"}})
|
||||
if newLoginResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login mit neuem Passwort status = %d, want 303", newLoginResp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswortVergessenFuerUnbekannteEmailVerraetNichts(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s, fm := newServerWithMailer(t, fs)
|
||||
|
||||
resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"gibt-es-nicht@example.com"}})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.Code)
|
||||
}
|
||||
if len(fm.Sent) != 0 {
|
||||
t.Fatalf("got %d gesendete Mails für unbekannte E-Mail, want 0", len(fm.Sent))
|
||||
}
|
||||
if !containsHinweis(resp.Body.String()) {
|
||||
t.Fatalf("erwartete generische Erfolgsmeldung, body: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func containsHinweis(body string) bool {
|
||||
return regexp.MustCompile(`Falls diese E-Mail-Adresse`).MatchString(body)
|
||||
}
|
||||
|
||||
func TestPasswortZuruecksetzenMitUngueltigemTokenGibt404(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/nicht-existierendes-token")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/mail"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
@@ -28,6 +29,15 @@ var staticFS embed.FS
|
||||
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||
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)
|
||||
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
|
||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||
@@ -39,6 +49,12 @@ type Store interface {
|
||||
GetUser(ctx context.Context, id string) (store.User, error)
|
||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||
SetUserActive(ctx context.Context, id string, active bool) error
|
||||
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)
|
||||
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
|
||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||
@@ -77,7 +93,7 @@ type Store interface {
|
||||
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
||||
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
|
||||
|
||||
CreateGenehmigerRolle(ctx context.Context, accountID, name string) (store.GenehmigerRolle, error)
|
||||
CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error)
|
||||
GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error)
|
||||
ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error)
|
||||
DeleteGenehmigerRolle(ctx context.Context, id string) error
|
||||
@@ -115,11 +131,15 @@ type Server struct {
|
||||
store Store
|
||||
regelwerk Regelwerk
|
||||
templates *template.Template
|
||||
mailer mail.Mailer
|
||||
}
|
||||
|
||||
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||
// wird einmal beim Start geladen, nicht pro Request.
|
||||
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
// wird einmal beim Start geladen, nicht pro Request. mailer ist
|
||||
// austauschbar (siehe mail.FakeMailer für Tests) — ohne konfigurierten
|
||||
// SMTP-Server schlägt nur der tatsächliche Versand fehl (z. B.
|
||||
// Passwort-Zurücksetzen), der restliche Server bleibt funktionsfähig.
|
||||
func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, error) {
|
||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||
@@ -129,6 +149,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
store: st,
|
||||
regelwerk: regelwerk,
|
||||
templates: tmpl,
|
||||
mailer: mailer,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -139,6 +160,12 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
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 /passwort-vergessen", s.handlePasswortVergessenForm)
|
||||
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
|
||||
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
|
||||
mux.HandleFunc("POST /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzen)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/mail"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
@@ -43,6 +44,8 @@ type fakeStore struct {
|
||||
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
|
||||
freigabeRegeln map[string]store.FreigabeRegel
|
||||
freigabeschritte map[string]store.Freigabeschritt
|
||||
passwordResetTokens map[string]store.PasswordResetToken
|
||||
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
@@ -62,9 +65,22 @@ func newFakeStore() *fakeStore {
|
||||
nutzerGenehmigerRollen: map[string]map[string]bool{},
|
||||
freigabeRegeln: map[string]store.FreigabeRegel{},
|
||||
freigabeschritte: map[string]store.Freigabeschritt{},
|
||||
passwordResetTokens: map[string]store.PasswordResetToken{},
|
||||
loeschfristen: map[string]map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
f.nextID++
|
||||
return fmt.Sprintf("id-%d", f.nextID)
|
||||
@@ -181,6 +197,73 @@ func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.users[id]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
u.PasswordHash = passwordHash
|
||||
f.users[id] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
t := store.PasswordResetToken{
|
||||
ID: f.newID(), UserID: userID, Token: token,
|
||||
ExpiresAt: time.Now().Add(store.PasswordResetTokenDuration), CreatedAt: time.Now(),
|
||||
}
|
||||
f.passwordResetTokens[t.ID] = t
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, t := range f.passwordResetTokens {
|
||||
if t.Token == token && t.UsedAt == nil && t.ExpiresAt.After(time.Now()) {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return store.PasswordResetToken{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
t, ok := f.passwordResetTokens[id]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
t.UsedAt = &now
|
||||
f.passwordResetTokens[id] = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.loeschfristen[accountID] == nil {
|
||||
f.loeschfristen[accountID] = map[string]int{}
|
||||
}
|
||||
f.loeschfristen[accountID][datenklasseID] = maxTage
|
||||
return store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage, UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.LoeschfristEinstellung
|
||||
for datenklasseID, maxTage := range f.loeschfristen[accountID] {
|
||||
out = append(out, store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -556,10 +639,10 @@ func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, account
|
||||
return f.registereintraege[accountID], nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (store.GenehmigerRolle, error) {
|
||||
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()}
|
||||
g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, Beschreibung: beschreibung, CreatedAt: time.Now()}
|
||||
f.genehmigerRollen[g.ID] = g
|
||||
return g, nil
|
||||
}
|
||||
@@ -784,11 +867,18 @@ func loadTestRegelwerk(t *testing.T) web.Regelwerk {
|
||||
|
||||
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||
t.Helper()
|
||||
s, err := web.NewServer(fs, loadTestRegelwerk(t))
|
||||
s, _ := newServerWithMailer(t, fs)
|
||||
return s
|
||||
}
|
||||
|
||||
func newServerWithMailer(t *testing.T, fs *fakeStore) (*web.Server, *mail.FakeMailer) {
|
||||
t.Helper()
|
||||
fm := &mail.FakeMailer{}
|
||||
s, err := web.NewServer(fs, loadTestRegelwerk(t), fm)
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return s
|
||||
return s, fm
|
||||
}
|
||||
|
||||
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
||||
@@ -944,6 +1034,53 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||
"account_name": {"Meine Firma"}, "email": {"neu2@example.com"},
|
||||
"password": {"ein-sicheres-passwort"},
|
||||
})
|
||||
if regResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
||||
}
|
||||
|
||||
var accountID string
|
||||
for _, u := range fs.users {
|
||||
if u.Email == "neu2@example.com" {
|
||||
accountID = u.AccountID
|
||||
}
|
||||
}
|
||||
if accountID == "" {
|
||||
t.Fatal("neuer Nutzer nicht gefunden")
|
||||
}
|
||||
|
||||
rollen, err := fs.ListGenehmigerRollenForAccount(context.Background(), accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListGenehmigerRollenForAccount: %v", err)
|
||||
}
|
||||
wantNamen := []string{"Datenschutzbeauftragter", "Geschäftsführer", "KI-Manager", "CISO"}
|
||||
if len(rollen) != len(wantNamen) {
|
||||
t.Fatalf("got %d Genehmiger-Rollen, want %d", len(rollen), len(wantNamen))
|
||||
}
|
||||
var namen []string
|
||||
for _, r := range rollen {
|
||||
namen = append(namen, r.Name)
|
||||
}
|
||||
for _, want := range wantNamen {
|
||||
found := false
|
||||
for _, n := range namen {
|
||||
if n == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("Standard-Rolle %q wurde nicht angelegt, vorhanden: %v", want, namen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
@@ -1057,4 +1057,17 @@ tbody tr:hover {
|
||||
padding: 32px 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Tabellen-/Listen-/Dashboard-Seiten verschwenden bei 1100px sichtbar
|
||||
Platz auf breiten Bildschirmen, während Tabellen selbst schon
|
||||
horizontal gescrollt/gequetscht werden müssen — .page-wide hebt die
|
||||
Deckelung für genau diese Seiten an. Bewusst 1600px statt "kein
|
||||
Limit": manche dieser Seiten (Fall-/Antrag-Detail) enthalten auch
|
||||
Fließtext-Absätze, die auf einem Ultrawide-Monitor sonst unlesbar
|
||||
lange Zeilen bekämen. Reine Formular-/Auth-Seiten (Login,
|
||||
Registrieren, Einladung, ...) bleiben bei 1100px, dort ist die
|
||||
schmalere Spalte weiterhin die bessere Lesbarkeit. */
|
||||
.page-wide {
|
||||
max-width: 1600px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Abteilungen</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Meine Anträge</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/antraege">← Meine Anträge</a></p>
|
||||
<h1>{{.Titel}}</h1>
|
||||
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/betreiber/accounts">← Alle Accounts</a></p>
|
||||
<h1>{{.Name}}</h1>
|
||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<h1>Plattform</h1>
|
||||
<ul class="stat-cards">
|
||||
<li>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Posteingang</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<p><a href="/faelle">← Posteingang</a></p>
|
||||
<h1>{{.Titel}}</h1>
|
||||
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Freigabe-Regeln</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Meine Freigaben</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Genehmiger-Rollen</h1>
|
||||
@@ -27,6 +27,10 @@
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="z. B. Datenschutzbeauftragter" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="beschreibung">Beschreibung (optional)</label>
|
||||
<input type="text" id="beschreibung" name="beschreibung" placeholder="z. B. Prüft den Antrag aus Sicherheitssicht">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
@@ -42,7 +46,7 @@
|
||||
{{range .Rollen}}
|
||||
{{$rolleID := .ID}}
|
||||
<li>
|
||||
<div class="beteiligter-kopf"><strong>{{.Name}}</strong></div>
|
||||
<div class="beteiligter-kopf"><strong>{{.Name}}</strong>{{if .Beschreibung}} — {{.Beschreibung}}{{end}}</div>
|
||||
{{if .Mitglieder}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Mitglieder}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<h1>Willkommen bei Deklarix</h1>
|
||||
{{if .Nav.IsBetreiber}}
|
||||
<ul class="stat-cards">
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
||||
{{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 .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Abmelden</button>
|
||||
|
||||
43
internal/web/templates/loeschfristen_liste.html
Normal file
43
internal/web/templates/loeschfristen_liste.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "loeschfristen-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Löschfristen</h1>
|
||||
<p class="hinweis">
|
||||
Maximale Aufbewahrungsdauer je Datenklasse, gegen die ein Werkzeug
|
||||
hart geprüft wird (Anforderung <code>loeschfrist_max_tage</code>).
|
||||
Die DSGVO nennt keine festen Fristen, nur den Grundsatz "so lange
|
||||
wie für den Zweck nötig" — die Werte unten sind ein Vorschlag,
|
||||
keine Rechtsvorgabe, und sollten vom Datenschutzbeauftragten
|
||||
geprüft und bei Bedarf angepasst werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Gespeichert}}<p class="hinweis">Löschfristen gespeichert.</p>{{end}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="/verwaltung/loeschfristen">
|
||||
<fieldset class="form-section">
|
||||
<legend>Maximale Aufbewahrungsdauer (Tage)</legend>
|
||||
<div class="form-grid">
|
||||
{{range .Zeilen}}
|
||||
<div>
|
||||
<label for="max_tage_{{.DatenklasseID}}">{{.DatenklasseID}}</label>
|
||||
<input type="number" min="0" id="max_tage_{{.DatenklasseID}}" name="max_tage_{{.DatenklasseID}}" value="{{.MaxTage}}" required>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="page">
|
||||
<h1>Anmelden</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
@@ -15,6 +16,7 @@
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
||||
<p><a href="/passwort-vergessen">Passwort vergessen?</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
|
||||
<div class="page-header">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<h1>Import-Ergebnis</h1>
|
||||
<p class="fehler">
|
||||
Die Einmalpasswörter unten werden nach dieser Seite nirgendwo mehr
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Nutzerverwaltung</h1>
|
||||
|
||||
18
internal/web/templates/passwort_vergessen.html
Normal file
18
internal/web/templates/passwort_vergessen.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{define "passwort-vergessen"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Passwort vergessen</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
||||
<form method="post" action="/passwort-vergessen">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
<button type="submit">Link anfordern</button>
|
||||
</form>
|
||||
<p><a href="/login">Zurück zur Anmeldung</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
16
internal/web/templates/passwort_zuruecksetzen.html
Normal file
16
internal/web/templates/passwort_zuruecksetzen.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{define "passwort-zuruecksetzen"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Neues Passwort setzen</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/passwort-zuruecksetzen/{{.Token}}">
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
<button type="submit">Passwort setzen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Register</h1>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Wiedervorlage</h1>
|
||||
|
||||
@@ -15,3 +15,12 @@ RULES_DIR=/usr/share/deklarix/rules
|
||||
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
||||
# Schleife gegen einen Platzhalter laufen zu lassen.
|
||||
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require
|
||||
|
||||
# Optional — ohne SMTP_HOST startet der Dienst normal, nur der
|
||||
# tatsächliche E-Mail-Versand (aktuell: Passwort-Zurücksetzen) schlägt
|
||||
# dann sichtbar fehl statt eine nie ankommende Mail vorzutäuschen.
|
||||
#SMTP_HOST=smtp.example.com
|
||||
#SMTP_PORT=587
|
||||
#SMTP_USER=
|
||||
#SMTP_PASSWORD=
|
||||
#SMTP_FROM=deklarix@example.com
|
||||
|
||||
@@ -48,18 +48,28 @@ final, nicht mehr Platzhalter. Ob diese drei Varianten fachlich
|
||||
vollständig sind, wurde am 2026-08-29 vom Produktverantwortlichen
|
||||
**bestätigt (keine Ergänzung nötig).**
|
||||
|
||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse — GELÖST (2026-09-01, konfigurierbar statt fest)
|
||||
|
||||
Die Spezifikation nennt `loeschfrist_max_tage` als Anforderung "aus
|
||||
Datenklasse", ohne die konkreten Tageswerte pro Stufe zu nennen.
|
||||
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
|
||||
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
|
||||
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
|
||||
Tage") müssen vor Phase 3 fachlich festgelegt werden. **Auswirkung auf
|
||||
Schritt 3 (`internal/rules.ErfuelltAnforderung`):** ohne Zahlenwerte
|
||||
wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
|
||||
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
||||
muss die Filterfunktion entsprechend erweitert werden.
|
||||
Ursprünglich war unklar, wer die konkreten Tageswerte je Datenklasse
|
||||
festlegt. Recherche (2026-09-01) ergab: die DSGVO selbst nennt **keine**
|
||||
festen Speicherfristen — Art. 5 Abs. 1 lit. e verlangt nur, Daten "so
|
||||
lange wie für den Zweck nötig" zu speichern (Grundsatz der
|
||||
Speicherbegrenzung). Es gibt daher keinen einzelnen "richtigen"
|
||||
Zahlenwert, den man fest in `rules/anforderungen.yaml` hätte eintragen
|
||||
können. Lösung: **pro Mandant einstellbar**, vom Datenschutzbeauftragten
|
||||
festgelegt (`loeschfrist_einstellung`, Migration 0020, `GET/POST
|
||||
/verwaltung/loeschfristen`) statt einer globalen YAML-Konstante. Jede
|
||||
neue Firma wird mit risikogestaffelten Vorschlagswerten vorbelegt
|
||||
(`oeffentlich` 365 / `intern` 180 / `auftragsdaten` 90 / `personenbezogen`
|
||||
90 / `berufsgeheimnis` 30 / `besondere_kategorie` 30 Tage) — eigene
|
||||
Einschätzung, keine Rechtsvorgabe, frei editierbar.
|
||||
|
||||
**Auswirkung auf Schritt 3 (`internal/rules.ErfuelltAnforderung`):** die
|
||||
Anforderung wird jetzt hart gefiltert, sobald für die betroffene
|
||||
Datenklasse eine Frist konfiguriert ist — ein Werkzeug ohne bezifferte
|
||||
Aufbewahrungsdauer erfüllt eine gesetzte Frist NICHT (fail closed, wie
|
||||
bei `eu_verarbeitung`). Ist keine Frist konfiguriert, bleibt das
|
||||
Verhalten wie zuvor (nicht gefiltert).
|
||||
|
||||
## 5. Fallback, wenn keine Datenklasse zutrifft — BESTÄTIGT (2026-08-29)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user