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:
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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user