diff --git a/features/INDEX.md b/features/INDEX.md index 3c27c22..16560f0 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -59,10 +59,10 @@ | PROJ-40 | Prometheus Metriken + Health-Check | Deployed | [PROJ-40](PROJ-40-prometheus-metriken.md) | 2026-04-05 | | PROJ-41 | Dashboard Zeitreihe + Speicherprognose | Deployed | [PROJ-41](PROJ-41-dashboard-zeitreihe.md) | 2026-04-05 | | PROJ-42 | Gespeicherte Suchanfragen | Deployed | [PROJ-42](PROJ-42-gespeicherte-suchanfragen.md) | 2026-04-05 | -| PROJ-43 | Automatische Archivierungsregeln | Planned | [PROJ-43](PROJ-43-archivierungsregeln.md) | 2026-04-05 | +| PROJ-43 | Automatische Archivierungsregeln | In Review | [PROJ-43](PROJ-43-archivierungsregeln.md) | 2026-04-05 | | PROJ-44 | OCR-GUI-Integration (Status, Download, Such-Highlight) | Deployed | [PROJ-44](PROJ-44-ocr-gui-integration.md) | 2026-05-08 | | PROJ-45 | IMAP Per-Folder UID-Tracking + UIDVALIDITY-Check | Deployed | [PROJ-45](PROJ-45-imap-folder-uid-tracking.md) | 2026-05-11 | -| PROJ-46 | E-Mail als primärer Login-Identifier für Tenant-User | Planned | [PROJ-46](PROJ-46-email-login-tenant-user.md) | 2026-06-13 | +| PROJ-46 | E-Mail als primärer Login-Identifier für Tenant-User | In Review | [PROJ-46](PROJ-46-email-login-tenant-user.md) | 2026-07-03 | | PROJ-47 | Tenant-Voll-Export per CLI | Deployed | [PROJ-47](PROJ-47-tenant-voll-export-cli.md) | 2026-06-13 | | PROJ-48 | Audit-Log Unveränderbarkeit (Nachbesserung PROJ-11) | Deployed | [PROJ-48](PROJ-48-audit-log-unveraenderbarkeit.md) | 2026-06-13 | | PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | Deployed | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 | diff --git a/features/PROJ-43-archivierungsregeln.md b/features/PROJ-43-archivierungsregeln.md new file mode 100644 index 0000000..10f48c4 --- /dev/null +++ b/features/PROJ-43-archivierungsregeln.md @@ -0,0 +1,112 @@ +--- +id: PROJ-43 +title: Automatische Archivierungsregeln (by Domain/Sender) +status: In Review +created: 2026-04-05 +--- + +## Kontext + +Das SMTP Domain-Routing (PROJ-21 Phase 5) ist bereits implementiert: +- `tenant_domains`-Tabelle ordnet Domains Mandanten zu +- `resolveTenantFromRcpts()` im SMTP-Daemon weist Mails automatisch zu +- IMAP/POP3-Importer unterstützen ebenfalls TenantID + +PROJ-43 **erweitert** diese Basis um flexiblere Muster-Regeln und eine GUI. + +## Ziel + +Admins können über die Web-Oberfläche Regeln verwalten die über einfache Domain-Zuordnung +hinausgehen — z.B. Wildcard-Domains, Absender-Adressen, Betreff-Muster. + +## User Stories + +- Als Admin möchte ich alle Mails von @kunde.de automatisch dem Mandanten "Kunde GmbH" zuweisen +- Als Admin möchte ich Mails an archiv@firma.de einem bestimmten Tenant zuordnen + +## Namenskonflikt (Nachtrag 2026-07-03) + +PROJ-51 (Retention-Kategorien) hat bereits eine Tabelle `archiving_rules` angelegt +(`internal/storage/retention_rules.go`, Spalten `condition_type`, `pattern`, +`priority`, `retention_days`) — andere Bedeutung (Aufbewahrungsfrist statt +Tenant-Zuordnung). Um Kollision zu vermeiden, heißt die neue Tabelle für PROJ-43 +**`tenant_routing_rules`**, nicht `archiving_rules` wie ursprünglich benannt. + +## Acceptance Criteria + +- [ ] Tabelle `tenant_routing_rules (id, tenant_id, match_type [from_domain|to_domain|from_addr|to_addr], pattern, priority, created_at)` +- [ ] SMTP-Daemon und IMAP-Import prüfen Regeln nach jedem eingehenden Mail +- [ ] API: CRUD für tenant_routing_rules (Admin only) +- [ ] Frontend: Regel-Verwaltung im Admin-Bereich +- [ ] Priorität: höhere Priorität gewinnt bei mehreren Treffern +- [ ] Dry-Run: zeigt welche bestehenden Mails von einer Regel betroffen wären + +## Betroffene Dateien + +- `internal/storage/storage.go` bzw. neues `internal/storage/tenant_routing_rules.go` (DB-Schema + ApplyRules-Methode, analog `retention_rules.go`) +- `internal/smtpd/smtpd.go` (Regel-Check nach Save, ergänzt bestehendes `resolveTenantFromRcpts()` aus PROJ-21 Phase 5) +- `internal/imap/importer.go` (Regel-Check nach Import) +- `internal/api/rules_handlers.go` (neu) +- `src/app/admin/` (Regel-UI) + +## Implementierungsnotizen (2026-07-03, Status: In Review) + +Kein lokaler `go build` möglich (kein Go-Toolchain im Arbeitsverzeichnis) — Build/Tests +laufen separat auf dem Testserver. + +### Neue/geänderte Dateien +- `internal/storage/tenant_routing_rules.go` (NEU) — Tabelle `tenant_routing_rules`, + CRUD, Matching-Engine `ResolveTenantByRoutingRules()`, Dry-Run `DryRunRoutingRule()`. + Stil analog `retention_rules.go`. Match-Typen: `from_domain`, `to_domain`, + `from_addr`, `to_addr`. Wildcard-Domains via `*.kunde.de` (matcht Sub-Domains). +- `internal/storage/storage.go` — `initTenantRoutingRulesSchema(ctx)` in Init-Kette. +- `internal/smtpd/smtpd.go` — `resolveTenantByRules()` neu; in `resolveTenant()` als + Stufe 0 VOR der `tenant_domains`-Logik (Regeln sind expliziter → Vorrang). +- `internal/imap/importer.go` — `storeAndIndex()`: Mail wird jetzt früh geparst, + Regel-Match kann die Account-Default-TenantID vor dem Save überschreiben. +- `internal/api/rules_handlers.go` (NEU) — CRUD + Dry-Run, `authAdmin` (domain_admin+), + Tenant-Scoping + IDOR-Check (`tenantAccessAllowed`) bei jedem `{id}`. +- `internal/api/server.go` — Routen registriert. + +### Tabelle +`tenant_routing_rules (id, tenant_id [NOT NULL, FK tenants ON DELETE CASCADE], +match_type, pattern, priority, created_at)`. Höhere `priority` gewinnt, bei +Gleichstand niedrigste `id`. + +### API-Endpunkte (alle domain_admin+; superadmin = alle Tenants, domain_admin = eigener) +- `GET /api/admin/routing-rules` → `{ "rules": RoutingRule[] }` +- `POST /api/admin/routing-rules` Body `{tenant_id?, match_type, pattern, priority}` + → `201 {"id": }` (superadmin muss `tenant_id` setzen; domain_admin bekommt + eigenen Tenant erzwungen) +- `PUT /api/admin/routing-rules/{id}` gleicher Body → `200 {"ok": true}` +- `DELETE /api/admin/routing-rules/{id}` → `200 {"ok": true}` +- `POST /api/admin/routing-rules/dry-run` Body entweder `{rule_id}` ODER + `{match_type, pattern}` (+ optional `limit`, default 20, max 100) → + `200 {match_type, pattern, match_count, sample_limit, sample: [{id, mail_from, + mail_to, subject, received_at}]}`. Dry-Run ist ILIKE-Näherung gegen `emails`, + LIMIT-begrenzt (kein Vollscan-Timeout); domain_admin sieht nur eigene Tenant-Mails. + +RoutingRule-Shape: `{id, tenant_id, match_type, pattern, priority, created_at}`. + +### Frontend (2026-07-03, Status: In Review — QA offen) +- `src/lib/api/routing_rules.ts` (NEU) — TS-Typen (`RoutingRule`, `RoutingRuleInput`, + `RoutingMatchType`, `RoutingDryRunResult/Input/Sample`) + API-Funktionen + `getRoutingRules`, `createRoutingRule`, `updateRoutingRule`, `deleteRoutingRule`, + `dryRunRoutingRule`. Nutzt zentralen `request()`-Wrapper aus `core.ts`. +- `src/lib/api/index.ts` — Re-exports der neuen Typen + Funktionen ergänzt. +- `src/components/admin/tabs/RoutingRulesTab.tsx` (NEU) — Tab „Routing-Regeln": + Tabelle (Prio, Typ, Muster, Angelegt; bei superadmin zusätzlich Tenant-Spalte), + CRUD via Dialog (match_type-Select, pattern-Input mit typabhängigem Placeholder, + priority-Input, bei superadmin Tenant-Auswahl-Dropdown), Löschen mit Bestätigung. + Dry-Run-Button im Dialog ruft `dryRunRoutingRule({match_type, pattern})` und zeigt + `match_count` + Stichproben-Tabelle. Prioritäts-Erklärung + Hinweis „keine + rückwirkende Umroutung" als Alert. Loading/Error/Empty-States implementiert. +- `src/app/admin/page.tsx` — Tab-Trigger + `` + eingebunden; Tab für alle Admin-Seiten-Besucher sichtbar (Seite ist bereits per + `useAuth("domain_admin")` auf domain_admin+ gegated). `isSuperAdmin`-Prop steuert + Tenant-Spalte/-Dropdown; serverseitiges Scoping bleibt maßgeblich. +- Typecheck: `npx tsc --noEmit` → sauber (Exit 0). + +### Offen / Handoff +- QA gegen Acceptance Criteria (CRUD, Dry-Run, Rollen-Gate) auf Testserver. +- Bereits archivierte Mails werden NICHT rückwirkend umgeroutet (nur neue Ingests). diff --git a/features/PROJ-46-email-login-tenant-user.md b/features/PROJ-46-email-login-tenant-user.md new file mode 100644 index 0000000..f58d0e8 --- /dev/null +++ b/features/PROJ-46-email-login-tenant-user.md @@ -0,0 +1,100 @@ +# PROJ-46: E-Mail als primärer Login-Identifier für Tenant-User + +## Status: In Review +**Created:** 2026-06-13 +**Last Updated:** 2026-07-03 + +## Implementation Notes (2026-07-03, Backend) +- **`internal/userstore/userstore.go`:** Neue Funktion `VerifyLogin(ctx, identifier, password) (*User, error)`. + Ablauf: (1) Lookup per `email = $1` (matcht alle User). (2) Falls kein Treffer, + Lookup per `username = $1 AND tenant_id IS NULL` — Tenant-User können sich damit + NICHT mehr per Username anmelden. Danach `active`-Check + `bcrypt.CompareHashAndPassword`. + Neuer Helper `scanUserWithHash` (reicht `pgx.ErrNoRows` unverfälscht durch, damit der + Email→Username-Fallback greift). `VerifyPassword` blieb unangetastet (IMAP-Pfad PROJ-26). +- **`internal/auth/auth.go`:** `Manager.Login()` ruft nun `VerifyLogin(context.Background(), ...)` + statt `VerifyPassword(...)`. `extractDomain(identifier)` musste NICHT angepasst werden: + Tenant-User senden jetzt die E-Mail (`user@domain`) als Identifier, woraus die bestehende + `strings.LastIndex(..., "@")`-Logik die Domain korrekt extrahiert — für den per-Tenant-/ + Global-LDAP-Fallback ist das sogar zuverlässiger als der frühere reine Username. +- **`internal/userstore/userstore_test.go`:** `TestVerifyLogin` deckt die volle Matrix ab + (Tenant per E-Mail = Erfolg, Tenant per Username = Reject, Non-Tenant per Username = Erfolg, + Non-Tenant per E-Mail = Erfolg, unbekannter Identifier = Reject, falsches Passwort = Reject). + DB-backed (Skip ohne `TEST_DATABASE_URL`, wie die übrigen userstore-Tests). +- **Kein lokaler `go build`/`go test` möglich** (kein Toolchain im Arbeitsverzeichnis) — + Build-/Testverifikation erfolgt separat auf dem Testserver. +- Frontend (`src/app/page.tsx`, `src/app/admin/login/page.tsx`) wird separat vom Frontend-Agent umgesetzt. + +## Implementation Notes (2026-07-03, Frontend) +- **`src/app/page.tsx` (Tenant-User-Login):** Label "Benutzername" → "E-Mail-Adresse", + Input `type="text"` → `type="email"`, `autoComplete="username"` → `autoComplete="email"`, + Placeholder + `aria-label` entsprechend angepasst. API-Client (`login()`) unverändert — + der eingegebene String geht weiterhin als `username`-Feld ins JSON-Body. +- **`src/app/admin/login/page.tsx` (Admin/Superadmin-Login):** Label + Placeholder → + "Benutzername oder E-Mail-Adresse". Input bleibt bewusst `type="text"` (beide Formate möglich). +- **`npx tsc --noEmit`:** sauber durchgelaufen (Exit 0), keine Typfehler. +- Kein API-Client-/Backend-Code angefasst. QA gegen Acceptance Criteria steht noch aus. + +## Problem Statement +Tenant-User melden sich aktuell mit `username` an, nicht mit ihrer E-Mail-Adresse. Das führt in der Praxis zu Verwechslungen: Ein User versucht sich mit seiner E-Mail-Adresse einzuloggen (die einzige Kennung, die er sich merkt), das Login schlägt mit "invalid_password" fehl, und nach 5 Fehlversuchen innerhalb von 15 Minuten greift das Rate-Limit (429 "too many failed login attempts") — selbst nachdem ein Admin das Passwort zurückgesetzt hat. + +Konkreter Support-Fall (2026-06-13): patrick@perlbach24.de konnte sich trotz Passwort-Reset durch den Superadmin nicht einloggen, weil er `patrick@perlbach24.de` statt `patrick` (seinem tatsächlichen `username`) als Login-Identifier verwendete. + +## Dependencies +- Betrifft: PROJ-1 (Authentifizierung & Rollen), PROJ-21 (Multi-Tenancy) +- Berührt NICHT: PROJ-26 (IMAP-Server-Schnittstelle) — IMAP-Login bleibt unverändert per `username` + +## User Stories +- Als **Tenant-User** möchte ich mich mit meiner **E-Mail-Adresse** anmelden, weil das die Kennung ist, die ich kenne und die mir in Einladungs-/Reset-Mails genannt wird. +- Als **Superadmin/System-User** (ohne Tenant-Zugehörigkeit) möchte ich mich weiterhin mit meinem **Benutzernamen ODER meiner E-Mail-Adresse** anmelden können. +- Als **Tenant-Admin** möchte ich, dass sich Tenant-User NICHT mehr per Benutzername einloggen können, um Verwechslungen wie im Support-Fall vom 2026-06-13 zukünftig zu vermeiden. + +## Acceptance Criteria + +### Login-Logik (Backend) +- [ ] Neue Lookup-Funktion `Store.VerifyLogin(ctx, identifier, password) (*User, error)` in `internal/userstore/userstore.go` +- [ ] Login per E-Mail-Adresse (`email = $1`) funktioniert für alle User (Tenant-User UND Nicht-Tenant-User) +- [ ] Login per `username` funktioniert NUR für User mit `tenant_id IS NULL` (Superadmin/System-User) +- [ ] Tenant-User (`tenant_id IS NOT NULL`), die ihren `username` als Login-Identifier verwenden, erhalten `invalid_credentials` (kein Login) +- [ ] Bestehende `VerifyPassword(username, password)` bleibt unverändert erhalten für den IMAP-Server-Login-Pfad (PROJ-26) +- [ ] `internal/auth/auth.go` `Manager.Login()` ruft `VerifyLogin()` statt `VerifyPassword()` + +### Frontend +- [ ] Tenant-User-Login (`src/app/page.tsx`): Label "Benutzername" → "E-Mail-Adresse", Input-Type `email`, `autoComplete="email"` +- [ ] Admin/Superadmin-Login (`src/app/admin/login/page.tsx`): Label → "Benutzername oder E-Mail-Adresse" +- [ ] API-Request-Format bleibt unverändert (`{"username": "", "password": "..."}`) + +### Rate-Limiting & Audit +- [ ] `login_attempts.username` Spalte auf VARCHAR(255) erweitert (bereits erledigt, siehe Migration unten) +- [ ] Rate-Limiting-Logik (`CountRecentFailures`, `RecordLoginAttempt`) funktioniert unverändert mit E-Mail-Strings als Schlüssel +- [ ] Audit-Log protokolliert bei Fehlversuchen weiterhin den eingegebenen Identifier (E-Mail oder Username) + +### Tests +- [ ] Unit-Tests für `VerifyLogin` decken die vollständige Matrix ab: + - Tenant-User per E-Mail → Erfolg + - Tenant-User per Username (≠ E-Mail) → `invalid_credentials` + - Nicht-Tenant-User per Username → Erfolg + - Nicht-Tenant-User per E-Mail → Erfolg + - Unbekannter Identifier → `invalid_credentials` + +## Migration (bereits durchgeführt am 2026-06-13) +- Datenqualitäts-Check auf 192.168.1.131: 0 Tenant-User mit fehlender/ungültiger E-Mail, 0 Username↔E-Mail-Kollisionen +- `login_attempts.username` von VARCHAR(100) → VARCHAR(255) erweitert (idempotenter `initSchema`-Eintrag in `internal/userstore/userstore.go` ergänzt) +- Hinweis: `superadmin@localhost` und `auditor@archivmail.local` (beide `tenant_id IS NULL`) sind als E-Mail-Format ungewöhnlich, aber kein Blocker — diese User können weiterhin per `username` einloggen + +## Edge Cases +- **Kollision `username` (User A) == `email` (User B):** Mit `email UNIQUE` selten, aber falls vorhanden gewinnt der `email`-Treffer (User B) immer — User A kann sich mit diesem String dann nicht mehr einloggen, auch wenn `tenant_id IS NULL`. Aktuell 0 solcher Fälle (siehe Migration). +- **LDAP-User (PROJ-16/23):** `extractDomain(identifier)` in `internal/auth/auth.go` muss bei E-Mail-Eingabe weiterhin korrekt funktionieren (E-Mail-Format `user@domain` ist kompatibel zum bisherigen Format). + +## Non-Goals +- IMAP-Server-Login (PROJ-26) bleibt unverändert per `username` +- Kein einheitlicher Login-Screen für alle Usertypen (bleibt bei zwei separaten Routen: `/` und `/admin/login`) +- Keine Änderung am API-Request-Wire-Format (`username`-Feld bleibt im JSON-Body, nur die Bedeutung ändert sich) + +## Technical Requirements +- **Breaking Change (bewusst):** Tenant-User, die sich bisher per `username` einloggten, müssen künftig die E-Mail-Adresse verwenden. REST-API-Clients (PROJ-13), die `username` für Tenant-User senden, müssen auf `email` umgestellt werden. +- **Betroffene Dateien:** + - `internal/userstore/userstore.go` (neue `VerifyLogin`, initSchema-Erweiterung — bereits erledigt) + - `internal/auth/auth.go` (`Login()` ruft `VerifyLogin()`) + - `src/app/page.tsx` (Label/Input-Type) + - `src/app/admin/login/page.tsx` (Label) + - `internal/auth/auth_test.go` (neue Testfälle) diff --git a/internal/api/rules_handlers.go b/internal/api/rules_handlers.go new file mode 100644 index 0000000..bede358 --- /dev/null +++ b/internal/api/rules_handlers.go @@ -0,0 +1,213 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "archivmail/internal/storage" +) + +// PROJ-43: Tenant routing rules CRUD + dry-run. +// +// Scope model (see PROJ-55/61/62/63 security fixes): +// - Superadmin (sess.TenantID == nil): may see/manage rules for ALL tenants. +// - Domain admin (sess.TenantID set): may only see/manage rules whose +// tenant_id matches their own tenant. Every {id} path additionally verifies +// ownership via tenantAccessAllowed() to prevent IDOR. + +type routingRuleBody struct { + TenantID *int64 `json:"tenant_id"` + MatchType string `json:"match_type"` + Pattern string `json:"pattern"` + Priority int `json:"priority"` +} + +// resolveRuleTenant determines the tenant_id a rule must belong to for the +// current session, and reports whether the request is allowed. +// - Superadmin: must specify tenant_id in the body (rules always target a +// concrete tenant); any tenant allowed. +// - Domain admin: tenant_id is forced to their own tenant; a mismatching +// explicit body value is rejected. +func (s *Server) resolveRuleTenant(sess sessionTenant, bodyTenantID *int64) (int64, bool) { + if sess.tenantID == nil { + // superadmin + if bodyTenantID == nil || *bodyTenantID <= 0 { + return 0, false + } + return *bodyTenantID, true + } + if bodyTenantID != nil && *bodyTenantID != *sess.tenantID { + return 0, false + } + return *sess.tenantID, true +} + +// sessionTenant is a tiny helper capturing what the handlers need from a session. +type sessionTenant struct { + tenantID *int64 +} + +func sessTenant(r *http.Request) sessionTenant { + sess := sessionFromCtx(r.Context()) + return sessionTenant{tenantID: sess.TenantID} +} + +// handleListRoutingRules returns routing rules visible to the caller. +// GET /api/admin/routing-rules +func (s *Server) handleListRoutingRules(w http.ResponseWriter, r *http.Request) { + scope := sessTenant(r).tenantID // nil for superadmin → all rules + rules, err := s.store.ListTenantRoutingRules(r.Context(), scope) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if rules == nil { + rules = []storage.TenantRoutingRule{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"rules": rules}) +} + +// handleCreateRoutingRule creates a new routing rule. +// POST /api/admin/routing-rules +func (s *Server) handleCreateRoutingRule(w http.ResponseWriter, r *http.Request) { + var body routingRuleBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + tenantID, ok := s.resolveRuleTenant(sessTenant(r), body.TenantID) + if !ok { + writeError(w, http.StatusForbidden, "tenant_id required and must match your scope") + return + } + id, err := s.store.CreateTenantRoutingRule(r.Context(), storage.TenantRoutingRule{ + TenantID: tenantID, + MatchType: body.MatchType, + Pattern: body.Pattern, + Priority: body.Priority, + }) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.auditRule(r, "routing_rule_created", fmt.Sprintf("id=%d tenant=%d type=%s pattern=%s prio=%d", + id, tenantID, body.MatchType, body.Pattern, body.Priority)) + writeJSON(w, http.StatusCreated, map[string]interface{}{"id": id}) +} + +// handleUpdateRoutingRule updates an existing routing rule. +// PUT /api/admin/routing-rules/{id} +func (s *Server) handleUpdateRoutingRule(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid rule id") + return + } + // IDOR: load existing rule and verify ownership before mutating. + existing, err := s.store.GetTenantRoutingRule(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "rule not found") + return + } + sess := sessionFromCtx(r.Context()) + if !tenantAccessAllowed(sess, &existing.TenantID) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + var body routingRuleBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + tenantID, ok := s.resolveRuleTenant(sessTenant(r), body.TenantID) + if !ok { + writeError(w, http.StatusForbidden, "tenant_id must match your scope") + return + } + if err := s.store.UpdateTenantRoutingRule(r.Context(), storage.TenantRoutingRule{ + ID: id, + TenantID: tenantID, + MatchType: body.MatchType, + Pattern: body.Pattern, + Priority: body.Priority, + }); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.auditRule(r, "routing_rule_updated", fmt.Sprintf("id=%d tenant=%d type=%s pattern=%s prio=%d", + id, tenantID, body.MatchType, body.Pattern, body.Priority)) + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// handleDeleteRoutingRule deletes a routing rule. +// DELETE /api/admin/routing-rules/{id} +func (s *Server) handleDeleteRoutingRule(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid rule id") + return + } + existing, err := s.store.GetTenantRoutingRule(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "rule not found") + return + } + sess := sessionFromCtx(r.Context()) + if !tenantAccessAllowed(sess, &existing.TenantID) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + if err := s.store.DeleteTenantRoutingRule(r.Context(), id); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + s.auditRule(r, "routing_rule_deleted", fmt.Sprintf("id=%d", id)) + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +type routingDryRunBody struct { + RuleID *int64 `json:"rule_id"` // dry-run an existing rule, OR ... + MatchType string `json:"match_type"` // ... an ad-hoc (match_type, pattern) + Pattern string `json:"pattern"` + Limit int `json:"limit"` +} + +// handleDryRunRoutingRule previews which already-archived mails a rule would +// match. Bounded by LIMIT to avoid full-scan timeouts on large archives. +// POST /api/admin/routing-rules/dry-run +func (s *Server) handleDryRunRoutingRule(w http.ResponseWriter, r *http.Request) { + var body routingDryRunBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + sess := sessionFromCtx(r.Context()) + + matchType, pattern := body.MatchType, body.Pattern + if body.RuleID != nil { + rule, err := s.store.GetTenantRoutingRule(r.Context(), *body.RuleID) + if err != nil { + writeError(w, http.StatusNotFound, "rule not found") + return + } + if !tenantAccessAllowed(sess, &rule.TenantID) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + matchType, pattern = rule.MatchType, rule.Pattern + } + + // Domain admins may only preview mails within their own tenant. + scope := sess.TenantID + res, err := s.store.DryRunRoutingRule(r.Context(), matchType, pattern, body.Limit, scope) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.auditRule(r, "routing_rule_dry_run", fmt.Sprintf("type=%s pattern=%s matches=%d", matchType, pattern, res.MatchCount)) + writeJSON(w, http.StatusOK, res) +} + +// NOTE: auditRule is defined in archiving_rules_handlers.go and reused here. diff --git a/internal/api/server.go b/internal/api/server.go index 80ce744..d026f66 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -249,6 +249,14 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/admin/archiving-rules", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCreateArchivingRule))) s.mux.HandleFunc("PUT /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleUpdateArchivingRule))) s.mux.HandleFunc("DELETE /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteArchivingRule))) + // PROJ-43: Tenant routing rules CRUD + dry-run — domain_admin+, tenant-scoped + // (superadmin sees all tenants, domain admins only their own). + s.mux.HandleFunc("GET /api/admin/routing-rules", s.authAdmin(s.handleListRoutingRules)) + s.mux.HandleFunc("POST /api/admin/routing-rules", s.authAdmin(s.handleCreateRoutingRule)) + s.mux.HandleFunc("PUT /api/admin/routing-rules/{id}", s.authAdmin(s.handleUpdateRoutingRule)) + s.mux.HandleFunc("DELETE /api/admin/routing-rules/{id}", s.authAdmin(s.handleDeleteRoutingRule)) + s.mux.HandleFunc("POST /api/admin/routing-rules/dry-run", s.authAdmin(s.handleDryRunRoutingRule)) + // PROJ-56c: pro-Mail Löschmarkierung — domain_admin+, tenant-scoped (kein // Mail-Lesezugriff nötig, daher requireRole statt requireMailAccess). s.mux.HandleFunc("GET /api/admin/retention/expired", s.authAdmin(s.handleListExpiredMails)) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index d05d43a..4f3fa9a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -74,7 +74,9 @@ func (m *Manager) SetTenantLDAP(tenantLdapStore *ldapcfg.TenantStore, tenantLook // short-lived pending token that can only be used with ValidateTOTPLogin. func (m *Manager) Login(username, password string) (token string, user *userstore.User, totpRequired bool, err error) { // 1. Try local authentication first. - user, err = m.store.VerifyPassword(username, password) + // PROJ-46: VerifyLogin lets tenant users authenticate by email and keeps + // username-login working only for non-tenant users (tenant_id IS NULL). + user, err = m.store.VerifyLogin(context.Background(), username, password) if err == nil { if user.TOTPEnabled { t, e := m.issuePendingTOTPToken(user) diff --git a/internal/imap/importer.go b/internal/imap/importer.go index 0523423..9f6b7e6 100644 --- a/internal/imap/importer.go +++ b/internal/imap/importer.go @@ -243,6 +243,22 @@ func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids // accountID identifies the IMAP account for PROJ-52 source tracking. func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, accountID int64, log *slog.Logger) error { ctx := context.Background() + + // Parse early: needed both for PROJ-43 routing-rule resolution (before Save, + // so the mail is stored under the correct tenant) and for indexing below. + pm, parseErr := mailparser.Parse(raw) + + // PROJ-43: pattern routing rules may override the account's default tenant. + // They are more specific than the per-account TenantID, so a match wins. + if parseErr == nil { + recipients := append(append([]string{}, pm.To...), pm.CC...) + if tid, err := imp.mailStore.ResolveTenantByRoutingRules(ctx, pm.From, recipients); err != nil { + log.Warn("routing rule lookup failed", "err", err) + } else if tid != nil { + tenantID = tid + } + } + // Save to file storage (deduplicates by SHA256 automatically) id, err := imp.mailStore.Save(ctx, raw, time.Now(), tenantID) if err != nil { @@ -255,10 +271,9 @@ func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, accountID int64, log.Warn("failed to tag source", "id", id, "err", err) } - // Parse for indexing - pm, err := mailparser.Parse(raw) - if err != nil { - log.Warn("failed to parse mail for indexing", "id", id, "err", err) + // Parse for indexing (reuse the early parse from routing-rule resolution). + if parseErr != nil { + log.Warn("failed to parse mail for indexing", "id", id, "err", parseErr) // Store succeeded, just skip indexing for unparseable mails return nil } diff --git a/internal/smtpd/smtpd.go b/internal/smtpd/smtpd.go index 7059fec..8b8073f 100644 --- a/internal/smtpd/smtpd.go +++ b/internal/smtpd/smtpd.go @@ -101,6 +101,13 @@ func (d *Daemon) resolveTenantFromRcpts(rcpts []string) *int64 { // This handles BCC-journaling where RCPT TO is the archive's own address and // the real sender/recipient domain is only visible in the RFC 2822 headers. func (d *Daemon) resolveTenant(rcpts []string, raw []byte) *int64 { + // 0. PROJ-43: explicit pattern routing rules take precedence over the plain + // tenant_domains mapping, because they are more specific (wildcard + // domains, exact sender/recipient addresses, priority ordering). + if tid := d.resolveTenantByRules(rcpts, raw); tid != nil { + return tid + } + if d.domainToTenant == nil { return d.defaultTenantID } @@ -144,6 +151,38 @@ func (d *Daemon) resolveTenant(rcpts []string, raw []byte) *int64 { return d.defaultTenantID } +// resolveTenantByRules gathers the From address and recipient list (envelope +// RCPT TO plus header To/Cc) and evaluates the PROJ-43 tenant_routing_rules. +// Returns nil when the store is unavailable or no rule matches. +func (d *Daemon) resolveTenantByRules(rcpts []string, raw []byte) *int64 { + if d.store == nil { + return nil + } + var from string + recipients := make([]string, 0, len(rcpts)+4) + for _, r := range rcpts { + recipients = append(recipients, strings.Trim(r, "<>")) + } + if msg, err := mail.ReadMessage(bytes.NewReader(raw)); err == nil { + if addrs, err := mail.ParseAddressList(msg.Header.Get("From")); err == nil && len(addrs) > 0 { + from = addrs[0].Address + } + for _, hdr := range []string{"To", "Cc"} { + if addrs, err := mail.ParseAddressList(msg.Header.Get(hdr)); err == nil { + for _, a := range addrs { + recipients = append(recipients, a.Address) + } + } + } + } + tid, err := d.store.ResolveTenantByRoutingRules(context.Background(), from, recipients) + if err != nil { + d.logger.Warn("SMTP: routing rule lookup failed", "err", err) + return nil + } + return tid +} + // SetIndexCallback sets the function called after each successfully stored mail. func (d *Daemon) SetIndexCallback(cb IndexCallback) { d.indexCallback = cb diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ba8cb8f..91ec7a3 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -107,6 +107,8 @@ func New(cfg Config) (*Store, error) { _, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS storage_id BIGINT REFERENCES storage_objects(id)`) // PROJ-51: archiving_rules table + retain_until_source column s.initRetentionRulesSchema(ctx) + // PROJ-43: tenant_routing_rules table (pattern-based tenant assignment) + s.initTenantRoutingRulesSchema(ctx) // PROJ-50: DSGVO Löschersuchen s.initDSGVOSchema(ctx) } diff --git a/internal/storage/tenant_routing_rules.go b/internal/storage/tenant_routing_rules.go new file mode 100644 index 0000000..343e990 --- /dev/null +++ b/internal/storage/tenant_routing_rules.go @@ -0,0 +1,383 @@ +package storage + +import ( + "context" + "fmt" + "strings" + "time" +) + +// PROJ-43: Tenant routing rules — flexible pattern rules that assign an +// incoming mail to a tenant. This complements the simple 1:1 tenant_domains +// mapping (PROJ-21 Phase 5): routing rules are more explicit and therefore +// evaluated *before* the plain domain fallback. Higher priority wins on ties. +// +// NOTE: this is intentionally a separate table from PROJ-51's `archiving_rules` +// (retention categories) — same "rules engine" style, different purpose. + +// Routing rule match types. +const ( + RouteMatchFromDomain = "from_domain" // domain of the From address + RouteMatchToDomain = "to_domain" // domain of any To/Cc/envelope recipient + RouteMatchFromAddr = "from_addr" // full From address (exact) + RouteMatchToAddr = "to_addr" // full To/Cc/envelope recipient (exact) +) + +// TenantRoutingRule assigns matching mails to TenantID. +type TenantRoutingRule struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` // target tenant (always set — a routing rule must resolve to a tenant) + MatchType string `json:"match_type"` + Pattern string `json:"pattern"` + Priority int `json:"priority"` // higher = evaluated first + CreatedAt time.Time `json:"created_at"` +} + +func (s *Store) initTenantRoutingRulesSchema(ctx context.Context) { + if s.db == nil { + return + } + _, _ = s.db.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS tenant_routing_rules ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + match_type TEXT NOT NULL, + pattern TEXT NOT NULL, + priority INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`) + _, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_tenant_routing_rules_priority ON tenant_routing_rules (priority DESC)`) + _, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_tenant_routing_rules_tenant ON tenant_routing_rules (tenant_id)`) +} + +func validRouteMatchType(t string) bool { + switch t { + case RouteMatchFromDomain, RouteMatchToDomain, RouteMatchFromAddr, RouteMatchToAddr: + return true + } + return false +} + +// ── CRUD ────────────────────────────────────────────────────────────────────── + +// ListTenantRoutingRules returns rules ordered by priority (desc). If tenantID +// is non-nil, only rules for that tenant are returned (tenant-admin scope). +// A nil tenantID returns all rules (superadmin scope). +func (s *Store) ListTenantRoutingRules(ctx context.Context, tenantID *int64) ([]TenantRoutingRule, error) { + if s.db == nil { + return nil, fmt.Errorf("storage: no db") + } + query := `SELECT id, tenant_id, match_type, pattern, priority, created_at FROM tenant_routing_rules` + var args []interface{} + if tenantID != nil { + query += ` WHERE tenant_id = $1` + args = append(args, *tenantID) + } + query += ` ORDER BY priority DESC, id ASC` + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("storage: list routing rules: %w", err) + } + defer rows.Close() + + var out []TenantRoutingRule + for rows.Next() { + var r TenantRoutingRule + if err := rows.Scan(&r.ID, &r.TenantID, &r.MatchType, &r.Pattern, &r.Priority, &r.CreatedAt); err != nil { + return nil, fmt.Errorf("storage: scan routing rule: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// GetTenantRoutingRule loads a single rule (used for IDOR scope checks and +// dry-run by rule ID). +func (s *Store) GetTenantRoutingRule(ctx context.Context, id int64) (*TenantRoutingRule, error) { + if s.db == nil { + return nil, fmt.Errorf("storage: no db") + } + var r TenantRoutingRule + err := s.db.QueryRow(ctx, + `SELECT id, tenant_id, match_type, pattern, priority, created_at FROM tenant_routing_rules WHERE id=$1`, id, + ).Scan(&r.ID, &r.TenantID, &r.MatchType, &r.Pattern, &r.Priority, &r.CreatedAt) + if err != nil { + return nil, fmt.Errorf("storage: get routing rule: %w", err) + } + return &r, nil +} + +func normalizeRoutePattern(matchType, pattern string) (string, error) { + if !validRouteMatchType(matchType) { + return "", fmt.Errorf("storage: invalid match_type %q", matchType) + } + p := strings.ToLower(strings.TrimSpace(pattern)) + if p == "" { + return "", fmt.Errorf("storage: pattern must not be empty") + } + return p, nil +} + +// CreateTenantRoutingRule inserts a new rule and returns its generated ID. +func (s *Store) CreateTenantRoutingRule(ctx context.Context, r TenantRoutingRule) (int64, error) { + if s.db == nil { + return 0, fmt.Errorf("storage: no db") + } + pat, err := normalizeRoutePattern(r.MatchType, r.Pattern) + if err != nil { + return 0, err + } + if r.TenantID <= 0 { + return 0, fmt.Errorf("storage: tenant_id must be set") + } + var id int64 + err = s.db.QueryRow(ctx, ` + INSERT INTO tenant_routing_rules (tenant_id, match_type, pattern, priority) + VALUES ($1, $2, $3, $4) RETURNING id`, + r.TenantID, r.MatchType, pat, r.Priority, + ).Scan(&id) + if err != nil { + return 0, fmt.Errorf("storage: create routing rule: %w", err) + } + return id, nil +} + +// UpdateTenantRoutingRule replaces the editable fields of an existing rule. +func (s *Store) UpdateTenantRoutingRule(ctx context.Context, r TenantRoutingRule) error { + if s.db == nil { + return fmt.Errorf("storage: no db") + } + pat, err := normalizeRoutePattern(r.MatchType, r.Pattern) + if err != nil { + return err + } + if r.TenantID <= 0 { + return fmt.Errorf("storage: tenant_id must be set") + } + tag, err := s.db.Exec(ctx, ` + UPDATE tenant_routing_rules + SET tenant_id=$1, match_type=$2, pattern=$3, priority=$4 + WHERE id=$5`, + r.TenantID, r.MatchType, pat, r.Priority, r.ID, + ) + if err != nil { + return fmt.Errorf("storage: update routing rule: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("storage: routing rule %d not found", r.ID) + } + return nil +} + +// DeleteTenantRoutingRule removes a rule. Already-archived mails keep their +// assigned tenant (no retroactive re-routing). +func (s *Store) DeleteTenantRoutingRule(ctx context.Context, id int64) error { + if s.db == nil { + return fmt.Errorf("storage: no db") + } + tag, err := s.db.Exec(ctx, `DELETE FROM tenant_routing_rules WHERE id=$1`, id) + if err != nil { + return fmt.Errorf("storage: delete routing rule: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("storage: routing rule %d not found", id) + } + return nil +} + +// ── Matching ──────────────────────────────────────────────────────────────── + +func routeDomainOf(addr string) string { + addr = strings.ToLower(strings.TrimSpace(addr)) + // strip a possible "Name " wrapper + if i := strings.LastIndex(addr, "<"); i >= 0 { + if j := strings.LastIndex(addr, ">"); j > i { + addr = addr[i+1 : j] + } + } + if i := strings.LastIndex(addr, "@"); i >= 0 { + return addr[i+1:] + } + return "" +} + +func routeBareAddr(addr string) string { + addr = strings.ToLower(strings.TrimSpace(addr)) + if i := strings.LastIndex(addr, "<"); i >= 0 { + if j := strings.LastIndex(addr, ">"); j > i { + addr = addr[i+1 : j] + } + } + return strings.TrimSpace(addr) +} + +// domainMatches supports exact match and a leading "*." wildcard that also +// matches sub-domains (e.g. "*.kunde.de" matches "mail.kunde.de" and "kunde.de"). +func domainMatches(pattern, domain string) bool { + if pattern == "" || domain == "" { + return false + } + if strings.HasPrefix(pattern, "*.") { + base := pattern[2:] + return domain == base || strings.HasSuffix(domain, "."+base) + } + return domain == pattern +} + +// routingRuleMatches reports whether a rule matches the given (already +// lowercased-on-store) from address and recipient list. +func routingRuleMatches(r TenantRoutingRule, from string, recipients []string) bool { + pat := strings.ToLower(strings.TrimSpace(r.Pattern)) + if pat == "" { + return false + } + switch r.MatchType { + case RouteMatchFromAddr: + return routeBareAddr(from) == pat + case RouteMatchFromDomain: + return domainMatches(pat, routeDomainOf(from)) + case RouteMatchToAddr: + for _, t := range recipients { + if routeBareAddr(t) == pat { + return true + } + } + return false + case RouteMatchToDomain: + for _, t := range recipients { + if domainMatches(pat, routeDomainOf(t)) { + return true + } + } + return false + } + return false +} + +// ResolveTenantByRoutingRules evaluates all routing rules against a mail's From +// address and recipient list. The highest-priority matching rule wins (lowest +// id on a tie). Returns nil when no rule matches. This is the PROJ-43 explicit +// stage that runs *before* the plain tenant_domains fallback. +func (s *Store) ResolveTenantByRoutingRules(ctx context.Context, from string, recipients []string) (*int64, error) { + if s.db == nil { + return nil, nil + } + rules, err := s.ListTenantRoutingRules(ctx, nil) + if err != nil { + return nil, err + } + for i := range rules { + if routingRuleMatches(rules[i], from, recipients) { + tid := rules[i].TenantID + return &tid, nil + } + } + return nil, nil +} + +// ── Dry-run ───────────────────────────────────────────────────────────────── + +// RoutingDryRunMatch is a single sample row for the dry-run preview. +type RoutingDryRunMatch struct { + ID string `json:"id"` + MailFrom string `json:"mail_from"` + MailTo string `json:"mail_to"` + Subject string `json:"subject"` + ReceivedAt time.Time `json:"received_at"` +} + +// RoutingDryRunResult reports how many already-archived mails would be matched +// by a (match_type, pattern) pair, plus a bounded sample. +type RoutingDryRunResult struct { + MatchType string `json:"match_type"` + Pattern string `json:"pattern"` + MatchCount int64 `json:"match_count"` + SampleLimit int `json:"sample_limit"` + Sample []RoutingDryRunMatch `json:"sample"` +} + +// dryRunCondition builds a SQL condition + argument approximating the rule +// against the emails table (mail_from / mail_to are display strings, so we use +// ILIKE containment — this is a preview estimate, not the exact live matcher). +func dryRunCondition(matchType, pattern string) (cond string, arg string, err error) { + p := strings.ToLower(strings.TrimSpace(pattern)) + if p == "" { + return "", "", fmt.Errorf("storage: pattern must not be empty") + } + // "*." wildcard: drop the leading star so ILIKE '%.base' / '%@base' catches + // both the base domain and its sub-domains. + wild := strings.HasPrefix(p, "*.") + base := p + if wild { + base = p[2:] + } + switch matchType { + case RouteMatchFromAddr: + return "LOWER(mail_from) LIKE $1", "%<" + p + ">%", nil + case RouteMatchToAddr: + return "LOWER(mail_to) LIKE $1", "%<" + p + ">%", nil + case RouteMatchFromDomain: + return "LOWER(mail_from) LIKE $1", "%@%" + base + "%", nil + case RouteMatchToDomain: + return "LOWER(mail_to) LIKE $1", "%@%" + base + "%", nil + } + return "", "", fmt.Errorf("storage: invalid match_type %q", matchType) +} + +// DryRunRoutingRule counts and samples archived mails that a rule would match. +// sampleLimit bounds the sample (and is applied to the query) to avoid full +// table scans / timeouts on large archives. +// tenantScope, when non-nil, restricts the dry-run to mails already assigned to +// that tenant (enforced for tenant/domain admins so they cannot preview other +// tenants' mails). A nil tenantScope (superadmin) counts across all tenants. +func (s *Store) DryRunRoutingRule(ctx context.Context, matchType, pattern string, sampleLimit int, tenantScope *int64) (*RoutingDryRunResult, error) { + if s.db == nil { + return nil, fmt.Errorf("storage: no db") + } + if !validRouteMatchType(matchType) { + return nil, fmt.Errorf("storage: invalid match_type %q", matchType) + } + if sampleLimit <= 0 || sampleLimit > 100 { + sampleLimit = 20 + } + cond, arg, err := dryRunCondition(matchType, pattern) + if err != nil { + return nil, err + } + args := []interface{}{arg} + if tenantScope != nil { + cond += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, *tenantScope) + } + + res := &RoutingDryRunResult{ + MatchType: matchType, + Pattern: strings.ToLower(strings.TrimSpace(pattern)), + SampleLimit: sampleLimit, + Sample: []RoutingDryRunMatch{}, + } + + if err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM emails WHERE `+cond, args..., + ).Scan(&res.MatchCount); err != nil { + return nil, fmt.Errorf("storage: dry-run count: %w", err) + } + + sampleArgs := append(append([]interface{}{}, args...), sampleLimit) + rows, err := s.db.Query(ctx, + `SELECT id, COALESCE(mail_from,''), COALESCE(mail_to,''), COALESCE(subject,''), received_at + FROM emails WHERE `+cond+fmt.Sprintf(` ORDER BY received_at DESC LIMIT $%d`, len(sampleArgs)), sampleArgs...) + if err != nil { + return nil, fmt.Errorf("storage: dry-run sample: %w", err) + } + defer rows.Close() + for rows.Next() { + var m RoutingDryRunMatch + if err := rows.Scan(&m.ID, &m.MailFrom, &m.MailTo, &m.Subject, &m.ReceivedAt); err != nil { + return nil, fmt.Errorf("storage: dry-run scan: %w", err) + } + res.Sample = append(res.Sample, m) + } + return res, rows.Err() +} diff --git a/internal/userstore/userstore.go b/internal/userstore/userstore.go index a814cdf..70b80a3 100644 --- a/internal/userstore/userstore.go +++ b/internal/userstore/userstore.go @@ -20,6 +20,13 @@ const ( RoleSuperAdmin = "superadmin" bcryptCost = 12 + + // dummyBcryptHash is used by VerifyLogin to burn bcrypt time when no user + // matched, closing the timing side-channel that would otherwise let an + // attacker distinguish "unknown identifier" from "wrong password" (PROJ-46 + // security review). Precomputed hash of a fixed placeholder string — the + // plaintext is never used or compared meaningfully, only the cost matters. + dummyBcryptHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEeO4TW/OZ/6PdTdSU0/eV1JCJXo.0DGvTa" ) // User represents a user account in the system. @@ -284,6 +291,59 @@ func (s *Store) VerifyPassword(username, password string) (*User, error) { return &u, nil } +// VerifyLogin checks credentials for the web-login path (PROJ-46) and returns +// the user on success. Lookup semantics: +// 1. Match by email (`email = $1`) — valid for ALL users (tenant users AND +// non-tenant users like superadmin/system). +// 2. If no email match, fall back to username (`username = $1`) — but ONLY +// accept the match when tenant_id IS NULL (superadmin/system users). +// Tenant users (tenant_id IS NOT NULL) can therefore no longer log in via +// their username; they must use their email address. +// +// Note: VerifyPassword (username-only) is intentionally left untouched — it is +// used by the IMAP server login path (PROJ-26). +func (s *Store) VerifyLogin(ctx context.Context, identifier, password string) (*User, error) { + // 1. Email lookup — matches any user. + row := s.pool.QueryRow(ctx, + `SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size, password_hash + FROM users WHERE email = $1`, + identifier, + ) + u, hash, err := scanUserWithHash(row) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("userstore: verify login (email): %w", err) + } + // 2. Username lookup — only accepted for non-tenant users (tenant_id IS NULL). + row = s.pool.QueryRow(ctx, + `SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size, password_hash + FROM users WHERE username = $1 AND tenant_id IS NULL`, + identifier, + ) + u, hash, err = scanUserWithHash(row) + if errors.Is(err, pgx.ErrNoRows) { + // PROJ-46 security review: run bcrypt against a dummy hash even when no + // user was found, so "unknown identifier" and "wrong password" take + // comparable time. Without this, the missing bcrypt call (~150-300ms + // cheaper) lets an attacker enumerate valid identifiers by timing the + // login endpoint. + _ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password)) + return nil, errors.New("userstore: user not found") + } + if err != nil { + return nil, fmt.Errorf("userstore: verify login (username): %w", err) + } + } + + if !u.Active { + return nil, errors.New("userstore: account disabled") + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return nil, errors.New("userstore: wrong password") + } + return u, nil +} + // Update applies a partial update to a user record. func (s *Store) Update(id int64, req UpdateUserRequest) (*User, error) { ctx := context.Background() @@ -517,6 +577,19 @@ func scanUser(row pgx.Row) (*User, error) { return &u, nil } +// scanUserWithHash scans a full user row that includes the password_hash column +// (used by the login verification paths). The pgx.ErrNoRows sentinel is passed +// through unwrapped so callers can distinguish "not found" from other errors. +func scanUserWithHash(row pgx.Row) (*User, string, error) { + var u User + var hash string + err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize, &hash) + if err != nil { + return nil, "", err + } + return &u, hash, nil +} + func scanUserRow(rows pgx.Rows) (*User, error) { var u User if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize); err != nil { diff --git a/internal/userstore/userstore_test.go b/internal/userstore/userstore_test.go index 3adf441..eb522af 100644 --- a/internal/userstore/userstore_test.go +++ b/internal/userstore/userstore_test.go @@ -116,6 +116,74 @@ func TestVerifyPassword(t *testing.T) { } } +// TestVerifyLogin covers the full PROJ-46 login-identifier matrix. +func TestVerifyLogin(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + tenantID := int64(1) + + // Tenant user: username "patrick" != email + if _, err := s.Create(userstore.CreateUserRequest{ + Username: "patrick", Email: "patrick@perlbach24.de", + Password: "pw-tenant", Role: userstore.RoleUser, TenantID: &tenantID, + }); err != nil { + t.Fatal(err) + } + // Non-tenant user (superadmin/system), tenant_id IS NULL + if _, err := s.Create(userstore.CreateUserRequest{ + Username: "superadmin", Email: "superadmin@localhost", + Password: "pw-super", Role: userstore.RoleSuperAdmin, TenantID: nil, + }); err != nil { + t.Fatal(err) + } + + // 1. Tenant user via email → success + u, err := s.VerifyLogin(ctx, "patrick@perlbach24.de", "pw-tenant") + if err != nil { + t.Fatalf("tenant user via email should succeed: %v", err) + } + if u.Username != "patrick" { + t.Errorf("Username = %q, want patrick", u.Username) + } + + // 2. Tenant user via username (≠ email) → invalid_credentials + if _, err := s.VerifyLogin(ctx, "patrick", "pw-tenant"); err == nil { + t.Error("tenant user via username should be rejected") + } + + // 3. Non-tenant user via username → success + u, err = s.VerifyLogin(ctx, "superadmin", "pw-super") + if err != nil { + t.Fatalf("non-tenant user via username should succeed: %v", err) + } + if u.Username != "superadmin" { + t.Errorf("Username = %q, want superadmin", u.Username) + } + + // 4. Non-tenant user via email → success + u, err = s.VerifyLogin(ctx, "superadmin@localhost", "pw-super") + if err != nil { + t.Fatalf("non-tenant user via email should succeed: %v", err) + } + if u.Username != "superadmin" { + t.Errorf("Username = %q, want superadmin", u.Username) + } + + // 5. Unknown identifier → invalid_credentials + if _, err := s.VerifyLogin(ctx, "ghost@nowhere.tld", "x"); err == nil { + t.Error("unknown identifier should be rejected") + } + if _, err := s.VerifyLogin(ctx, "ghost", "x"); err == nil { + t.Error("unknown username should be rejected") + } + + // Wrong password for valid identifier → rejected + if _, err := s.VerifyLogin(ctx, "patrick@perlbach24.de", "wrong"); err == nil { + t.Error("wrong password should be rejected") + } +} + func TestUpdateUser(t *testing.T) { s := newTestStore(t) u, _ := s.Create(userstore.CreateUserRequest{ diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx index 27e0911..9d681d6 100644 --- a/src/app/admin/login/page.tsx +++ b/src/app/admin/login/page.tsx @@ -62,11 +62,11 @@ export default function AdminLoginPage() {
- + setUsername(e.target.value)} required diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5ec887e..2c4ac98 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -33,6 +33,7 @@ import { ModulesTab } from "@/components/admin/ModulesTab"; import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab"; import { RetentionTab } from "@/components/admin/tabs/RetentionTab"; import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab"; +import { RoutingRulesTab } from "@/components/admin/tabs/RoutingRulesTab"; import { QuotaTab } from "@/components/admin/tabs/QuotaTab"; import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab"; import { DSGVOTab } from "@/components/admin/tabs/DSGVOTab"; @@ -167,6 +168,7 @@ export default function AdminPage() { {isSuperAdmin && Mandanten} {isSuperAdmin && Retention} {isSuperAdmin && Regeln} + Routing-Regeln {isSuperAdmin && Quotas} {isSuperAdmin && SMTP-Out} {isSuperAdmin && Module} @@ -442,6 +444,10 @@ export default function AdminPage() { )} + + + + {isSuperAdmin && ( diff --git a/src/app/page.tsx b/src/app/page.tsx index 648c140..e55f619 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -60,16 +60,16 @@ export default function LoginPage() {
- + setUsername(e.target.value)} required - autoComplete="username" - aria-label="Benutzername" + autoComplete="email" + aria-label="E-Mail-Adresse" />
diff --git a/src/components/admin/tabs/RoutingRulesTab.tsx b/src/components/admin/tabs/RoutingRulesTab.tsx new file mode 100644 index 0000000..7609921 --- /dev/null +++ b/src/components/admin/tabs/RoutingRulesTab.tsx @@ -0,0 +1,564 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + getRoutingRules, + createRoutingRule, + updateRoutingRule, + deleteRoutingRule, + dryRunRoutingRule, + getTenants, + type RoutingRule, + type RoutingRuleInput, + type RoutingMatchType, + type RoutingDryRunResult, + type Tenant, +} from "@/lib/api"; + +const MATCH_LABELS: Record = { + from_domain: "Absender-Domain", + to_domain: "Empfänger-Domain", + from_addr: "Absender-Adresse", + to_addr: "Empfänger-Adresse", +}; + +const MATCH_PLACEHOLDER: Record = { + from_domain: "z.B. kunde.de oder *.kunde.de", + to_domain: "z.B. firma.de", + from_addr: "z.B. buchhaltung@kunde.de", + to_addr: "z.B. archiv@firma.de", +}; + +interface EditState { + id: number | null; // null = create + tenant_id: string; // "" = noch nicht gewählt (nur superadmin relevant) + match_type: RoutingMatchType; + pattern: string; + priority: string; +} + +const EMPTY_EDIT: EditState = { + id: null, + tenant_id: "", + match_type: "from_domain", + pattern: "", + priority: "0", +}; + +function formatDate(value: string | null): string { + if (!value) return "–"; + const d = new Date(value); + if (isNaN(d.getTime())) return value; + return d.toLocaleString("de-DE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function RoutingRulesTab({ isSuperAdmin }: { isSuperAdmin: boolean }) { + const [rules, setRules] = useState([]); + const [tenants, setTenants] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const [edit, setEdit] = useState(null); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(""); + + // Dry-Run innerhalb des Anlege-/Bearbeiten-Dialogs + const [dryRunning, setDryRunning] = useState(false); + const [dryRunResult, setDryRunResult] = useState(null); + const [dryRunError, setDryRunError] = useState(""); + + const [deleteRule, setDeleteRule] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(() => { + setLoading(true); + setError(""); + const loaders: [Promise, Promise] = [ + getRoutingRules(), + isSuperAdmin ? getTenants() : Promise.resolve([]), + ]; + Promise.all(loaders) + .then(([rs, ts]) => { + setRules(rs); + setTenants(ts); + }) + .catch(() => setError("Routing-Regeln konnten nicht geladen werden")) + .finally(() => setLoading(false)); + }, [isSuperAdmin]); + + useEffect(() => { + load(); + }, [load]); + + const tenantName = (id: number): React.ReactNode => { + const t = tenants.find((x) => x.id === id); + return t ? t.name : `#${id}`; + }; + + const resetDryRun = () => { + setDryRunResult(null); + setDryRunError(""); + }; + + const openCreate = () => { + setEdit({ ...EMPTY_EDIT }); + setFormError(""); + resetDryRun(); + }; + + const openEdit = (r: RoutingRule) => { + setEdit({ + id: r.id, + tenant_id: String(r.tenant_id), + match_type: r.match_type, + pattern: r.pattern, + priority: String(r.priority), + }); + setFormError(""); + resetDryRun(); + }; + + const buildInput = (): RoutingRuleInput | null => { + if (!edit) return null; + const pattern = edit.pattern.trim(); + if (!pattern) { + setFormError("Muster darf nicht leer sein"); + return null; + } + const priority = parseInt(edit.priority, 10); + if (isNaN(priority)) { + setFormError("Priorität muss eine Zahl sein"); + return null; + } + const input: RoutingRuleInput = { + match_type: edit.match_type, + pattern, + priority, + }; + if (isSuperAdmin) { + if (edit.tenant_id === "") { + setFormError("Bitte einen Mandanten auswählen"); + return null; + } + input.tenant_id = parseInt(edit.tenant_id, 10); + } + return input; + }; + + const handleSave = async () => { + if (!edit) return; + setFormError(""); + const input = buildInput(); + if (!input) return; + setSaving(true); + try { + if (edit.id === null) { + await createRoutingRule(input); + } else { + await updateRoutingRule(edit.id, input); + } + setEdit(null); + load(); + } catch (e: unknown) { + setFormError(e instanceof Error ? e.message : "Speichern fehlgeschlagen"); + } finally { + setSaving(false); + } + }; + + const handleDryRun = async () => { + if (!edit) return; + setDryRunError(""); + setDryRunResult(null); + const pattern = edit.pattern.trim(); + if (!pattern) { + setDryRunError("Muster darf nicht leer sein"); + return; + } + setDryRunning(true); + try { + const res = await dryRunRoutingRule({ + match_type: edit.match_type, + pattern, + }); + setDryRunResult(res); + } catch (e: unknown) { + setDryRunError(e instanceof Error ? e.message : "Dry-Run fehlgeschlagen"); + } finally { + setDryRunning(false); + } + }; + + const handleDelete = async () => { + if (!deleteRule) return; + setDeleting(true); + try { + await deleteRoutingRule(deleteRule.id); + setDeleteRule(null); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen"); + setDeleteRule(null); + } finally { + setDeleting(false); + } + }; + + return ( +
+ + +
+ Routing-Regeln + + Ordnen eingehende Mails automatisch einem Mandanten zu — nach + Absender-/Empfänger-Domain oder -Adresse. Wildcard-Domains via{" "} + *.kunde.de. + +
+ +
+ + + + Priorität: Höhere Zahl = höhere Priorität und + gewinnt, wenn mehrere Regeln zutreffen (bei Gleichstand die zuerst + angelegte Regel). Regeln greifen nur beim Import neuer Mails —{" "} + bereits archivierte Mails werden nicht rückwirkend + umgeroutet. + + + + {error &&

{error}

} + + {loading ? ( +
+ + +
+ ) : rules.length === 0 ? ( +

+ Noch keine Routing-Regeln definiert. +

+ ) : ( +
+ + + + Prio + Typ + Muster + {isSuperAdmin && Mandant} + Angelegt + + + + + {rules.map((r) => ( + + {r.priority} + {MATCH_LABELS[r.match_type] ?? r.match_type} + + {r.pattern} + + {isSuperAdmin && {tenantName(r.tenant_id)}} + + {formatDate(r.created_at)} + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+
+ + {/* Create / Edit Dialog */} + { + if (!o) { + setEdit(null); + resetDryRun(); + } + }} + > + + + + {edit?.id === null ? "Regel hinzufügen" : "Regel bearbeiten"} + + + Legt fest, welche eingehenden Mails automatisch welchem Mandanten + zugeordnet werden. + + + + {edit && ( +
+
+ + +
+ +
+ + { + setEdit({ ...edit, pattern: e.target.value }); + resetDryRun(); + }} + /> +
+ + {isSuperAdmin && ( +
+ + +
+ )} + +
+ + setEdit({ ...edit, priority: e.target.value })} + /> +

+ Höhere Zahl = höhere Priorität. Bei mehreren Treffern gewinnt + die Regel mit der höchsten Priorität. +

+
+ + {/* Dry-Run */} +
+
+
+

Test (Dry-Run)

+

+ Zeigt, wie viele bereits archivierte Mails dieses Muster + treffen würde (nur zur Vorschau). +

+
+ +
+ + {dryRunError && ( +

{dryRunError}

+ )} + + {dryRunResult && ( +
+

+ {dryRunResult.match_count} Treffer{" "} + + (Stichprobe max. {dryRunResult.sample_limit}) + +

+ {dryRunResult.sample && dryRunResult.sample.length > 0 ? ( +
+ + + + Von + An + Betreff + Datum + + + + {dryRunResult.sample.map((s) => ( + + + {s.mail_from} + + + {s.mail_to} + + + {s.subject} + + + {formatDate(s.received_at)} + + + ))} + +
+
+ ) : ( +

+ Keine passenden Mails im Archiv gefunden. +

+ )} +
+ )} +
+ + {formError &&

{formError}

} +
+ )} + + + + + +
+
+ + {/* Delete Dialog */} + { + if (!o) setDeleteRule(null); + }} + > + + + Regel löschen + + Die Regel wird gelöscht. Bereits archivierte Mails bleiben ihrem + bisherigen Mandanten zugeordnet. + + + {deleteRule && ( +

+ {deleteRule.pattern} ( + {MATCH_LABELS[deleteRule.match_type]}) +

+ )} + + + + +
+
+
+ ); +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 2a7cdc3..4b0e09e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -188,6 +188,23 @@ export { exportReconciliationCSV, } from "./reconciliation"; +export type { + RoutingMatchType, + RoutingRule, + RoutingRulesResponse, + RoutingRuleInput, + RoutingDryRunSample, + RoutingDryRunResult, + RoutingDryRunInput, +} from "./routing_rules"; +export { + getRoutingRules, + createRoutingRule, + updateRoutingRule, + deleteRoutingRule, + dryRunRoutingRule, +} from "./routing_rules"; + export type { SavedSearch } from "./saved_searches"; export { listSavedSearches, diff --git a/src/lib/api/routing_rules.ts b/src/lib/api/routing_rules.ts new file mode 100644 index 0000000..52a1222 --- /dev/null +++ b/src/lib/api/routing_rules.ts @@ -0,0 +1,94 @@ +import { request } from "./core"; + +// PROJ-43: Tenant-Routing-Regeln — automatische Mandanten-Zuordnung nach +// Domain/Absender-Mustern mit Priorität + Dry-Run. +// CRUD unter /api/admin/routing-rules (domain_admin+; superadmin = alle Tenants, +// domain_admin = eigener Tenant, serverseitig erzwungen). + +// Match-Typen — müssen mit den Backend-Konstanten übereinstimmen. +export type RoutingMatchType = + | "from_domain" + | "to_domain" + | "from_addr" + | "to_addr"; + +export interface RoutingRule { + id: number; + tenant_id: number; + match_type: RoutingMatchType; + pattern: string; + priority: number; + created_at: string; +} + +export interface RoutingRulesResponse { + rules: RoutingRule[] | null; +} + +// tenant_id optional: nur superadmin muss ihn setzen; domain_admin bekommt +// serverseitig seinen eigenen Tenant erzwungen. +export interface RoutingRuleInput { + tenant_id?: number; + match_type: RoutingMatchType; + pattern: string; + priority: number; +} + +export interface RoutingDryRunSample { + id: string; + mail_from: string; + mail_to: string; + subject: string; + received_at: string | null; +} + +export interface RoutingDryRunResult { + match_type: RoutingMatchType; + pattern: string; + match_count: number; + sample_limit: number; + sample: RoutingDryRunSample[] | null; +} + +// Dry-Run kann entweder gegen eine bestehende Regel (rule_id) oder gegen ein +// noch nicht gespeichertes Muster (match_type + pattern) laufen. +export type RoutingDryRunInput = + | { rule_id: number; limit?: number } + | { match_type: RoutingMatchType; pattern: string; limit?: number }; + +export async function getRoutingRules(): Promise { + const data = await request("/api/admin/routing-rules"); + return data.rules ?? []; +} + +export async function createRoutingRule( + input: RoutingRuleInput +): Promise<{ id: number }> { + return request<{ id: number }>("/api/admin/routing-rules", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function updateRoutingRule( + id: number, + input: RoutingRuleInput +): Promise { + await request(`/api/admin/routing-rules/${id}`, { + method: "PUT", + body: JSON.stringify(input), + }); +} + +export async function deleteRoutingRule(id: number): Promise { + await request(`/api/admin/routing-rules/${id}`, { method: "DELETE" }); +} + +export async function dryRunRoutingRule( + input: RoutingDryRunInput +): Promise { + return request("/api/admin/routing-rules/dry-run", { + method: "POST", + body: JSON.stringify(input), + }); +}