From 8a8295dacd595225901f3ae876fd05143034786c Mon Sep 17 00:00:00 2001 From: noroot Date: Tue, 1 Sep 2026 07:38:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20L=C3=B6schfristen=20je=20Datenklasse,?= =?UTF-8?q?=20Passwort-Zur=C3=BCcksetzen,=20DPF-Recherche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Löschfristen (Migration 0020): pro Mandant einstellbar statt fest im Regelwerk, da die DSGVO selbst keine festen Fristen nennt (Art. 5 Abs. 1 lit. e). Jede Firma wird mit risikogestaffelten Vorschlagswerten vorbelegt, loeschfrist_max_tage wird jetzt tatsächlich hart gegen Werkzeuge gefiltert (schließt rules/OPEN.md Punkt 4). - Neues internal/mail-Paket (SMTP-Versand + Test-Doppel) und darauf aufbauend Passwort-Zurücksetzen (Migration 0019) - bisher nur als Absicht in der Rollentabelle genannt, nie gebaut. - dpf_zertifiziert für alle 20 Katalogeinträge gegen das offizielle DPF-Register recherchiert und in der Produktions-DB aktualisiert (12 zertifiziert, 5 recherchiert-nicht-gefunden, 3 nicht anwendbar). Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 134 +++++++++++++++--- cmd/deklarix/main.go | 11 +- internal/mail/mail.go | 78 ++++++++++ internal/rules/evaluate.go | 33 +++-- internal/rules/evaluate_test.go | 39 +++-- internal/store/loeschfrist.go | 69 +++++++++ internal/store/loeschfrist_test.go | 41 ++++++ .../0019_password_reset_token.down.sql | 1 + .../0019_password_reset_token.up.sql | 10 ++ .../0020_loeschfrist_einstellung.down.sql | 1 + .../0020_loeschfrist_einstellung.up.sql | 8 ++ internal/store/password_reset.go | 77 ++++++++++ internal/store/password_reset_test.go | 58 ++++++++ internal/store/user.go | 14 ++ internal/web/antrag_handlers.go | 21 ++- internal/web/antrag_handlers_test.go | 45 ++++++ internal/web/auth_handlers.go | 15 +- internal/web/betreiber_handlers.go | 7 + internal/web/loeschfrist_handlers.go | 98 +++++++++++++ internal/web/passwort_reset_handlers.go | 103 ++++++++++++++ internal/web/passwort_reset_handlers_test.go | 94 ++++++++++++ internal/web/server.go | 22 ++- internal/web/server_test.go | 83 ++++++++++- internal/web/templates/layout.html | 1 + .../web/templates/loeschfristen_liste.html | 43 ++++++ internal/web/templates/login.html | 2 + .../web/templates/passwort_vergessen.html | 18 +++ .../web/templates/passwort_zuruecksetzen.html | 16 +++ packaging/etc/deklarix/deklarix.env.example | 9 ++ rules/OPEN.md | 32 +++-- 30 files changed, 1120 insertions(+), 63 deletions(-) create mode 100644 internal/mail/mail.go create mode 100644 internal/store/loeschfrist.go create mode 100644 internal/store/loeschfrist_test.go create mode 100644 internal/store/migrations/0019_password_reset_token.down.sql create mode 100644 internal/store/migrations/0019_password_reset_token.up.sql create mode 100644 internal/store/migrations/0020_loeschfrist_einstellung.down.sql create mode 100644 internal/store/migrations/0020_loeschfrist_einstellung.up.sql create mode 100644 internal/store/password_reset.go create mode 100644 internal/store/password_reset_test.go create mode 100644 internal/web/loeschfrist_handlers.go create mode 100644 internal/web/passwort_reset_handlers.go create mode 100644 internal/web/passwort_reset_handlers_test.go create mode 100644 internal/web/templates/loeschfristen_liste.html create mode 100644 internal/web/templates/passwort_vergessen.html create mode 100644 internal/web/templates/passwort_zuruecksetzen.html diff --git a/CLAUDE.md b/CLAUDE.md index ea4644f..24804fd 100644 --- a/CLAUDE.md +++ b/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 @@ -617,6 +633,83 @@ und live per curl gegen echten Server + Postgres verifiziert. --- +## 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 **Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular @@ -1193,8 +1286,9 @@ 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. +- ~~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)** — die Frontend-Spezifikation fordert das explizit ("nicht in der Anwendungslogik"), aktuell ist Isolation ausschließlich @@ -1216,15 +1310,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. diff --git a/cmd/deklarix/main.go b/cmd/deklarix/main.go index 400e902..ae4a87d 100644 --- a/cmd/deklarix/main.go +++ b/cmd/deklarix/main.go @@ -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" @@ -51,9 +52,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) } diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..37753c3 --- /dev/null +++ b/internal/mail/mail.go @@ -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 +} diff --git a/internal/rules/evaluate.go b/internal/rules/evaluate.go index 4ef23f8..6ac0fe0 100644 --- a/internal/rules/evaluate.go +++ b/internal/rules/evaluate.go @@ -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) } } diff --git a/internal/rules/evaluate_test.go b/internal/rules/evaluate_test.go index 67a6fbc..ee09182 100644 --- a/internal/rules/evaluate_test.go +++ b/internal/rules/evaluate_test.go @@ -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) } diff --git a/internal/store/loeschfrist.go b/internal/store/loeschfrist.go new file mode 100644 index 0000000..2384b74 --- /dev/null +++ b/internal/store/loeschfrist.go @@ -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.Pool.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.Pool.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 +} diff --git a/internal/store/loeschfrist_test.go b/internal/store/loeschfrist_test.go new file mode 100644 index 0000000..4936a69 --- /dev/null +++ b/internal/store/loeschfrist_test.go @@ -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) + } + } +} diff --git a/internal/store/migrations/0019_password_reset_token.down.sql b/internal/store/migrations/0019_password_reset_token.down.sql new file mode 100644 index 0000000..950a83e --- /dev/null +++ b/internal/store/migrations/0019_password_reset_token.down.sql @@ -0,0 +1 @@ +DROP TABLE password_reset_token; diff --git a/internal/store/migrations/0019_password_reset_token.up.sql b/internal/store/migrations/0019_password_reset_token.up.sql new file mode 100644 index 0000000..a155242 --- /dev/null +++ b/internal/store/migrations/0019_password_reset_token.up.sql @@ -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); diff --git a/internal/store/migrations/0020_loeschfrist_einstellung.down.sql b/internal/store/migrations/0020_loeschfrist_einstellung.down.sql new file mode 100644 index 0000000..1e21d3a --- /dev/null +++ b/internal/store/migrations/0020_loeschfrist_einstellung.down.sql @@ -0,0 +1 @@ +DROP TABLE loeschfrist_einstellung; diff --git a/internal/store/migrations/0020_loeschfrist_einstellung.up.sql b/internal/store/migrations/0020_loeschfrist_einstellung.up.sql new file mode 100644 index 0000000..45da62a --- /dev/null +++ b/internal/store/migrations/0020_loeschfrist_einstellung.up.sql @@ -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) +); diff --git a/internal/store/password_reset.go b/internal/store/password_reset.go new file mode 100644 index 0000000..3d0d44b --- /dev/null +++ b/internal/store/password_reset.go @@ -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.Pool.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.Pool.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.Pool.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 +} diff --git a/internal/store/password_reset_test.go b/internal/store/password_reset_test.go new file mode 100644 index 0000000..4de7de5 --- /dev/null +++ b/internal/store/password_reset_test.go @@ -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) + } +} diff --git a/internal/store/user.go b/internal/store/user.go index 092a2ab..218198f 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -108,6 +108,20 @@ 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.Pool.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 diff --git a/internal/web/antrag_handlers.go b/internal/web/antrag_handlers.go index e201b61..c81239b 100644 --- a/internal/web/antrag_handlers.go +++ b/internal/web/antrag_handlers.go @@ -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}) diff --git a/internal/web/antrag_handlers_test.go b/internal/web/antrag_handlers_test.go index d951eb3..5ab2813 100644 --- a/internal/web/antrag_handlers_test.go +++ b/internal/web/antrag_handlers_test.go @@ -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) diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index bd1882d..1641e6d 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -8,8 +8,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 +24,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- @@ -70,6 +75,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Genehmiger-Rollen konnten nicht angelegt werden"}) return } + if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil { + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Löschfristen konnten nicht angelegt werden"}) + return + } if err := s.startSession(w, r, user.ID); err != nil { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"}) diff --git a/internal/web/betreiber_handlers.go b/internal/web/betreiber_handlers.go index b65af5a..b802b5c 100644 --- a/internal/web/betreiber_handlers.go +++ b/internal/web/betreiber_handlers.go @@ -154,6 +154,13 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req } 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 diff --git a/internal/web/loeschfrist_handlers.go b/internal/web/loeschfrist_handlers.go new file mode 100644 index 0000000..60222e9 --- /dev/null +++ b/internal/web/loeschfrist_handlers.go @@ -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) +} diff --git a/internal/web/passwort_reset_handlers.go b/internal/web/passwort_reset_handlers.go new file mode 100644 index 0000000..a55ebfb --- /dev/null +++ b/internal/web/passwort_reset_handlers.go @@ -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) + } +} diff --git a/internal/web/passwort_reset_handlers_test.go b/internal/web/passwort_reset_handlers_test.go new file mode 100644 index 0000000..81ac036 --- /dev/null +++ b/internal/web/passwort_reset_handlers_test.go @@ -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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 6c86e06..95ffd65 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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" ) @@ -39,6 +40,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) @@ -115,11 +122,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 +140,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { store: st, regelwerk: regelwerk, templates: tmpl, + mailer: mailer, } mux := http.NewServeMux() @@ -139,6 +151,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)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 9e339e7..f6e2714 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -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,6 +65,8 @@ 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{}, } } @@ -181,6 +186,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() @@ -784,11 +856,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 diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index f090608..bddbef4 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -34,6 +34,7 @@ {{if .IsAdmin}}Werkzeugkatalog{{end}} {{if .IsAdmin}}Genehmiger-Rollen{{end}} {{if .IsAdmin}}Freigabe-Regeln{{end}} + {{if .IsAdmin}}Löschfristen{{end}} {{if .IsBetreiber}}Plattform{{end}}
diff --git a/internal/web/templates/loeschfristen_liste.html b/internal/web/templates/loeschfristen_liste.html new file mode 100644 index 0000000..f245eb9 --- /dev/null +++ b/internal/web/templates/loeschfristen_liste.html @@ -0,0 +1,43 @@ +{{define "loeschfristen-liste"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+ +{{if .Error}}

{{.Error}}

{{end}} +{{if .Gespeichert}}

Löschfristen gespeichert.

{{end}} +
+ +
+ Maximale Aufbewahrungsdauer (Tage) +
+ {{range .Zeilen}} +
+ + +
+ {{end}} +
+
+
+ +
+ +
+
+ + +{{end}} diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html index 0171e5d..9e8f508 100644 --- a/internal/web/templates/login.html +++ b/internal/web/templates/login.html @@ -5,6 +5,7 @@

Anmelden

{{if .Error}}

{{.Error}}

{{end}} +{{if .Message}}

{{.Message}}

{{end}}
@@ -15,6 +16,7 @@

Noch kein Konto? Registrieren

+

Passwort vergessen?

diff --git a/internal/web/templates/passwort_vergessen.html b/internal/web/templates/passwort_vergessen.html new file mode 100644 index 0000000..b627635 --- /dev/null +++ b/internal/web/templates/passwort_vergessen.html @@ -0,0 +1,18 @@ +{{define "passwort-vergessen"}} + +{{template "head" .}} + +
+

Passwort vergessen

+{{if .Error}}

{{.Error}}

{{end}} +{{if .Message}}

{{.Message}}

{{end}} +
+ + + +
+

Zurück zur Anmeldung

+
+ + +{{end}} diff --git a/internal/web/templates/passwort_zuruecksetzen.html b/internal/web/templates/passwort_zuruecksetzen.html new file mode 100644 index 0000000..8aa06b0 --- /dev/null +++ b/internal/web/templates/passwort_zuruecksetzen.html @@ -0,0 +1,16 @@ +{{define "passwort-zuruecksetzen"}} + +{{template "head" .}} + +
+

Neues Passwort setzen

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + +
+
+ + +{{end}} diff --git a/packaging/etc/deklarix/deklarix.env.example b/packaging/etc/deklarix/deklarix.env.example index f1e2fe3..aace7e6 100644 --- a/packaging/etc/deklarix/deklarix.env.example +++ b/packaging/etc/deklarix/deklarix.env.example @@ -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 diff --git a/rules/OPEN.md b/rules/OPEN.md index 2fe0ee4..9bd99c8 100644 --- a/rules/OPEN.md +++ b/rules/OPEN.md @@ -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)