feat(PROJ-46): E-Mail als primärer Login-Identifier für Tenant-User
Tenant-User (tenant_id IS NOT NULL) melden sich künftig per E-Mail an statt per Username — behebt Verwechslungen wie im Support-Fall vom 2026-06-13 (Login schlug trotz Passwort-Reset fehl, weil E-Mail statt Username verwendet wurde). Nicht-Tenant-User (Superadmin/System) können weiterhin Username ODER E-Mail nutzen. Neue Store.VerifyLogin() prüft erst per E-Mail (alle User), fällt dann auf Username zurück (nur tenant_id IS NULL). VerifyPassword() bleibt für den IMAP-Server-Login-Pfad (PROJ-26) unverändert. Bewusster Breaking Change für Tenant-User, Datenqualität vorab geprüft (0 Kollisionen). Security-Nachtrag: bcrypt-Dummy-Compare im "user not found"-Pfad ergänzt, um Timing-basierte Identifier-Enumeration zu verhindern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
804cd62201
commit
767373b206
+2
-2
@@ -59,10 +59,10 @@
|
|||||||
| PROJ-40 | Prometheus Metriken + Health-Check | Deployed | [PROJ-40](PROJ-40-prometheus-metriken.md) | 2026-04-05 |
|
| 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-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-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-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-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-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-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 |
|
| PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | Deployed | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 |
|
||||||
|
|||||||
@@ -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": <int>}` (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 + `<TabsContent value="routing-rules">`
|
||||||
|
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).
|
||||||
@@ -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": "<identifier>", "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)
|
||||||
@@ -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.
|
||||||
@@ -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("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("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)))
|
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
|
// PROJ-56c: pro-Mail Löschmarkierung — domain_admin+, tenant-scoped (kein
|
||||||
// Mail-Lesezugriff nötig, daher requireRole statt requireMailAccess).
|
// Mail-Lesezugriff nötig, daher requireRole statt requireMailAccess).
|
||||||
s.mux.HandleFunc("GET /api/admin/retention/expired", s.authAdmin(s.handleListExpiredMails))
|
s.mux.HandleFunc("GET /api/admin/retention/expired", s.authAdmin(s.handleListExpiredMails))
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ func (m *Manager) SetTenantLDAP(tenantLdapStore *ldapcfg.TenantStore, tenantLook
|
|||||||
// short-lived pending token that can only be used with ValidateTOTPLogin.
|
// 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) {
|
func (m *Manager) Login(username, password string) (token string, user *userstore.User, totpRequired bool, err error) {
|
||||||
// 1. Try local authentication first.
|
// 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 err == nil {
|
||||||
if user.TOTPEnabled {
|
if user.TOTPEnabled {
|
||||||
t, e := m.issuePendingTOTPToken(user)
|
t, e := m.issuePendingTOTPToken(user)
|
||||||
|
|||||||
@@ -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.
|
// accountID identifies the IMAP account for PROJ-52 source tracking.
|
||||||
func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, accountID int64, log *slog.Logger) error {
|
func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, accountID int64, log *slog.Logger) error {
|
||||||
ctx := context.Background()
|
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)
|
// Save to file storage (deduplicates by SHA256 automatically)
|
||||||
id, err := imp.mailStore.Save(ctx, raw, time.Now(), tenantID)
|
id, err := imp.mailStore.Save(ctx, raw, time.Now(), tenantID)
|
||||||
if err != nil {
|
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)
|
log.Warn("failed to tag source", "id", id, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse for indexing
|
// Parse for indexing (reuse the early parse from routing-rule resolution).
|
||||||
pm, err := mailparser.Parse(raw)
|
if parseErr != nil {
|
||||||
if err != nil {
|
log.Warn("failed to parse mail for indexing", "id", id, "err", parseErr)
|
||||||
log.Warn("failed to parse mail for indexing", "id", id, "err", err)
|
|
||||||
// Store succeeded, just skip indexing for unparseable mails
|
// Store succeeded, just skip indexing for unparseable mails
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// the real sender/recipient domain is only visible in the RFC 2822 headers.
|
||||||
func (d *Daemon) resolveTenant(rcpts []string, raw []byte) *int64 {
|
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 {
|
if d.domainToTenant == nil {
|
||||||
return d.defaultTenantID
|
return d.defaultTenantID
|
||||||
}
|
}
|
||||||
@@ -144,6 +151,38 @@ func (d *Daemon) resolveTenant(rcpts []string, raw []byte) *int64 {
|
|||||||
return d.defaultTenantID
|
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.
|
// SetIndexCallback sets the function called after each successfully stored mail.
|
||||||
func (d *Daemon) SetIndexCallback(cb IndexCallback) {
|
func (d *Daemon) SetIndexCallback(cb IndexCallback) {
|
||||||
d.indexCallback = cb
|
d.indexCallback = cb
|
||||||
|
|||||||
@@ -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)`)
|
_, _ = 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
|
// PROJ-51: archiving_rules table + retain_until_source column
|
||||||
s.initRetentionRulesSchema(ctx)
|
s.initRetentionRulesSchema(ctx)
|
||||||
|
// PROJ-43: tenant_routing_rules table (pattern-based tenant assignment)
|
||||||
|
s.initTenantRoutingRulesSchema(ctx)
|
||||||
// PROJ-50: DSGVO Löschersuchen
|
// PROJ-50: DSGVO Löschersuchen
|
||||||
s.initDSGVOSchema(ctx)
|
s.initDSGVOSchema(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <addr>" 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()
|
||||||
|
}
|
||||||
@@ -20,6 +20,13 @@ const (
|
|||||||
RoleSuperAdmin = "superadmin"
|
RoleSuperAdmin = "superadmin"
|
||||||
|
|
||||||
bcryptCost = 12
|
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.
|
// User represents a user account in the system.
|
||||||
@@ -284,6 +291,59 @@ func (s *Store) VerifyPassword(username, password string) (*User, error) {
|
|||||||
return &u, nil
|
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.
|
// Update applies a partial update to a user record.
|
||||||
func (s *Store) Update(id int64, req UpdateUserRequest) (*User, error) {
|
func (s *Store) Update(id int64, req UpdateUserRequest) (*User, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -517,6 +577,19 @@ func scanUser(row pgx.Row) (*User, error) {
|
|||||||
return &u, nil
|
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) {
|
func scanUserRow(rows pgx.Rows) (*User, error) {
|
||||||
var u User
|
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 {
|
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 {
|
||||||
|
|||||||
@@ -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) {
|
func TestUpdateUser(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
u, _ := s.Create(userstore.CreateUserRequest{
|
u, _ := s.Create(userstore.CreateUserRequest{
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ export default function AdminLoginPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Benutzername</Label>
|
<Label htmlFor="username">Benutzername oder E-Mail-Adresse</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Benutzername"
|
placeholder="Benutzername oder E-Mail-Adresse"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { ModulesTab } from "@/components/admin/ModulesTab";
|
|||||||
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
|
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
|
||||||
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
|
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
|
||||||
import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab";
|
import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab";
|
||||||
|
import { RoutingRulesTab } from "@/components/admin/tabs/RoutingRulesTab";
|
||||||
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
|
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
|
||||||
import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab";
|
import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab";
|
||||||
import { DSGVOTab } from "@/components/admin/tabs/DSGVOTab";
|
import { DSGVOTab } from "@/components/admin/tabs/DSGVOTab";
|
||||||
@@ -167,6 +168,7 @@ export default function AdminPage() {
|
|||||||
{isSuperAdmin && <TabsTrigger value="tenants" onClick={tenantsState.loadTenants}>Mandanten</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="tenants" onClick={tenantsState.loadTenants}>Mandanten</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="archiving-rules">Regeln</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="archiving-rules">Regeln</TabsTrigger>}
|
||||||
|
<TabsTrigger value="routing-rules">Routing-Regeln</TabsTrigger>
|
||||||
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="smtp-out">SMTP-Out</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="smtp-out">SMTP-Out</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
||||||
@@ -442,6 +444,10 @@ export default function AdminPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<TabsContent value="routing-rules">
|
||||||
|
<RoutingRulesTab isSuperAdmin={isSuperAdmin} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<TabsContent value="smtp-out">
|
<TabsContent value="smtp-out">
|
||||||
<SMTPOutTab />
|
<SMTPOutTab />
|
||||||
|
|||||||
+5
-5
@@ -60,16 +60,16 @@ export default function LoginPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Benutzername</Label>
|
<Label htmlFor="username">E-Mail-Adresse</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="email"
|
||||||
placeholder="Benutzername"
|
placeholder="E-Mail-Adresse"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="email"
|
||||||
aria-label="Benutzername"
|
aria-label="E-Mail-Adresse"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -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<RoutingMatchType, string> = {
|
||||||
|
from_domain: "Absender-Domain",
|
||||||
|
to_domain: "Empfänger-Domain",
|
||||||
|
from_addr: "Absender-Adresse",
|
||||||
|
to_addr: "Empfänger-Adresse",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MATCH_PLACEHOLDER: Record<RoutingMatchType, string> = {
|
||||||
|
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<RoutingRule[]>([]);
|
||||||
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [edit, setEdit] = useState<EditState | null>(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<RoutingDryRunResult | null>(null);
|
||||||
|
const [dryRunError, setDryRunError] = useState("");
|
||||||
|
|
||||||
|
const [deleteRule, setDeleteRule] = useState<RoutingRule | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const loaders: [Promise<RoutingRule[]>, Promise<Tenant[]>] = [
|
||||||
|
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 (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Routing-Regeln</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ordnen eingehende Mails automatisch einem Mandanten zu — nach
|
||||||
|
Absender-/Empfänger-Domain oder -Adresse. Wildcard-Domains via{" "}
|
||||||
|
<code className="text-xs">*.kunde.de</code>.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={openCreate}>
|
||||||
|
Regel hinzufügen
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
<strong>Priorität:</strong> 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 —{" "}
|
||||||
|
<strong>bereits archivierte Mails werden nicht rückwirkend
|
||||||
|
umgeroutet.</strong>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Noch keine Routing-Regeln definiert.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">Prio</TableHead>
|
||||||
|
<TableHead>Typ</TableHead>
|
||||||
|
<TableHead>Muster</TableHead>
|
||||||
|
{isSuperAdmin && <TableHead>Mandant</TableHead>}
|
||||||
|
<TableHead>Angelegt</TableHead>
|
||||||
|
<TableHead className="w-40"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rules.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>{r.priority}</TableCell>
|
||||||
|
<TableCell>{MATCH_LABELS[r.match_type] ?? r.match_type}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs break-all">
|
||||||
|
{r.pattern}
|
||||||
|
</TableCell>
|
||||||
|
{isSuperAdmin && <TableCell>{tenantName(r.tenant_id)}</TableCell>}
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{formatDate(r.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openEdit(r)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => setDeleteRule(r)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create / Edit Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!edit}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) {
|
||||||
|
setEdit(null);
|
||||||
|
resetDryRun();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{edit?.id === null ? "Regel hinzufügen" : "Regel bearbeiten"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Legt fest, welche eingehenden Mails automatisch welchem Mandanten
|
||||||
|
zugeordnet werden.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{edit && (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Match-Typ</Label>
|
||||||
|
<Select
|
||||||
|
value={edit.match_type}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setEdit({ ...edit, match_type: v as RoutingMatchType });
|
||||||
|
resetDryRun();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(MATCH_LABELS) as RoutingMatchType[]).map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{MATCH_LABELS[k]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="routing-pattern">Muster</Label>
|
||||||
|
<Input
|
||||||
|
id="routing-pattern"
|
||||||
|
placeholder={MATCH_PLACEHOLDER[edit.match_type]}
|
||||||
|
value={edit.pattern}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEdit({ ...edit, pattern: e.target.value });
|
||||||
|
resetDryRun();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mandant</Label>
|
||||||
|
<Select
|
||||||
|
value={edit.tenant_id}
|
||||||
|
onValueChange={(v) => setEdit({ ...edit, tenant_id: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Mandant auswählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={String(t.id)}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="routing-priority">Priorität</Label>
|
||||||
|
<Input
|
||||||
|
id="routing-priority"
|
||||||
|
type="number"
|
||||||
|
value={edit.priority}
|
||||||
|
onChange={(e) => setEdit({ ...edit, priority: e.target.value })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Höhere Zahl = höhere Priorität. Bei mehreren Treffern gewinnt
|
||||||
|
die Regel mit der höchsten Priorität.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dry-Run */}
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Test (Dry-Run)</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Zeigt, wie viele bereits archivierte Mails dieses Muster
|
||||||
|
treffen würde (nur zur Vorschau).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={dryRunning}
|
||||||
|
onClick={handleDryRun}
|
||||||
|
>
|
||||||
|
{dryRunning ? "Teste..." : "Dry-Run"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dryRunError && (
|
||||||
|
<p className="text-sm text-destructive">{dryRunError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dryRunResult && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
<Badge>{dryRunResult.match_count} Treffer</Badge>{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(Stichprobe max. {dryRunResult.sample_limit})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{dryRunResult.sample && dryRunResult.sample.length > 0 ? (
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Von</TableHead>
|
||||||
|
<TableHead>An</TableHead>
|
||||||
|
<TableHead>Betreff</TableHead>
|
||||||
|
<TableHead>Datum</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dryRunResult.sample.map((s) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell className="max-w-[10rem] truncate text-xs">
|
||||||
|
{s.mail_from}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[10rem] truncate text-xs">
|
||||||
|
{s.mail_to}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[12rem] truncate text-xs">
|
||||||
|
{s.subject}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{formatDate(s.received_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Keine passenden Mails im Archiv gefunden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEdit(null);
|
||||||
|
resetDryRun();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button disabled={saving} onClick={handleSave}>
|
||||||
|
{saving ? "Speichern..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!deleteRule}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setDeleteRule(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Regel löschen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Die Regel wird gelöscht. Bereits archivierte Mails bleiben ihrem
|
||||||
|
bisherigen Mandanten zugeordnet.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{deleteRule && (
|
||||||
|
<p className="py-2 text-sm">
|
||||||
|
<span className="font-mono">{deleteRule.pattern}</span> (
|
||||||
|
{MATCH_LABELS[deleteRule.match_type]})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteRule(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{deleting ? "Lösche..." : "Löschen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -188,6 +188,23 @@ export {
|
|||||||
exportReconciliationCSV,
|
exportReconciliationCSV,
|
||||||
} from "./reconciliation";
|
} 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 type { SavedSearch } from "./saved_searches";
|
||||||
export {
|
export {
|
||||||
listSavedSearches,
|
listSavedSearches,
|
||||||
|
|||||||
@@ -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<RoutingRule[]> {
|
||||||
|
const data = await request<RoutingRulesResponse>("/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<void> {
|
||||||
|
await request<void>(`/api/admin/routing-rules/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRoutingRule(id: number): Promise<void> {
|
||||||
|
await request<void>(`/api/admin/routing-rules/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dryRunRoutingRule(
|
||||||
|
input: RoutingDryRunInput
|
||||||
|
): Promise<RoutingDryRunResult> {
|
||||||
|
return request<RoutingDryRunResult>("/api/admin/routing-rules/dry-run", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user