feat: Löschfristen je Datenklasse, Passwort-Zurücksetzen, DPF-Recherche
- 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 <noreply@anthropic.com>
This commit is contained in:
134
CLAUDE.md
134
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
|
künftigen Katalogeintrag — die Sperre lag ausschließlich am alten
|
||||||
Wertebereich, nicht an einer fachlichen Entscheidung.
|
Wertebereich, nicht an einer fachlichen Entscheidung.
|
||||||
|
|
||||||
**Offen:** `dpf_zertifiziert` ist bei allen 19 Einträgen noch `false` —
|
**`dpf_zertifiziert` recherchiert (2026-09-01), Offener Punkt erledigt.**
|
||||||
für keinen wurde bisher gezielt recherchiert, ob der jeweilige
|
Für alle 20 Einträge wurde gezielt gegen das offizielle Register
|
||||||
US-Anbieter tatsächlich DPF-zertifiziert ist (das Feld wurde erst mit
|
(dataprivacyframework.gov, per Participant-ID einzeln nachvollziehbar)
|
||||||
dieser Migration eingeführt, `false` heißt hier "nicht recherchiert",
|
geprüft — nicht gegen die JS-Suchseite selbst (nicht scrapebar), sondern
|
||||||
nicht "nachweislich nicht zertifiziert"). Nachrecherche pro Anbieter
|
gegen das dahinterliegende, von ihr selbst genutzte API. Ergebnis: 12
|
||||||
noch zu tun.
|
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
|
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
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**
|
**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt**
|
||||||
(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
|
(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
|
||||||
3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst
|
3). Zum Zeitpunkt dieser Umsetzung gab es noch keine E-Mail-Infrastruktur
|
||||||
eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort,
|
(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
|
wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
|
||||||
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
||||||
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
|
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
|
## Onboarding
|
||||||
|
|
||||||
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
|
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
|
||||||
@@ -1193,8 +1286,9 @@ journalctl -u deklarix -f
|
|||||||
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
|
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
|
||||||
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
|
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
|
||||||
Rückmeldung dazu bisher eingeholt.
|
Rückmeldung dazu bisher eingeholt.
|
||||||
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
- ~~Löschfristen je Datenklasse~~ — **erledigt 2026-09-01**, siehe
|
||||||
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
„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)**
|
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||||
— die Frontend-Spezifikation fordert das explizit ("nicht in der
|
— die Frontend-Spezifikation fordert das explizit ("nicht in der
|
||||||
Anwendungslogik"), aktuell ist Isolation ausschließlich
|
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
|
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
|
||||||
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
|
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
|
||||||
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
|
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
|
||||||
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
|
- ~~Zentraler Werkzeugkatalog muss noch befüllt werden~~ — **erledigt**,
|
||||||
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
|
20 recherchierte Einträge seit 2026-08-31 (siehe „Werkzeugkatalog"
|
||||||
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
|
weiter oben). ~~`dpf_zertifiziert` recherchieren~~ — **erledigt
|
||||||
anlegen, `GET/POST /betreiber/werkzeuge/{id}` bearbeiten, `POST
|
2026-09-01** (siehe dort für Details je Anbieter). "Werkzeugkatalog aus
|
||||||
.../loeschen` löschen — siehe `internal/web/betreiber_werkzeug_handlers.go`.
|
der Plattform vorbelegt" beim Firma-Onboarding bleibt weiterhin nicht
|
||||||
Bearbeitet ausschließlich `account_id IS NULL`-Einträge, ein
|
automatisiert — jede neue Firma sieht sofort den vollen zentralen
|
||||||
mandantenspezifischer Eintrag ist über diese Seiten nicht erreichbar
|
Katalog (kein Kopiervorgang nötig, `account_id IS NULL` ist für alle
|
||||||
[404]). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma-
|
Mandanten gleich sichtbar), es gibt nur keine mandantenspezifische
|
||||||
Onboarding bleibt trotzdem nicht automatisiert — der Katalog ist
|
Vorauswahl/Kuratierung beim Onboarding.
|
||||||
inhaltlich weiterhin leer, bis der Betreiber tatsächlich Einträge
|
|
||||||
pflegt.
|
|
||||||
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.
|
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/mail"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"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)",
|
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||||
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
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{
|
server, err := web.NewServer(db, web.Regelwerk{
|
||||||
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||||
})
|
}, mailer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("web server: %v", err)
|
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
|
Verarbeitungslaender []string
|
||||||
TrainingOptOut bool
|
TrainingOptOut bool
|
||||||
TrainingStandard 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
|
// 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,
|
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||||
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
||||||
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
||||||
// Werkzeug selbst. loeschfrist_max_tage hat noch keine fachlich
|
// Werkzeug selbst.
|
||||||
// festgelegten Tageswerte (rules/OPEN.md, Punkt 4) und wird deshalb
|
//
|
||||||
// ebenfalls noch nicht hart gefiltert.
|
// loeschfristMaxTage ist die vom Mandanten für die aktuelle Datenklasse
|
||||||
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
// 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 {
|
switch anforderungID {
|
||||||
case "avv_erforderlich":
|
case "avv_erforderlich":
|
||||||
return w.AVVVerfuegbar
|
return w.AVVVerfuegbar
|
||||||
@@ -245,6 +253,14 @@ func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
|||||||
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
||||||
case "kein_training_auf_eingabe":
|
case "kein_training_auf_eingabe":
|
||||||
return w.TrainingStandard
|
return w.TrainingStandard
|
||||||
|
case "loeschfrist_max_tage":
|
||||||
|
if loeschfristMaxTage == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if w.AufbewahrungTage == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return *w.AufbewahrungTage <= *loeschfristMaxTage
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -266,12 +282,13 @@ type WerkzeugKandidat struct {
|
|||||||
|
|
||||||
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
||||||
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
||||||
// Werkzeug fest, welche Anforderungen fehlten.
|
// Werkzeug fest, welche Anforderungen fehlten. loeschfristMaxTage siehe
|
||||||
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
// ErfuelltAnforderung.
|
||||||
|
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string, loeschfristMaxTage *int) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||||
for _, k := range kandidaten {
|
for _, k := range kandidaten {
|
||||||
var fehlend []string
|
var fehlend []string
|
||||||
for _, reqID := range anforderungIDs {
|
for _, reqID := range anforderungIDs {
|
||||||
if !ErfuelltAnforderung(reqID, k.Eigenschaften) {
|
if !ErfuelltAnforderung(reqID, k.Eigenschaften, loeschfristMaxTage) {
|
||||||
fehlend = append(fehlend, reqID)
|
fehlend = append(fehlend, reqID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,31 +150,52 @@ func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestErfuelltAnforderungAVV(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")
|
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")
|
t.Error("erwartet: erfuellt mit AVV")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
|
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")
|
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")
|
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")
|
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")
|
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) {
|
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")
|
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: "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}},
|
{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" {
|
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||||
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
||||||
}
|
}
|
||||||
@@ -198,7 +219,7 @@ func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
|||||||
|
|
||||||
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
||||||
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
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 {
|
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
||||||
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
||||||
}
|
}
|
||||||
|
|||||||
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.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
|
||||||
|
}
|
||||||
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 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
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.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
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,20 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
|
|||||||
return out, nil
|
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
|
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
||||||
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
||||||
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||||
|
|||||||
@@ -290,16 +290,25 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
|
|||||||
}
|
}
|
||||||
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||||
for i, w := range 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{
|
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
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
|
in.ZulaessigeWerkzeuge = zulaessig
|
||||||
for _, a := range ausgeschlossen {
|
for _, a := range ausgeschlossen {
|
||||||
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
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) {
|
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type authPageData struct {
|
type authPageData struct {
|
||||||
Title string
|
Title string
|
||||||
Error string
|
Error string
|
||||||
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
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) {
|
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-
|
// 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"})
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Genehmiger-Rollen konnten nicht angelegt werden"})
|
||||||
return
|
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 {
|
if err := s.startSession(w, r, user.ID); err != nil {
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
||||||
|
|||||||
@@ -154,6 +154,13 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
return
|
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 {
|
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)
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
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"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/mail"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
@@ -39,6 +40,12 @@ type Store interface {
|
|||||||
GetUser(ctx context.Context, id string) (store.User, error)
|
GetUser(ctx context.Context, id string) (store.User, error)
|
||||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||||
SetUserActive(ctx context.Context, id string, active bool) 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)
|
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)
|
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
||||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||||
@@ -115,11 +122,15 @@ type Server struct {
|
|||||||
store Store
|
store Store
|
||||||
regelwerk Regelwerk
|
regelwerk Regelwerk
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
|
mailer mail.Mailer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||||
// wird einmal beim Start geladen, nicht pro Request.
|
// wird einmal beim Start geladen, nicht pro Request. mailer ist
|
||||||
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
// 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")
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||||
@@ -129,6 +140,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
store: st,
|
store: st,
|
||||||
regelwerk: regelwerk,
|
regelwerk: regelwerk,
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
|
mailer: mailer,
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -139,6 +151,12 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
||||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||||
mux.HandleFunc("POST /login", s.handleLogin)
|
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("POST /logout", s.handleLogout)
|
||||||
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||||
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"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/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
@@ -43,6 +44,8 @@ type fakeStore struct {
|
|||||||
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
|
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
|
||||||
freigabeRegeln map[string]store.FreigabeRegel
|
freigabeRegeln map[string]store.FreigabeRegel
|
||||||
freigabeschritte map[string]store.Freigabeschritt
|
freigabeschritte map[string]store.Freigabeschritt
|
||||||
|
passwordResetTokens map[string]store.PasswordResetToken
|
||||||
|
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeStore() *fakeStore {
|
func newFakeStore() *fakeStore {
|
||||||
@@ -62,6 +65,8 @@ func newFakeStore() *fakeStore {
|
|||||||
nutzerGenehmigerRollen: map[string]map[string]bool{},
|
nutzerGenehmigerRollen: map[string]map[string]bool{},
|
||||||
freigabeRegeln: map[string]store.FreigabeRegel{},
|
freigabeRegeln: map[string]store.FreigabeRegel{},
|
||||||
freigabeschritte: map[string]store.Freigabeschritt{},
|
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
|
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) {
|
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -784,11 +856,18 @@ func loadTestRegelwerk(t *testing.T) web.Regelwerk {
|
|||||||
|
|
||||||
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewServer: %v", err)
|
t.Fatalf("NewServer: %v", err)
|
||||||
}
|
}
|
||||||
return s
|
return s, fm
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
{{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/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
|
||||||
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</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}}
|
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<button type="submit">Abmelden</button>
|
<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">
|
<div class="page">
|
||||||
<h1>Anmelden</h1>
|
<h1>Anmelden</h1>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
||||||
<form method="post" action="/login">
|
<form method="post" action="/login">
|
||||||
<label for="email">E-Mail</label>
|
<label for="email">E-Mail</label>
|
||||||
<input type="email" id="email" name="email" required>
|
<input type="email" id="email" name="email" required>
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
<button type="submit">Anmelden</button>
|
<button type="submit">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
||||||
|
<p><a href="/passwort-vergessen">Passwort vergessen?</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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}}
|
||||||
@@ -15,3 +15,12 @@ RULES_DIR=/usr/share/deklarix/rules
|
|||||||
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
||||||
# Schleife gegen einen Platzhalter laufen zu lassen.
|
# Schleife gegen einen Platzhalter laufen zu lassen.
|
||||||
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require
|
#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
|
vollständig sind, wurde am 2026-08-29 vom Produktverantwortlichen
|
||||||
**bestätigt (keine Ergänzung nötig).**
|
**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
|
Ursprünglich war unklar, wer die konkreten Tageswerte je Datenklasse
|
||||||
Datenklasse", ohne die konkreten Tageswerte pro Stufe zu nennen.
|
festlegt. Recherche (2026-09-01) ergab: die DSGVO selbst nennt **keine**
|
||||||
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
|
festen Speicherfristen — Art. 5 Abs. 1 lit. e verlangt nur, Daten "so
|
||||||
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
|
lange wie für den Zweck nötig" zu speichern (Grundsatz der
|
||||||
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
|
Speicherbegrenzung). Es gibt daher keinen einzelnen "richtigen"
|
||||||
Tage") müssen vor Phase 3 fachlich festgelegt werden. **Auswirkung auf
|
Zahlenwert, den man fest in `rules/anforderungen.yaml` hätte eintragen
|
||||||
Schritt 3 (`internal/rules.ErfuelltAnforderung`):** ohne Zahlenwerte
|
können. Lösung: **pro Mandant einstellbar**, vom Datenschutzbeauftragten
|
||||||
wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
|
festgelegt (`loeschfrist_einstellung`, Migration 0020, `GET/POST
|
||||||
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
/verwaltung/loeschfristen`) statt einer globalen YAML-Konstante. Jede
|
||||||
muss die Filterfunktion entsprechend erweitert werden.
|
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)
|
## 5. Fallback, wenn keine Datenklasse zutrifft — BESTÄTIGT (2026-08-29)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user