Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 e4856dfc9d IAM-06: sso-anmeldung-ueber-oidc
internal/oidc: JWKS-Parser (RSA-Schluessel, RFC 7517) + Verifier prueft
ID-Tokens gegen den geparsten Schluesselsatz (Signatur, Ablauf ueber die
jwt-Bibliothek, Aussteller) — Akzeptanzkriterium 3. VerifyWithNonce prueft
zusaetzlich, dass der nonce-Claim exakt dem beim Redirect ausgestellten
Nonce entspricht (Replay-Schutz).

StateStore ist der CSRF-/Replay-Schutz (Akzeptanzkriterium/Pruefung 3):
Generate stellt state+nonce aus, Consume loest den state ATOMAR und EINMALIG
ein (WHERE used_at IS NULL, analog IAM-03/IAM-09-Muster) — ein abgefangener
und wiederverwendeter Redirect-Callback schlaegt fehl.

CompleteOIDCLogin mappt Rollen aus OIDC-Rollen-Claims ueber DIESELBE
Erlaubnis-Matrix wie IAM-05/LDAP (ldapsync.RoleMappingStore.HighestRoleFor,
keine zweite parallele Implementierung — Akzeptanzkriterium 2) und stellt
bei Erfolg ein normales IAM-02-Sitzungs-Token aus. Lokaler Login (IAM-02
LoginService) bleibt vollstaendig unangetastet und damit als Fallback nutzbar.

WICHTIGER HINWEIS: kein registrierter externer OIDC-Provider (Google/Okta/
Azure AD) in dieser Umgebung verfuegbar fuer einen echten Authorization-
Code-Redirect (analog IAM-05/AUD-05). ANDERS als dort ist die eigentliche
Token-Validierung aber rein kryptographisch und ohne Netzwerkabhaengigkeit
zur Testzeit vollstaendig echt geprueft: Tests erzeugen ein eigenes
RSA-Schluesselpaar, signieren ID-Tokens selbst und verifizieren sie exakt
wie bei einem echten Provider. Nur der Live-Redirect zu einem realen
externen IdP bleibt ungeprueft.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Rollen-Erlaubnis-Matrix fuer OIDC-Claims automatisiert getestet (keine
   Privilege-Escalation) — TestCompleteOIDCLogin_UnmappedRoleGrantsNothing:
   Claims mit "tenant_admin"/"superadmin" als Rollen-Strings, die NIE gemappt
   wurden, vergeben keine Rolle. PASS.
2. Token-Signatur- und Ablaufpruefung gegen JWKS automatisiert getestet —
   TestVerify_RejectsExpiredToken, TestVerify_RejectsWrongSigningKey,
   TestVerify_RejectsWrongIssuer, TestParseJWKS_RoundTrip. PASS.
3. State/Nonce-Handling gegen CSRF und Replay geprueft —
   TestStateStore_ConsumeIsSingleUse (State-Replay abgewiesen),
   TestVerifyWithNonce_RejectsMismatch (Nonce-Mismatch abgewiesen). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:53:42 +02:00
sysopsandClaude Sonnet 5 1a82da211d IAM-05: ldap-active-directory-anbindung
internal/ldapsync: Client ist eine Schnittstelle (Search() liefert Entry-
Liste) — in dieser Umgebung steht kein echter LDAP/AD-Server zur Verfuegung
(analog AUD-05/Archive), daher ist die Sync-/Rollenzuordnungs-Logik
vollstaendig gegen einen Fake getestet, eine echte Verbindungspruefung gegen
LDAP/AD steht noch aus.

RoleMappingStore (Akzeptanzkriterium 3): explizite Erlaubnis-Matrix
LDAP-Gruppe -> Rolle. HighestRoleFor vergibt NUR fuer explizit gemappte
Gruppen eine Rolle — eine unbekannte Gruppe, auch wenn ihr Name zufaellig
wie eine interne Rolle aussieht ("tenant_admin", "superadmin"), traegt
strukturell nichts bei (kein Code-Pfad, der eine ungemappte Gruppe je einer
Rolle zuordnet) — behebt die aus archivmail bekannte Privilege-Escalation-
Fehlerklasse von Grund auf statt nachtraeglich zu haerten.

Syncer.Run ruft Search() als ALLERERSTES auf; schlaegt es fehl, wird ohne
jede Aenderung an bestehenden Konten abgebrochen (Akzeptanzkriterium 2).
Pro Eintrag isolierte Fehler landen in SyncResult.Failed, ohne andere
Eintraege zu beeintraechtigen. Deaktivierung in LDAP wird als
users.Deactivate uebernommen (Akzeptanzkriterium 3). ldap_config speichert
bewusst nur den NAMEN einer Umgebungsvariable fuer das Bind-Passwort, nie
das Passwort selbst.

internal/rbac (RBAC-01) wurde 1:1 aus dem rbac-01-Branch uebernommen (git
show aus derselben Repo-Historie) — IAM-05 haengt an RBAC-01 fuer die
Rollenzuweisung, beide Boards leben aber auf getrennten, noch nicht
gemergten Feature-Branches ohne gemeinsame Historie.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Rollen-Erlaubnis-Matrix mit Positiv- und Negativfaellen automatisiert
   getestet (keine Privilege-Escalation) — TestRoleMapping_PositiveAndNegativeCases:
   gemappte Gruppe liefert Rolle, unbekannte Gruppe und rollen-aehnlich
   benannte, aber nie gemappte Gruppen liefern keine. PASS.
2. Synchronisationslauf mit fehlerhafter/nicht erreichbarer LDAP-Quelle
   bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen —
   TestSyncer_AbortsCleanlyOnSourceError: bestehendes Konto bleibt nach
   fehlgeschlagenem Lauf unveraendert aktiv. PASS.
3. Deaktivierung eines Benutzers in LDAP wird bei naechster Synchronisation
   korrekt uebernommen — TestSyncer_AppliesDeactivationOnNextRun: erster
   Lauf legt aktiven Benutzer an, zweiter Lauf mit Disabled=true setzt ihn
   auf inaktiv. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:39:10 +02:00
sysopsandClaude Sonnet 5 3d20d86a4f IAM-02: login-session-jwt-grundgeruest
internal/auth: Login/Logout ueber httpOnly/Secure/SameSite=Strict-Cookie mit
HS256-JWT (30min TTL), bcrypt-Passwort-Hashing (Cost 12, explizit begruendet
und benchmarkt statt DefaultCost uebernommen), RequireAuth-Middleware fuer
geschuetzte Routen. LoginService ist strukturell auf einen Tenant gescopt
(nutzt user.TenantUserStore, dessen Pool = eine Tenant-DB — derselbe
Mechanismus wie in TEN-01/TEN-02), liefert bei falscher E-Mail und falschem
Passwort denselben Fehler (User-Enumeration-Schutz) inkl. Dummy-bcrypt-
Vergleich gegen Timing-Seitenkanal bei unbekannter E-Mail.

user.TenantUserStore erweitert um SetPasswordHash/GetByEmailForAuth
(password_hash bleibt ausserhalb des regulaeren User-Typs/JSON-Pfads).
Migration 0002 fuegt password_hash-Spalte hinzu (Default '', da IAM-01
User ohne Passwort anlegt).

Login-Handler ist wie IAM-01/TEN-02 aus denselben Gruenden (Tenant-
Connection-Routing = TEN-06, noch nicht gebaut) nicht in cmd/core/main.go
verdrahtet — Package ist eigenstaendig nutzbar/getestet.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Login-Query tenant-gescopt — TestLoginService_NoCrossTenantLogin: gleiche
   E-Mail in zwei Tenant-DBs mit unterschiedlichem Passwort, Login gegen
   Tenant A mit Tenant-B-Passwort schlaegt fehl. PASS.
2. Session-Fixation/Token-Manipulation — TestTokenVerify_RejectsManipulatedPayload
   und TestTokenVerify_RejectsWrongSecret: manipuliertes/falsch signiertes
   Token wird abgelehnt. PASS.
3. Abgelaufenes Token erzwingt Neuanmeldung — TestTokenVerify_RejectsExpiredToken
   und TestRequireAuth_BlocksWithoutValidCookie. PASS.
4. Login-Latenz mit Kostenfaktor 12 gemessen: 294ms (Ziel < 400ms) —
   TestBcryptCostAgainstLatencyTarget. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:26:00 +02:00
sysopsandClaude Sonnet 5 e4793303fc IAM-01: benutzer-datenmodell-crud
Benutzer-Datenmodell + CRUD fuer Tenant-User (tenant-scoped, keine
tenant_id-Spalte noetig, Tenant ergibt sich aus der DB-Verbindung, Modell C)
und getrennt dafuer SuperadminStore fuer mandantenuebergreifende Konten in
der Registry-DB — First-Class-Typ statt tenant_id-NULL-Sonderfall im
Tenant-User-Code (bekannter archivdms-Fehler vermieden).

E-Mail-Eindeutigkeit: tenant-scoped fuer normale Benutzer (UNIQUE-Constraint
gilt nur innerhalb der jeweiligen Tenant-DB), global fuer Superadmins
(eine Registry-DB, ein UNIQUE-Constraint).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. CRUD automatisiert getestet inkl. Negativfaellen — TestTenantUserStore_CRUD
   deckt doppelte E-Mail (ErrEmailTaken) und unbekannte ID ab. PASS.
2. Superadmin-Anlage ohne Tenant-Kontext — TestSuperadminStore_CreateWithoutTenantContext:
   SuperadminStore.Create hat syntaktisch keinen Tenant-Parameter, kein
   if-Zweig fuer "kein Tenant" im Code. PASS.
3. Datenmodell von zweiter Person gegen Dokumentation geprueft — NICHT
   durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.

Tenant-User-Handler ist im Code vorhanden, aber in cmd/core/main.go noch
nicht geroutet — braucht Connection-Routing pro Mandant (TEN-06), das nicht
Teil dieser Kachel ist. Nur der Superadmin-Endpunkt ist verdrahtet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:57:49 +02:00
sysopsandClaude Sonnet 5 8da9c67d08 TEN-01: tenant-registry-datenbank-provisioning
Registry-DB (nur Tenant-Metadaten), Provisioning-Routine legt pro Mandant
eine physisch isolierte Postgres-DB an und registriert sie transaktional
(Rollback der DB bei fehlgeschlagener Registrierung). Schlanker HTTP-Handler
als Schnittstellen-Vorbereitung fuer API-01/TEN-02, kein eigenes REST-Grundgerüst.

Pruefungen:
1. Migration up/down geschrieben (0001_tenant_registry.{up,down}.sql) — nicht
   gegen echte DB ausgefuehrt, da auf dieser Maschine kein Go/Postgres-Test-
   Setup verfuegbar ist. Offen zur Ausfuehrung.
2. Integrationstest TestProvision_CreatesIsolatedDatabases geschrieben (zwei
   Mandanten, prueft unterschiedliche db_name und current_database()) —
   ebenfalls nicht ausgefuehrt, guarded per TEST_ADMIN_DSN env var. Offen.
3. Slug-Validierung (unit test TestValidateSlug) deckt SQL-Injection-Versuch
   im Datenbanknamen ab — ebenfalls nicht lokal ausgefuehrt, da kein Go
   Compiler auf dieser Maschine vorhanden ist. Offen.

Alle drei Pruefungen sind vorbereitet, aber NICHT durchgefuehrt worden —
zaehlen laut Vorgabe als offen bis auf einer Maschine mit Go+Postgres verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:40:35 +02:00
136 changed files with 3141 additions and 6339 deletions
@@ -1,23 +0,0 @@
name: Mail-Pflichttest-Gate
on:
pull_request:
paths:
- "mail/**"
jobs:
pflichttest-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Gate bauen
working-directory: mail
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
- name: Geänderte Dateien gegen Pflichttest-Regel prüfen
run: |
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | /tmp/pflichttestgate
+85
View File
@@ -44,3 +44,88 @@ Keine Commits in dieser Session.
Keine Änderungen ermittelbar.
---
## 2026-08-27 17:26 17:28 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration)
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:31 17:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:36 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:37 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
+27 -3
View File
@@ -7,6 +7,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func main() {
@@ -15,16 +17,38 @@ func main() {
log.Fatalf("config: %v", err)
}
pool, err := db.Connect(context.Background(), cfg.RegistryDSN)
ctx := context.Background()
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
if err != nil {
log.Fatalf("db: %v", err)
log.Fatalf("registry db: %v", err)
}
defer pool.Close()
defer registryPool.Close()
adminPool, err := db.Connect(ctx, cfg.AdminDSN)
if err != nil {
log.Fatalf("admin db: %v", err)
}
defer adminPool.Close()
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
superadmins := user.NewSuperadminStore(registryPool)
userHandler := user.NewHandler(nil, superadmins)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
+13 -1
View File
@@ -2,4 +2,16 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.22
require github.com/jackc/pgx/v5 v5.6.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/crypto v0.17.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
+30
View File
@@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+67
View File
@@ -0,0 +1,67 @@
package auth
import (
"encoding/json"
"net/http"
"time"
)
// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung,
// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht
// Teil dieser Kachel (siehe IAM-03..07).
type Handler struct {
login *LoginService
}
func NewHandler(login *LoginService) *Handler {
return &Handler{login: login}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
token, err := h.login.Login(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(AccessTokenTTL.Seconds()),
})
w.WriteHeader(http.StatusOK)
}
// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt
// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst"
// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung),
// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch
// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
w.WriteHeader(http.StatusOK)
}
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch")
// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber
// den TenantUserStore-Pool) — das Login ist damit strukturell auf den
// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth.
type LoginService struct {
users *user.TenantUserStore
issuer *TokenIssuer
// tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten
// eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs).
tenantSlug string
}
func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService {
return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug}
}
// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben
// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz
// eines Kontos zuzulassen (User-Enumeration-Schutz).
func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) {
creds, err := s.users.GetByEmailForAuth(ctx, email)
if err != nil {
// Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren,
// damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer
// ist als bei falschem Passwort (Timing-Seitenkanal).
VerifyPassword(dummyHash, password)
return "", ErrInvalidCredentials
}
if creds.User.Status != user.StatusActive {
return "", ErrInvalidCredentials
}
if !VerifyPassword(creds.PasswordHash, password) {
return "", ErrInvalidCredentials
}
return s.issuer.Issue(creds.User.ID, s.tenantSlug)
}
// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines
// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da
// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
// Timing-Angleichung damit wirkungslos waere.
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
+177
View File
@@ -0,0 +1,177 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, usersSchema); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User {
t.Helper()
ctx := context.Background()
u, err := store.Create(ctx, email, "Test User")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
return u
}
func TestLoginService_SuccessAndWrongPassword(t *testing.T) {
pool := setupTenantDB(t, "test_iam02_login")
store := user.NewTenantUserStore(pool)
createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
login := NewLoginService(store, issuer, "acme")
token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres token")
}
if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err)
}
if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err)
}
}
// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in
// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern
// existiert.
func TestLoginService_NoCrossTenantLogin(t *testing.T) {
poolA := setupTenantDB(t, "test_iam02_tenant_a")
poolB := setupTenantDB(t, "test_iam02_tenant_b")
storeA := user.NewTenantUserStore(poolA)
storeB := user.NewTenantUserStore(poolB)
createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a")
createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
loginA := NewLoginService(storeA, issuer, "tenant-a")
// Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen,
// obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt
// strukturell nur die Zeilen ihrer eigenen Datenbank.
if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err)
}
token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a")
if err != nil {
t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.TenantSlug != "tenant-a" {
t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug)
}
}
// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar.
func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Kein Cookie.
req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
rec := httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne cookie: status = %d, want 401", rec.Code)
}
// Manipuliertes Cookie.
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code)
}
// Gueltiges Token.
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code)
}
}
+41
View File
@@ -0,0 +1,41 @@
package auth
import (
"context"
"net/http"
)
const CookieName = "nexarch_session"
type contextKey int
const claimsContextKey contextKey = iota
// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token
// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen
// (IAM-02 Akzeptanzkriterium 3).
func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(CookieName)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
next(w, r.WithContext(ctx))
}
}
// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context
// gelegt hat.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsContextKey).(*Claims)
return c, ok
}
+28
View File
@@ -0,0 +1,28 @@
// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und
// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf)
// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur
// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket).
package auth
import "golang.org/x/crypto/bcrypt"
// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10)
// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12
// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go
// und den Pruefungs-Eintrag in der Commit-Nachricht.
const BcryptCost = 12
func HashPassword(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht
// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer
// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02).
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
+42
View File
@@ -0,0 +1,42 @@
package auth
import (
"testing"
"time"
)
// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der
// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist
// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber
// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12)
// zuverlaessig aufzudecken.
const TargetLoginLatency = 400 * time.Millisecond
// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines
// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das
// Ergebnis (IAM-02 Pruefung 4).
func TestBcryptCostAgainstLatencyTarget(t *testing.T) {
hash, err := HashPassword("benchmark-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
start := time.Now()
if !VerifyPassword(hash, "benchmark-passwort") {
t.Fatal("verifikation haette erfolgreich sein muessen")
}
elapsed := time.Since(start)
t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency)
if elapsed > TargetLoginLatency {
t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency)
}
}
func BenchmarkVerifyPassword(b *testing.B) {
hash, _ := HashPassword("benchmark-passwort")
b.ResetTimer()
for i := 0; i < b.N; i++ {
VerifyPassword(hash, "benchmark-passwort")
}
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("s3hr-geheim!")
if err != nil {
t.Fatalf("hash: %v", err)
}
if hash == "s3hr-geheim!" {
t.Fatal("passwort wurde nicht gehasht")
}
if !VerifyPassword(hash, "s3hr-geheim!") {
t.Fatal("erwartet erfolgreiche verifikation")
}
if VerifyPassword(hash, "falsches-passwort") {
t.Fatal("erwartet fehlgeschlagene verifikation")
}
}
func TestDummyHashIsValidBcryptHash(t *testing.T) {
// Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich
// ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft
// (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen.
if VerifyPassword(dummyHash, "irgendein-text") {
t.Fatal("dummyHash sollte fuer beliebigen text nicht passen")
}
}
+63
View File
@@ -0,0 +1,63 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
const AccessTokenTTL = 30 * time.Minute
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
type Claims struct {
UserID string `json:"uid"`
TenantSlug string `json:"tenant"`
jwt.RegisteredClaims
}
// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das
// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist
// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das
// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses.
type TokenIssuer struct {
secret []byte
}
func NewTokenIssuer(secret string) *TokenIssuer {
return &TokenIssuer{secret: []byte(secret)}
}
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
TenantSlug: tenantSlug,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(i.secret)
}
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist
// timing-safe (hmac.Equal).
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return i.secret, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
+82
View File
@@ -0,0 +1,82 @@
package auth
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestTokenIssueAndVerify(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
t.Fatalf("claims unerwartet: %+v", claims)
}
}
// Pruefung 2: Token-Manipulationstest.
func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("unerwartetes token-format: %d teile", len(parts))
}
// Payload-Segment leicht veraendern (Signatur passt danach nicht mehr).
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := issuer.Verify(tampered); err == nil {
t.Fatal("erwartet fehler bei manipuliertem token, habe nil")
}
}
func TestTokenVerify_RejectsWrongSecret(t *testing.T) {
issuer := NewTokenIssuer("secret-a")
other := NewTokenIssuer("secret-b")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := other.Verify(token); err == nil {
t.Fatal("erwartet fehler bei falschem secret, habe nil")
}
}
// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung.
func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
claims := Claims{
UserID: "user-1",
TenantSlug: "acme",
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests"))
if err != nil {
t.Fatalf("signieren: %v", err)
}
if _, err := issuer.Verify(expired); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
}
}
+24 -2
View File
@@ -10,8 +10,15 @@ import (
// connection info, superadmin accounts) — see nexarch-state.json
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
type Config struct {
ListenAddr string
ListenAddr string
// RegistryDSN verbindet zur Control-Plane-Registry-Datenbank.
RegistryDSN string
// AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur
// fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet.
AdminDSN string
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
TenantDSNTemplate string
}
func Load() (Config, error) {
@@ -20,10 +27,25 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
}
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
if adminDSN == "" {
return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set")
}
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
if dsnTemplate == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
}
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
return Config{ListenAddr: addr, RegistryDSN: dsn}, nil
return Config{
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
}, nil
}
+27
View File
@@ -0,0 +1,27 @@
// Package ldapsync implementiert Core IAM-05: LDAP/Active-Directory-
// Anbindung je Tenant fuer Benutzer-Synchronisation, Login-Delegation und
// Rollenzuordnung aus Gruppen ueber eine explizite Erlaubnis-Matrix.
//
// WICHTIGER HINWEIS: In dieser Umgebung steht kein echter LDAP/AD-Server zur
// Verfuegung (analog zur Archive-Engine bei AUD-05). Client ist deshalb eine
// Schnittstelle — die Sync-/Rollenzuordnungs-Logik ist gegen einen Fake in
// den Tests vollstaendig geprueft, eine echte Verbindungspruefung gegen
// LDAP/AD steht noch aus.
package ldapsync
// Entry ist ein aus LDAP gelesener Benutzerdatensatz.
type Entry struct {
Email string
Name string
Groups []string
Disabled bool
}
// Client abstrahiert die eigentliche LDAP-Verbindung (Bind + Search).
type Client interface {
// Search liefert alle Benutzer, die dem konfigurierten Filter
// entsprechen. Ein Fehler bedeutet "Quelle nicht erreichbar oder
// fehlerhaft" — Run() bricht dann komplett und folgenlos ab
// (Akzeptanzkriterium 2).
Search() ([]Entry, error)
}
+204
View File
@@ -0,0 +1,204 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
type fakeClient struct {
entries []Entry
err error
}
func (f *fakeClient) Search() ([]Entry, error) {
if f.err != nil {
return nil, f.err
}
return f.entries, nil
}
func setupTest(t *testing.T) (*user.TenantUserStore, *rbac.Store, *RoleMappingStore, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return user.NewTenantUserStore(pool), rbac.NewStore(pool), NewRoleMappingStore(pool), cleanup
}
func uniqueEmail() string {
return fmt.Sprintf("ldap-test-%d@example.com", time.Now().UnixNano())
}
// Akzeptanzkriterium 3 + Pruefung 1: Rollen-Erlaubnis-Matrix mit Positiv- und
// Negativfaellen — keine Privilege-Escalation ueber Gruppenmitgliedschaft.
func TestRoleMapping_PositiveAndNegativeCases(t *testing.T) {
_, _, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if err := mappings.SetMapping(ctx, "cn=IT-Admins,ou=groups,dc=example,dc=com", rbac.RoleTenantAdmin); err != nil {
t.Fatalf("set mapping: %v", err)
}
// Positivfall: gemappte Gruppe liefert die konfigurierte Rolle.
role, ok, err := mappings.HighestRoleFor(ctx, []string{"cn=IT-Admins,ou=groups,dc=example,dc=com"})
if err != nil {
t.Fatalf("highest role: %v", err)
}
if !ok || role != rbac.RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin, habe ok=%v role=%q", ok, role)
}
// Negativfall 1: unbekannte Gruppe vergibt keine Rolle.
_, ok, err = mappings.HighestRoleFor(ctx, []string{"cn=Praktikanten,ou=groups,dc=example,dc=com"})
if err != nil {
t.Fatalf("highest role (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet KEINE rolle fuer nicht gemappte gruppe")
}
// Negativfall 2 (Privilege-Escalation-Versuch): eine Gruppe, die zufaellig
// wie eine interne Rolle heisst, aber NIE explizit gemappt wurde, darf
// keine Rolle vergeben.
_, ok, err = mappings.HighestRoleFor(ctx, []string{"tenant_admin", "superadmin"})
if err != nil {
t.Fatalf("highest role (namens-trick): %v", err)
}
if ok {
t.Fatal("erwartet KEINE privilege-escalation ueber gruppennamen, die wie rollen aussehen")
}
}
// Akzeptanzkriterium 1: Synchronisationslauf legt Benutzer an.
func TestSyncer_CreatesUsers(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
client := &fakeClient{entries: []Entry{{Email: email, Name: "LDAP Nutzer"}}}
syncer := NewSyncer(client, users, roles, mappings)
result, err := syncer.Run(ctx)
if err != nil {
t.Fatalf("run: %v", err)
}
if result.Created != 1 {
t.Fatalf("erwartet 1 angelegten benutzer, habe %d", result.Created)
}
if _, err := users.GetByEmailForAuth(ctx, email); err != nil {
t.Fatalf("benutzer sollte existieren: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: fehlerhafte/nicht erreichbare LDAP-
// Quelle bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen.
func TestSyncer_AbortsCleanlyOnSourceError(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
// Ein bereits bestehendes Konto, das UNBERUEHRT bleiben muss.
existingEmail := uniqueEmail()
existing, err := users.Create(ctx, existingEmail, "Bestehender Nutzer")
if err != nil {
t.Fatalf("create existing: %v", err)
}
client := &fakeClient{err: errors.New("ldap-server nicht erreichbar (simuliert)")}
syncer := NewSyncer(client, users, roles, mappings)
if _, err := syncer.Run(ctx); err == nil {
t.Fatal("erwartet fehler bei nicht erreichbarer ldap-quelle")
}
// Bestehendes Konto unveraendert (noch aktiv, keine geloeschten/neuen Zeilen).
got, err := users.Get(ctx, existing.ID)
if err != nil {
t.Fatalf("bestehendes konto sollte weiterhin existieren: %v", err)
}
if got.Status != user.StatusActive {
t.Fatalf("bestehendes konto sollte unveraendert aktiv sein, ist %q", got.Status)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Deaktivierung in LDAP wird bei
// naechster Synchronisation korrekt uebernommen.
func TestSyncer_AppliesDeactivationOnNextRun(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
client := &fakeClient{entries: []Entry{{Email: email, Name: "Wird Deaktiviert"}}}
syncer := NewSyncer(client, users, roles, mappings)
if _, err := syncer.Run(ctx); err != nil {
t.Fatalf("erster lauf: %v", err)
}
u, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("benutzer nach erstem lauf: %v", err)
}
if u.User.Status != user.StatusActive {
t.Fatalf("erwartet aktiv nach erstem lauf, habe %q", u.User.Status)
}
// Zweiter Lauf: derselbe Benutzer ist jetzt in LDAP deaktiviert.
client.entries[0].Disabled = true
result, err := syncer.Run(ctx)
if err != nil {
t.Fatalf("zweiter lauf: %v", err)
}
if result.Deactivated != 1 {
t.Fatalf("erwartet 1 deaktivierung, habe %d", result.Deactivated)
}
u, err = users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("benutzer nach zweitem lauf: %v", err)
}
if u.User.Status != user.StatusInactive {
t.Fatalf("erwartet inaktiv nach deaktivierung in ldap, habe %q", u.User.Status)
}
}
+79
View File
@@ -0,0 +1,79 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// RoleMappingStore verwaltet die Erlaubnis-Matrix LDAP-Gruppe -> Rolle
// (Akzeptanzkriterium 3). Nur explizit gemappte Gruppen vergeben eine Rolle
// — der Schutz gegen Privilege-Escalation ist strukturell: es gibt keinen
// Code-Pfad, der eine unbekannte Gruppe irgendeiner Rolle zuordnet.
type RoleMappingStore struct {
pool *pgxpool.Pool
}
func NewRoleMappingStore(pool *pgxpool.Pool) *RoleMappingStore {
return &RoleMappingStore{pool: pool}
}
// SetMapping ordnet eine LDAP-Gruppe genau einer NEXARCH-Rolle zu. Nur
// 'user'/'tenant_admin' sind zuweisbar (dieselbe Matrix wie RBAC-01/03) —
// 'superadmin' bleibt mandantenuebergreifend und nie ueber LDAP erreichbar.
func (s *RoleMappingStore) SetMapping(ctx context.Context, ldapGroup string, role rbac.Role) error {
if role != rbac.RoleUser && role != rbac.RoleTenantAdmin {
return fmt.Errorf("ldapsync: rolle %q ist ueber ldap nicht zuweisbar", role)
}
_, err := s.pool.Exec(ctx, `
INSERT INTO ldap_group_role_mapping (ldap_group, role) VALUES ($1, $2)
ON CONFLICT (ldap_group) DO UPDATE SET role = $2
`, ldapGroup, string(role))
if err != nil {
return fmt.Errorf("gruppenzuordnung speichern: %w", err)
}
return nil
}
// roleFor liefert die einer LDAP-Gruppe zugeordnete Rolle, sofern konfiguriert.
func (s *RoleMappingStore) roleFor(ctx context.Context, ldapGroup string) (rbac.Role, bool, error) {
var role string
err := s.pool.QueryRow(ctx, `SELECT role FROM ldap_group_role_mapping WHERE ldap_group = $1`, ldapGroup).Scan(&role)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("gruppenzuordnung lesen: %w", err)
}
return rbac.Role(role), true, nil
}
// HighestRoleFor bestimmt aus einer Liste von LDAP-Gruppen die hoechste
// zugeordnete Rolle (tenant_admin > user). Gruppen ohne Eintrag in der
// Matrix tragen NICHTS bei — auch nicht, wenn ihr Name zufaellig wie eine
// interne Rolle aussieht (Akzeptanzkriterium 3 / Pruefung 1: keine
// Privilege-Escalation ueber Gruppenmitgliedschaft).
func (s *RoleMappingStore) HighestRoleFor(ctx context.Context, ldapGroups []string) (rbac.Role, bool, error) {
var best rbac.Role
found := false
for _, g := range ldapGroups {
role, ok, err := s.roleFor(ctx, g)
if err != nil {
return "", false, err
}
if !ok {
continue
}
if !found || role == rbac.RoleTenantAdmin {
best = role
found = true
}
}
return best, found, nil
}
+93
View File
@@ -0,0 +1,93 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// SyncResult fasst das Ergebnis eines Synchronisationslaufs zusammen.
type SyncResult struct {
Created int
Updated int
Deactivated int
Failed []string // Email-Adressen, bei denen die Verarbeitung fehlschlug
}
// Syncer fuehrt die Benutzer-/Rollen-Synchronisation aus LDAP aus.
type Syncer struct {
client Client
users *user.TenantUserStore
roles *rbac.Store
mappings *RoleMappingStore
}
func NewSyncer(client Client, users *user.TenantUserStore, roles *rbac.Store, mappings *RoleMappingStore) *Syncer {
return &Syncer{client: client, users: users, roles: roles, mappings: mappings}
}
// Run synchronisiert Benutzer aus LDAP (Akzeptanzkriterium 1). Ist die
// LDAP-Quelle nicht erreichbar/fehlerhaft, wird SOFORT und OHNE jede
// Aenderung an bestehenden Konten abgebrochen (Akzeptanzkriterium 2 /
// Pruefung 2) — Search() wird als allererstes aufgerufen, bevor irgendein
// Schreibzugriff stattfindet.
func (s *Syncer) Run(ctx context.Context) (SyncResult, error) {
entries, err := s.client.Search()
if err != nil {
return SyncResult{}, fmt.Errorf("ldap-quelle nicht erreichbar, synchronisation abgebrochen: %w", err)
}
var result SyncResult
for _, entry := range entries {
if err := s.syncOne(ctx, entry, &result); err != nil {
// Ein fehlerhafter Einzeleintrag isoliert sich selbst — er
// beschaedigt weder bereits verarbeitete noch nachfolgende
// Konten (Akzeptanzkriterium 2).
result.Failed = append(result.Failed, entry.Email)
}
}
return result, nil
}
func (s *Syncer) syncOne(ctx context.Context, entry Entry, result *SyncResult) error {
existing, err := s.users.GetByEmailForAuth(ctx, entry.Email)
isNew := errors.Is(err, user.ErrNotFound)
if err != nil && !isNew {
return err
}
var userID string
if isNew {
created, err := s.users.Create(ctx, entry.Email, entry.Name)
if err != nil {
return err
}
userID = created.ID
result.Created++
} else {
userID = existing.User.ID
result.Updated++
}
// Deaktivierung in LDAP wird uebernommen (Akzeptanzkriterium 3 / Pruefung 3).
if entry.Disabled {
if _, err := s.users.Deactivate(ctx, userID); err != nil {
return err
}
result.Deactivated++
}
role, ok, err := s.mappings.HighestRoleFor(ctx, entry.Groups)
if err != nil {
return err
}
if ok {
if _, err := s.roles.Assign(ctx, userID, role, "ldap-sync"); err != nil {
return err
}
}
return nil
}
+78
View File
@@ -0,0 +1,78 @@
// Package oidc implementiert Core IAM-06: SSO-Anmeldung ueber OpenID
// Connect gegen externe Identity-Provider.
//
// WICHTIGER HINWEIS: In dieser Umgebung ist kein registrierter externer
// OIDC-Provider (Client-ID/Secret bei Google/Okta/Azure AD o.ae.) verfuegbar,
// gegen den ein echter Authorization-Code-Redirect-Flow end-to-end
// durchgefuehrt werden koennte (analog AUD-05/Archive, IAM-05/LDAP). ANDERS
// als bei LDAP/Archive ist die eigentliche Token-VALIDIERUNG (JWKS-
// Signaturpruefung, Ablauf, Claims, Nonce/State) aber ein rein
// kryptographischer Vorgang ohne Netzwerkabhaengigkeit zur Testzeit — die
// Tests unten erzeugen ein eigenes RSA-Schluesselpaar, signieren ein
// ID-Token selbst und pruefen es exakt so, wie es ein echter Provider
// liefern wuerde. Nur der Redirect zu einem echten externen Provider bleibt
// ungeprueft.
package oidc
import (
"crypto/rsa"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
)
type jwk struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
}
type jwkSet struct {
Keys []jwk `json:"keys"`
}
var ErrUnsupportedKeyType = errors.New("oidc: nicht unterstuetzter jwk-schluesseltyp")
// ParseJWKS dekodiert eine JWKS-Antwort (RFC 7517) in kid -> RSA-Public-Key.
// Nur RSA-Schluessel (kty=RSA, Standard bei allen gaengigen OIDC-Providern
// fuer ID-Tokens) werden unterstuetzt.
func ParseJWKS(data []byte) (map[string]*rsa.PublicKey, error) {
var set jwkSet
if err := json.Unmarshal(data, &set); err != nil {
return nil, fmt.Errorf("jwks parsen: %w", err)
}
out := make(map[string]*rsa.PublicKey, len(set.Keys))
for _, k := range set.Keys {
if k.Kty != "RSA" {
continue
}
pub, err := decodeRSAKey(k)
if err != nil {
return nil, fmt.Errorf("schluessel %q dekodieren: %w", k.Kid, err)
}
out[k.Kid] = pub
}
return out, nil
}
func decodeRSAKey(k jwk) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
if err != nil {
return nil, fmt.Errorf("modulus dekodieren: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
if err != nil {
return nil, fmt.Errorf("exponent dekodieren: %w", err)
}
eBuf := make([]byte, 8)
copy(eBuf[8-len(eBytes):], eBytes)
e := int(binary.BigEndian.Uint64(eBuf))
return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}, nil
}
+69
View File
@@ -0,0 +1,69 @@
package oidc
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// CompleteOIDCLogin verifiziert das ID-Token (Signatur, Ablauf, Aussteller,
// Nonce — Akzeptanzkriterium 3), loest den State einmalig ein
// (Akzeptanzkriterium/Pruefung 3), legt bei Bedarf einen lokalen Benutzer an
// und mapped Rollen aus den OIDC-Claims ueber DIESELBE Erlaubnis-Matrix wie
// IAM-05/LDAP (ldapsync.RoleMappingStore — kein zweiter, paralleler
// Mapping-Mechanismus, Akzeptanzkriterium 2). Bei Erfolg wird ein normales
// IAM-02-Sitzungs-Token ausgestellt.
func CompleteOIDCLogin(
ctx context.Context,
verifier *Verifier,
states *StateStore,
users *user.TenantUserStore,
roles *rbac.Store,
roleMapping *ldapsync.RoleMappingStore,
issuer *auth.TokenIssuer,
tenantSlug, state, idToken string,
) (token string, err error) {
nonce, err := states.Consume(ctx, state)
if err != nil {
return "", err
}
claims, err := verifier.VerifyWithNonce(idToken, nonce)
if err != nil {
return "", err
}
if claims.Email == "" {
return "", fmt.Errorf("%w: id-token ohne e-mail-claim", ErrInvalidToken)
}
u, err := findOrCreateUser(ctx, users, claims.Email)
if err != nil {
return "", err
}
if role, ok, err := roleMapping.HighestRoleFor(ctx, claims.Roles); err != nil {
return "", err
} else if ok {
if _, err := roles.Assign(ctx, u.ID, role, "oidc-sso"); err != nil {
return "", err
}
}
return issuer.Issue(u.ID, tenantSlug)
}
func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err == nil {
return creds.User, nil
}
if !errors.Is(err, user.ErrNotFound) {
return user.User{}, err
}
return users.Create(ctx, email, email)
}
+172
View File
@@ -0,0 +1,172 @@
package oidc
import (
"context"
"crypto/rsa"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/golang-jwt/jwt/v5"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupLoginTest(t *testing.T) (*pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
);
CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return pool, cleanup
}
// Akzeptanzkriterium 1 + 3: End-to-End-Login gegen ein (selbst signiertes,
// aber protokollkorrektes) ID-Token — Signatur, Nonce, State, Rollen-Mapping,
// Sitzungs-Token-Ausstellung.
func TestCompleteOIDCLogin_EndToEnd(t *testing.T) {
pool, cleanup := setupLoginTest(t)
defer cleanup()
ctx := context.Background()
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
states := NewStateStore(pool)
users := user.NewTenantUserStore(pool)
roles := rbac.NewStore(pool)
roleMapping := ldapsync.NewRoleMappingStore(pool)
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
if err := roleMapping.SetMapping(ctx, "sso-admins", rbac.RoleTenantAdmin); err != nil {
t.Fatalf("set mapping: %v", err)
}
state, nonce, err := states.Generate(ctx)
if err != nil {
t.Fatalf("generate state: %v", err)
}
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
claims := Claims{
Subject: "idp-subject-123",
Email: email,
Nonce: nonce,
Roles: []string{"sso-admins"},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
idToken := signTestToken(t, key, "kid-1", claims)
token, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken)
if err != nil {
t.Fatalf("complete oidc login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres sitzungs-token")
}
assignment, err := roles.Get(ctx, mustGetUserID(t, ctx, users, email))
if err != nil {
t.Fatalf("rollenzuweisung lesen: %v", err)
}
if assignment.Role != rbac.RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin aus sso-admins-mapping, habe %q", assignment.Role)
}
// Replay desselben state schlaegt fehl.
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err == nil {
t.Fatal("erwartet fehler bei wiederverwendetem state (replay)")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: keine Privilege-Escalation ueber
// manipulierte/unbekannte Rollen-Claims.
func TestCompleteOIDCLogin_UnmappedRoleGrantsNothing(t *testing.T) {
pool, cleanup := setupLoginTest(t)
defer cleanup()
ctx := context.Background()
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
states := NewStateStore(pool)
users := user.NewTenantUserStore(pool)
roles := rbac.NewStore(pool)
roleMapping := ldapsync.NewRoleMappingStore(pool)
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
state, nonce, err := states.Generate(ctx)
if err != nil {
t.Fatalf("generate state: %v", err)
}
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
claims := Claims{
Email: email,
Nonce: nonce,
// Versuch, sich ueber einen manipulierten Rollen-Claim
// hochzustufen — diese Gruppen wurden NIE gemappt.
Roles: []string{"tenant_admin", "superadmin"},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
idToken := signTestToken(t, key, "kid-1", claims)
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err != nil {
t.Fatalf("complete oidc login: %v", err)
}
userID := mustGetUserID(t, ctx, users, email)
if _, err := roles.Get(ctx, userID); err == nil {
t.Fatal("erwartet KEINE rollenzuweisung fuer nicht gemappte claims (privilege-escalation-versuch)")
}
}
func mustGetUserID(t *testing.T, ctx context.Context, users *user.TenantUserStore, email string) string {
t.Helper()
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("get user by email: %v", err)
}
return creds.User.ID
}
+76
View File
@@ -0,0 +1,76 @@
package oidc
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrStateInvalid = errors.New("oidc: state ungueltig, bereits verwendet oder abgelaufen")
// DefaultStateTTL begrenzt, wie lange ein ausgestellter State/Nonce fuer den
// Redirect-Umweg zum Identity-Provider gueltig bleibt.
const DefaultStateTTL = 10 * time.Minute
type StateStore struct {
pool *pgxpool.Pool
}
func NewStateStore(pool *pgxpool.Pool) *StateStore {
return &StateStore{pool: pool}
}
// Generate stellt state+nonce fuer einen neuen Login-Redirect aus. state
// wird als OAuth2-"state"-Parameter mitgeschickt (CSRF-Schutz), nonce
// erscheint spaeter im ID-Token und muss exakt uebereinstimmen (Replay-Schutz).
func (s *StateStore) Generate(ctx context.Context) (state, nonce string, err error) {
state, err = randomValue()
if err != nil {
return "", "", err
}
nonce, err = randomValue()
if err != nil {
return "", "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO oidc_states (state, nonce, expires_at) VALUES ($1, $2, $3)
`, state, nonce, time.Now().Add(DefaultStateTTL))
if err != nil {
return "", "", fmt.Errorf("state speichern: %w", err)
}
return state, nonce, nil
}
// Consume loest einen State EINMALIG ein (Akzeptanzkriterium/Pruefung 3:
// Replay-Schutz) — atomar ueber WHERE used_at IS NULL, analog IAM-03/IAM-09.
// Ein zweiter Callback mit demselben state (z.B. durch einen Angreifer, der
// die Redirect-URL abgefangen hat) schlaegt fehl.
func (s *StateStore) Consume(ctx context.Context, state string) (nonce string, err error) {
err = s.pool.QueryRow(ctx, `
UPDATE oidc_states SET used_at = now()
WHERE state = $1 AND used_at IS NULL AND expires_at > now()
RETURNING nonce
`, state).Scan(&nonce)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrStateInvalid
}
return "", fmt.Errorf("state einloesen: %w", err)
}
return nonce, nil
}
func randomValue() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
+68
View File
@@ -0,0 +1,68 @@
package oidc
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStateTest(t *testing.T) (*StateStore, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewStateStore(pool), cleanup
}
// Akzeptanzkriterium/Pruefung 3: State/Nonce-Handling gegen CSRF und Replay.
func TestStateStore_ConsumeIsSingleUse(t *testing.T) {
store, cleanup := setupStateTest(t)
defer cleanup()
ctx := context.Background()
state, nonce, err := store.Generate(ctx)
if err != nil {
t.Fatalf("generate: %v", err)
}
gotNonce, err := store.Consume(ctx, state)
if err != nil {
t.Fatalf("consume (erste einloesung): %v", err)
}
if gotNonce != nonce {
t.Fatalf("nonce = %q, want %q", gotNonce, nonce)
}
if _, err := store.Consume(ctx, state); !errors.Is(err, ErrStateInvalid) {
t.Fatalf("erwartet ErrStateInvalid bei erneutem einloesen (replay), habe %v", err)
}
}
func TestStateStore_RejectsUnknownState(t *testing.T) {
store, cleanup := setupStateTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Consume(ctx, "niemals-ausgestellter-state"); !errors.Is(err, ErrStateInvalid) {
t.Fatalf("erwartet ErrStateInvalid, habe %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package oidc
import (
"crypto/rsa"
"errors"
"fmt"
"github.com/golang-jwt/jwt/v5"
)
var ErrInvalidToken = errors.New("oidc: id-token ungueltig, abgelaufen oder falscher aussteller")
// Claims sind die fuer NEXARCH relevanten OIDC-ID-Token-Claims.
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Nonce string `json:"nonce"`
Roles []string `json:"roles"` // providerabhaengiger Claim-Name, hier vereinfacht fest "roles"
jwt.RegisteredClaims
}
// Verifier prueft ID-Tokens gegen den JWKS-Schluesselsatz eines konfigurierten
// Providers (Akzeptanzkriterium 3: fehlerhafte/abgelaufene Tokens werden
// abgewiesen).
type Verifier struct {
keys map[string]*rsa.PublicKey
issuer string
}
func NewVerifier(keys map[string]*rsa.PublicKey, issuer string) *Verifier {
return &Verifier{keys: keys, issuer: issuer}
}
// Verify prueft Signatur (gegen JWKS), Ablauf (jwt-Bibliothek lehnt
// abgelaufene Tokens automatisch ab) und Aussteller.
func (v *Verifier) Verify(idToken string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(idToken, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, ErrInvalidToken
}
kid, _ := t.Header["kid"].(string)
key, ok := v.keys[kid]
if !ok {
return nil, ErrInvalidToken
}
return key, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
if claims.Issuer != v.issuer {
return nil, fmt.Errorf("%w: unerwarteter aussteller %q", ErrInvalidToken, claims.Issuer)
}
return claims, nil
}
// VerifyWithNonce prueft zusaetzlich, dass der nonce-Claim im Token exakt
// dem bei der Anfrage ausgestellten Nonce entspricht — verhindert Replay
// eines fremden, gueltigen ID-Tokens (Akzeptanzkriterium/Pruefung 3).
func (v *Verifier) VerifyWithNonce(idToken, expectedNonce string) (*Claims, error) {
claims, err := v.Verify(idToken)
if err != nil {
return nil, err
}
if claims.Nonce == "" || claims.Nonce != expectedNonce {
return nil, fmt.Errorf("%w: nonce stimmt nicht ueberein", ErrInvalidToken)
}
return claims, nil
}
+184
View File
@@ -0,0 +1,184 @@
package oidc
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/binary"
"encoding/json"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func generateTestKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
return key
}
func signTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims Claims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = kid
s, err := token.SignedString(key)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return s
}
func TestVerify_AcceptsValidToken(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
Email: "person@example.com",
Nonce: "erwarteter-nonce",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := signTestToken(t, key, "kid-1", claims)
got, err := verifier.Verify(tok)
if err != nil {
t.Fatalf("verify: %v", err)
}
if got.Email != "person@example.com" {
t.Fatalf("email = %q", got.Email)
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Token-Signatur- und Ablaufpruefung.
func TestVerify_RejectsExpiredToken(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // abgelaufen
},
}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.Verify(tok); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token")
}
}
func TestVerify_RejectsWrongSigningKey(t *testing.T) {
realKey := generateTestKey(t)
attackerKey := generateTestKey(t)
// Verifier kennt NUR den echten oeffentlichen Schluessel.
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &realKey.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
// Mit dem Angreifer-Schluessel signiert, aber unter demselben kid wie der echte.
tok := signTestToken(t, attackerKey, "kid-1", claims)
if _, err := verifier.Verify(tok); err == nil {
t.Fatal("erwartet fehler bei fremd signiertem token")
}
}
func TestVerify_RejectsWrongIssuer(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://ganz-anderer-idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.Verify(tok); err == nil {
t.Fatal("erwartet fehler bei unerwartetem aussteller")
}
}
// Akzeptanzkriterium/Pruefung 3: Nonce-Handling gegen Replay.
func TestVerifyWithNonce_RejectsMismatch(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
Nonce: "nonce-a",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.VerifyWithNonce(tok, "nonce-b"); err == nil {
t.Fatal("erwartet fehler bei nonce-mismatch (moeglicher replay eines fremden tokens)")
}
if _, err := verifier.VerifyWithNonce(tok, "nonce-a"); err != nil {
t.Fatalf("erwartet erfolg bei passendem nonce: %v", err)
}
}
func TestParseJWKS_RoundTrip(t *testing.T) {
key := generateTestKey(t)
nBytes := key.PublicKey.N.Bytes()
eBuf := make([]byte, 8)
binary.BigEndian.PutUint64(eBuf, uint64(key.PublicKey.E))
// Fuehrende Nullbytes des Exponenten abschneiden (ueblich bei JWK-Kodierung).
eStart := 0
for eStart < len(eBuf)-1 && eBuf[eStart] == 0 {
eStart++
}
set := jwkSet{Keys: []jwk{{
Kid: "kid-1",
Kty: "RSA",
N: base64.RawURLEncoding.EncodeToString(nBytes),
E: base64.RawURLEncoding.EncodeToString(eBuf[eStart:]),
}}}
data, err := json.Marshal(set)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := ParseJWKS(data)
if err != nil {
t.Fatalf("parse jwks: %v", err)
}
got, ok := parsed["kid-1"]
if !ok {
t.Fatal("erwartet kid-1 im ergebnis")
}
if got.E != key.PublicKey.E || got.N.Cmp(key.PublicKey.N) != 0 {
t.Fatalf("erwartet identischen oeffentlichen schluessel nach roundtrip")
}
// Der wiederhergestellte Schluessel muss ein damit signiertes Token auch tatsaechlich verifizieren.
verifier := NewVerifier(parsed, "https://idp.example.com")
claims := Claims{Subject: "x", RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
}}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.Verify(tok); err != nil {
t.Fatalf("verify mit aus jwks wiederhergestelltem schluessel: %v", err)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package rbac implementiert Core RBAC-01: das Rollenmodell mit
// Grundrollen/-rechten, Hierarchie und Zuweisung. Die eigentliche
// Durchsetzung (erlaubt/verboten je Request) ist RBAC-02, dieses Paket
// liefert nur Modell + Zuweisung (Casbin-Prinzip: Policy-Modell getrennt
// von der Entscheidungsfunktion).
package rbac
// Role ist eine der drei Grundrollen. Superadmin lebt mandantenuebergreifend
// (siehe internal/user.SuperadminStore aus IAM-01) und wird deshalb NICHT
// ueber Store (tenant-gescoped) zugewiesen — die Existenz eines Superadmin-
// Kontos IST die Rollenzuweisung.
type Role string
const (
RoleSuperadmin Role = "superadmin"
RoleTenantAdmin Role = "tenant_admin"
RoleUser Role = "user"
)
type Permission string
const (
PermSelfRead Permission = "self.read"
PermSelfUpdate Permission = "self.update"
PermManageUsers Permission = "tenant.manage_users"
PermManageSettings Permission = "tenant.manage_settings"
PermManageTenants Permission = "platform.manage_tenants"
)
// parent bildet die Rollenhierarchie ab (Akzeptanzkriterium 2): eine Rolle
// erbt alle Rechte ihrer Elternrolle. tenant_admin erbt von user,
// superadmin erbt von tenant_admin.
var parent = map[Role]Role{
RoleTenantAdmin: RoleUser,
RoleSuperadmin: RoleTenantAdmin,
}
// direct sind die einer Rolle direkt (ohne Vererbung) zugeordneten Rechte.
var direct = map[Role][]Permission{
RoleUser: {PermSelfRead, PermSelfUpdate},
RoleTenantAdmin: {PermManageUsers, PermManageSettings},
RoleSuperadmin: {PermManageTenants},
}
// EffectivePermissions liefert die vollstaendige, ueber die Hierarchie
// aufgeloeste Rechtemenge einer Rolle (Akzeptanzkriterium 2 / Pruefung 2).
func EffectivePermissions(role Role) []Permission {
seen := make(map[Permission]bool)
var out []Permission
for r, ok := role, true; ok; r, ok = parent[r] {
for _, p := range direct[r] {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
}
return out
}
// HasPermission prueft, ob eine Rolle (inklusive geerbter Rechte) ein
// bestimmtes Recht besitzt.
func HasPermission(role Role, perm Permission) bool {
for _, p := range EffectivePermissions(role) {
if p == perm {
return true
}
}
return false
}
+124
View File
@@ -0,0 +1,124 @@
package rbac
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
// ErrRoleNotAssignableInTenantScope wird geliefert, wenn versucht wird,
// eine mandantenuebergreifende Rolle (superadmin) ueber den tenant-
// gescopten Store zu vergeben — die erlaubte Matrix laesst hier nur
// user/tenant_admin zu (Akzeptanzkriterium 1 / Pruefung 1).
ErrRoleNotAssignableInTenantScope = errors.New("rbac: rolle ist in diesem geltungsbereich nicht zuweisbar")
ErrNotFound = errors.New("rbac: keine rollenzuweisung gefunden")
)
// assignableRoles ist die erlaubte Matrix fuer Store (tenant-gescoped).
var assignableRoles = map[Role]bool{
RoleUser: true,
RoleTenantAdmin: true,
}
type Assignment struct {
UserID string
Role Role
GrantedBy string
}
// Store verwaltet Rollenzuweisungen innerhalb GENAU EINER Tenant-Datenbank —
// analog zu internal/user.TenantUserStore (Modell C: der Pool bestimmt den
// Tenant, keine tenant_id-Spalte noetig).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Assign vergibt eine Rolle an einen Benutzer. grantedBy identifiziert den
// Akteur, der die Zuweisung vorgenommen hat (Akzeptanzkriterium 3). Jede
// Zuweisung wird zusaetzlich in role_assignment_history festgehalten, auch
// wenn sie eine vorherige Rolle ersetzt.
func (s *Store) Assign(ctx context.Context, userID string, role Role, grantedBy string) (Assignment, error) {
if !assignableRoles[role] {
return Assignment{}, ErrRoleNotAssignableInTenantScope
}
if grantedBy == "" {
return Assignment{}, errors.New("rbac: grantedBy darf nicht leer sein")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Assignment{}, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignments (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id) DO UPDATE SET role = $2, granted_by = $3, granted_at = now()
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("rolle zuweisen: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignment_history (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("historie schreiben: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Assignment{}, fmt.Errorf("transaktion committen: %w", err)
}
return Assignment{UserID: userID, Role: role, GrantedBy: grantedBy}, nil
}
func (s *Store) Get(ctx context.Context, userID string) (Assignment, error) {
var a Assignment
var role string
a.UserID = userID
if err := s.pool.QueryRow(ctx, `
SELECT role, granted_by FROM role_assignments WHERE user_id = $1
`, userID).Scan(&role, &a.GrantedBy); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Assignment{}, ErrNotFound
}
return Assignment{}, fmt.Errorf("rollenzuweisung lesen: %w", err)
}
a.Role = Role(role)
return a, nil
}
// History liefert jede Rollenzuweisung eines Benutzers in chronologischer
// Reihenfolge — die Grundlage fuer "wer hat wann welche Rolle vergeben"
// (Akzeptanzkriterium 3).
func (s *Store) History(ctx context.Context, userID string) ([]Assignment, error) {
rows, err := s.pool.Query(ctx, `
SELECT role, granted_by FROM role_assignment_history
WHERE user_id = $1 ORDER BY granted_at
`, userID)
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
var out []Assignment
for rows.Next() {
var role string
a := Assignment{UserID: userID}
if err := rows.Scan(&role, &a.GrantedBy); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
a.Role = Role(role)
out = append(out, a)
}
return out, rows.Err()
}
+45
View File
@@ -0,0 +1,45 @@
package tenant
import (
"encoding/json"
"net/http"
)
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
// (REST-API-Grundgerüst & Versionierung) und TEN-02 (Self-Service-Onboarding).
// Auth/Rate-Limiting/Versionierung selbst sind ausdruecklich nicht Teil von
// TEN-01 und werden dort nachgezogen.
type Handler struct {
provisioner *Provisioner
}
func NewHandler(p *Provisioner) *Handler {
return &Handler{provisioner: p}
}
type createTenantRequest struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
var req createTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
t, err := h.provisioner.Provision(r.Context(), req.Slug, req.Name)
if err != nil {
if err == ErrInvalidSlug {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, "tenant konnte nicht angelegt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(t)
}
+78
View File
@@ -0,0 +1,78 @@
package tenant
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
// Infrastrukturebene komplett getrennt (Akzeptanzkriterium 3).
type Provisioner struct {
// adminPool ist mit der Wartungsdatenbank (z. B. "postgres") verbunden
// und wird ausschliesslich fuer CREATE/DROP DATABASE verwendet, da diese
// Befehle in PostgreSQL nicht in einer Transaktion laufen koennen.
adminPool *pgxpool.Pool
registry *Registry
// dsnTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen, z. B. "postgresql://user:pass@host:5432/%s?sslmode=disable".
dsnTemplate string
}
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner {
return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate}
}
// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die
// Registrierung fehl, wird die bereits angelegte Datenbank wieder entfernt,
// damit kein verwaister, unregistrierter Tenant zurueckbleibt.
func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant, error) {
if err := ValidateSlug(slug); err != nil {
return Tenant{}, err
}
dbName := dbNameForSlug(slug)
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
}
t := Tenant{
Slug: slug,
Name: name,
DBName: dbName,
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
Status: StatusActive,
}
tx, err := p.registry.pool.Begin(ctx)
if err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err)
}
created, err := p.registry.insertTx(ctx, tx, t)
if err != nil {
_ = tx.Rollback(ctx)
p.rollbackDatabase(ctx, dbName)
return Tenant{}, err
}
if err := tx.Commit(ctx); err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err)
}
return created, nil
}
// rollbackDatabase entfernt eine bereits angelegte Tenant-Datenbank, wenn die
// Registrierung fehlschlug, damit Provisioning insgesamt atomar wirkt.
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
}
+105
View File
@@ -0,0 +1,105 @@
package tenant
import (
"context"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// Integrationstest fuer Akzeptanzkriterien 2 und 3. Benoetigt eine echte
// Postgres-Instanz und wird ohne TEST_ADMIN_DSN uebersprungen, nicht als
// fehlgeschlagen gewertet — siehe Pruefungen-Ergebnis im PR.
//
// TEST_ADMIN_DSN muss auf die Wartungsdatenbank zeigen, z. B.:
//
// postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable
func TestProvision_CreatesIsolatedDatabases(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
defer registryPool.Close()
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
registry := NewRegistry(registryPool)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
t.Cleanup(func() {
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_acme`)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_globex`)
})
tenantA, err := provisioner.Provision(ctx, "acme", "Acme GmbH")
if err != nil {
t.Fatalf("provision acme: %v", err)
}
tenantB, err := provisioner.Provision(ctx, "globex", "Globex AG")
if err != nil {
t.Fatalf("provision globex: %v", err)
}
if tenantA.DBName == tenantB.DBName {
t.Fatalf("erwartet unterschiedliche db_name, beide sind %q", tenantA.DBName)
}
// Akzeptanzkriterium 3 / Pruefung 3: In der Datenbank von Tenant A existiert
// keine Verbindungsmoeglichkeit zu Tenant B, weil beide physisch getrennte
// Datenbanken sind, statt sich auf einen Query-Filter zu verlassen.
poolA, err := pgxpool.New(ctx, tenantA.DBDSN)
if err != nil {
t.Fatalf("connect tenant a: %v", err)
}
defer poolA.Close()
var globexVisible bool
err = poolA.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1
)
`, tenantB.DBName).Scan(&globexVisible)
if err != nil {
t.Fatalf("pruefung tenant-trennung: %v", err)
}
// pg_database ist clusterweit sichtbar (Existenz der DB), aber die
// eigentliche Pruefung ist: aus poolA (verbunden mit tenant_acme) ist keine
// Tabelle/Zeile aus tenant_globex erreichbar, da current_database() getrennt ist.
var currentDB string
if err := poolA.QueryRow(ctx, `SELECT current_database()`).Scan(&currentDB); err != nil {
t.Fatalf("current_database: %v", err)
}
if currentDB != tenantA.DBName {
t.Fatalf("current_database() = %q, want %q — keine physische Trennung", currentDB, tenantA.DBName)
}
if currentDB == tenantB.DBName {
t.Fatalf("tenant a verbindung zeigt auf tenant b datenbank")
}
}
+69
View File
@@ -0,0 +1,69 @@
package tenant
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Registry kapselt den Zugriff auf die Control-Plane-Registry-Datenbank.
// Sie enthaelt ausschliesslich Tenant-Metadaten (Akzeptanzkriterium 1) —
// niemals Geschaeftsdaten eines Mandanten.
type Registry struct {
pool *pgxpool.Pool
}
func NewRegistry(pool *pgxpool.Pool) *Registry {
return &Registry{pool: pool}
}
// insertTx schreibt den Tenant-Datensatz innerhalb einer laufenden Transaktion,
// damit Provisioner.Provision DB-Anlage und Registrierung atomar behandeln kann.
func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) {
row := tx.QueryRow(ctx, `
INSERT INTO tenants (slug, name, db_name, db_dsn, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at
`, t.Slug, t.Name, t.DBName, t.DBDSN, t.Status)
if err := row.Scan(&t.ID, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant registrieren: %w", err)
}
return t, nil
}
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
var t Tenant
row := r.pool.QueryRow(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants WHERE slug = $1
`, slug)
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
}
return t, nil
}
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("tenants auflisten: %w", err)
}
defer rows.Close()
var out []Tenant
for rows.Next() {
var t Tenant
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("tenant lesen: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
+42
View File
@@ -0,0 +1,42 @@
// Package tenant implements Core TEN-01: die Control-Plane-Registry und die
// Provisioning-Routine fuer physisch getrennte Mandanten-Datenbanken (Modell C).
package tenant
import (
"errors"
"regexp"
"time"
)
type Status string
const (
StatusActive Status = "active"
)
type Tenant struct {
ID string
Slug string
Name string
DBName string
DBDSN string
Status Status
CreatedAt time.Time
}
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
// der Datenbankname niemals aus unkontrolliertem Nutzereingabe-Text gebaut wird.
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,48}$`)
var ErrInvalidSlug = errors.New("tenant: slug muss mit Kleinbuchstaben beginnen und darf nur [a-z0-9_] enthalten (2-49 Zeichen)")
func ValidateSlug(slug string) error {
if !slugPattern.MatchString(slug) {
return ErrInvalidSlug
}
return nil
}
func dbNameForSlug(slug string) string {
return "tenant_" + slug
}
+35
View File
@@ -0,0 +1,35 @@
package tenant
import "testing"
func TestValidateSlug(t *testing.T) {
cases := []struct {
slug string
wantErr bool
}{
{"acme", false},
{"acme_gmbh", false},
{"a1", false},
{"", true},
{"a", true},
{"1acme", true},
{"Acme", true},
{"acme-gmbh", true},
{"acme;drop table tenants", true},
}
for _, c := range cases {
err := ValidateSlug(c.slug)
if (err != nil) != c.wantErr {
t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", c.slug, err, c.wantErr)
}
}
}
func TestDBNameForSlug(t *testing.T) {
got := dbNameForSlug("acme")
want := "tenant_acme"
if got != want {
t.Errorf("dbNameForSlug() = %q, want %q", got, want)
}
}
+66
View File
@@ -0,0 +1,66 @@
package user
import (
"encoding/json"
"errors"
"net/http"
)
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
// dieser Kachel und daher hier noch nicht angebunden.
type Handler struct {
users *TenantUserStore
superadmins *SuperadminStore
}
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
return &Handler{users: users, superadmins: superadmins}
}
type createUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
u, err := h.users.Create(r.Context(), req.Email, req.Name)
writeUserResult(w, u, err)
}
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
writeUserResult(w, u, err)
}
func writeUserResult(w http.ResponseWriter, u User, err error) {
if err != nil {
switch {
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
default:
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(u)
}
+173
View File
@@ -0,0 +1,173 @@
package user
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
// fuer dasselbe Muster.
func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
const superadminsSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE superadmins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
func TestTenantUserStore_CRUD(t *testing.T) {
pool := setupTestDB(t, "test_iam01_users", usersSchema)
store := NewTenantUserStore(pool)
ctx := context.Background()
created, err := store.Create(ctx, "alice@example.com", "Alice")
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Status != StatusActive {
t.Fatalf("erwartet status active, hat %q", created.Status)
}
got, err := store.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "alice@example.com" {
t.Fatalf("get email = %q", got.Email)
}
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
t.Fatalf("update ergebnis unerwartet: %+v", updated)
}
list, err := store.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 1 {
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
}
deactivated, err := store.Deactivate(ctx, created.ID)
if err != nil {
t.Fatalf("deactivate: %v", err)
}
if deactivated.Status != StatusInactive {
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
}
// Negativfall: doppelte E-Mail-Adresse.
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
t.Fatalf("create second: %v", err)
}
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
}
// Negativfall: fehlender Benutzer.
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
store := NewSuperadminStore(pool)
ctx := context.Background()
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
if err != nil {
t.Fatalf("create superadmin: %v", err)
}
if created.Status != StatusActive {
t.Fatalf("erwartet status active, hat %q", created.Status)
}
got, err := store.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "root@nexarch.internal" {
t.Fatalf("get email = %q", got.Email)
}
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
}
deactivated, err := store.Deactivate(ctx, created.ID)
if err != nil {
t.Fatalf("deactivate: %v", err)
}
if deactivated.Status != StatusInactive {
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
}
}
+77
View File
@@ -0,0 +1,77 @@
package user
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
type SuperadminStore struct {
pool *pgxpool.Pool
}
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
return &SuperadminStore{pool: pool}
}
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
var u User
u.Email, u.Name, u.Status = email, name, StatusActive
row := s.pool.QueryRow(ctx, `
INSERT INTO superadmins (email, name, status)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`, u.Email, u.Name, u.Status)
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM superadmins WHERE id = $1
`, id))
}
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM superadmins ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("superadmins auflisten: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("superadmin lesen: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
UPDATE superadmins SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, StatusInactive))
}
+173
View File
@@ -0,0 +1,173 @@
package user
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
type TenantUserStore struct {
pool *pgxpool.Pool
}
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
return &TenantUserStore{pool: pool}
}
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
var u User
u.Email, u.Name, u.Status = email, name, StatusActive
row := s.pool.QueryRow(ctx, `
INSERT INTO users (email, name, status)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`, u.Email, u.Name, u.Status)
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM users WHERE id = $1
`, id))
}
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM users ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("benutzer auflisten: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("benutzer lesen: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
// Feld unveraendert.
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
if email != "" {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
}
row := s.pool.QueryRow(ctx, `
UPDATE users
SET email = COALESCE(NULLIF($2, ''), email),
name = COALESCE(NULLIF($3, ''), name),
updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, email, name)
u, err := scanUser(row)
if err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
UPDATE users SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, StatusInactive))
}
// SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe
// internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort.
func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1
`, id, hash)
if err != nil {
return fmt.Errorf("passwort setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth)
// verwendet und traegt bewusst den password_hash, damit er nicht ueber den
// regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann.
type AuthCredentials struct {
User User
PasswordHash string
}
// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse
// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit
// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich
// ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe
// IAM-02 "Bekannte Fehler vermeiden").
func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) {
var c AuthCredentials
row := s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at, password_hash
FROM users WHERE email = $1
`, email)
if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status,
&c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AuthCredentials{}, ErrNotFound
}
return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err)
}
return c, nil
}
func scanUser(row pgx.Row) (User, error) {
var u User
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
return User{}, fmt.Errorf("benutzer lesen: %w", err)
}
return u, nil
}
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
func mapWriteErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrEmailTaken
}
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("benutzer schreiben: %w", err)
}
+42
View File
@@ -0,0 +1,42 @@
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
// dagegen mandantenuebergreifend in der Registry und sind ueber
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
// tenant_id-NULL-Sonderfall in User.
package user
import (
"errors"
"regexp"
"time"
)
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
)
type User struct {
ID string
Email string
Name string
Status Status
CreatedAt time.Time
UpdatedAt time.Time
}
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
var ErrNotFound = errors.New("user: nicht gefunden")
func ValidateEmail(email string) error {
if !emailPattern.MatchString(email) {
return ErrInvalidEmail
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package user
import "testing"
func TestValidateEmail(t *testing.T) {
cases := []struct {
email string
wantErr bool
}{
{"a@b.de", false},
{"a.b+c@sub.example.com", false},
{"", true},
{"keine-email", true},
{"a@b", true},
{"@b.de", true},
}
for _, c := range cases {
err := ValidateEmail(c.email)
if (err != nil) != c.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
}
}
}
-39
View File
@@ -1,39 +0,0 @@
// Command pflichttestgate ist das CI-Gate aus docs/TESTSTRATEGIE-MAIL.md
// Abschnitt 4. Aufruf: pflichttestgate < geänderte-dateien.txt
package main
import (
"bufio"
"fmt"
"os"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/pflichttestgate"
)
func main() {
var changedFiles []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
changedFiles = append(changedFiles, line)
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "pflichttestgate: eingabe konnte nicht gelesen werden: %v\n", err)
os.Exit(2)
}
violations := pflichttestgate.CheckDiff(changedFiles)
if len(violations) == 0 {
fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.")
return
}
fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:")
for _, v := range violations {
fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile)
}
fmt.Fprintln(os.Stderr, "\nSiehe docs/TESTSTRATEGIE-MAIL.md Abschnitt 4.")
os.Exit(1)
}
-54
View File
@@ -1,54 +0,0 @@
# ARC-01 Prüfprotokoll: Objekt-Speicher-Anbindung für Mails/Anhänge
Voraussetzung ING-04 bereits Fertig. ARC-01 ist der Startpunkt der
Foundation-Kette (analog DMS FDN-03), nicht nur eine Ergänzung — es
entsperrt ARC-02 bis ARC-10 sowie mehrere Ingestion-Tickets.
## Umsetzung
Bewährtes Muster aus DMS FDN-03 (LocalDriver/S3Driver-Abstraktion)
übernommen — bewusste Neuimplementierung statt Cross-Modul-Import
(Mail ist eigenständiges Go-Modul, kann DMS' `internal/` nicht
importieren):
- `mail/internal/storage.Driver``Put`/`Get`/`Delete`, zwei
Implementierungen (`LocalDriver`, `S3Driver`).
- `ObjectKey(messageID, partIndex)` — festes, dokumentiertes
Pfadschema `messages/<id>/parts/<n>` (Akzeptanzkriterium 1).
Lesezugriff hängt NUR von `messageID`+`partIndex` ab, nicht vom
ursprünglichen Importpfad (Akzeptanzkriterium 3).
- **Erweiterung gegenüber FDN-03** — Prüfsummenverifikation AN DIESER
SCHICHT (Akzeptanzkriterium 2, von ARC-01 explizit gefordert, anders
als FDN-03): `Service.Put` schreibt Inhalt + SHA-256-Sidecar-Objekt,
liest SOFORT zurück und verifiziert — ein fehlgeschlagener
Rücklese-Vergleich lässt `Put` selbst fehlschlagen, keine unbemerkt
fehlerhafte Ablage. `Service.GetVerified` wiederholt die Prüfung bei
jedem späteren Lesezugriff.
- `HTTPUsageReporter` — identisches Muster wie DMS FDN-03, meldet über
Core API-11 (`resync-api`, `internal/resync.Handler.UsageHandler`,
Service-Credential wie API-02) an LIC-05 (Akzeptanzkriterium 4).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: geschriebenes Objekt liefert beim Lesen byteidentischen Inhalt | **bestanden** `TestPut_ReadBackIsByteIdentical`: `GetVerified` liefert exakt den geschriebenen Inhalt |
| 2 | Test: absichtlich beschädigtes Objekt wird bei Prüfsummenvergleich erkannt | **bestanden** `TestGetVerified_DetectsTamperedObject`: Objekt direkt am Dateisystem manipuliert (umgeht `Service` vollständig), `GetVerified` liefert real `ErrChecksumMismatch` |
| 3 | Lasttest mit vielen kleinen Objekten bestätigt akzeptable Latenz | **bestanden** `TestPut_ManySmallObjectsAcceptableLatency`: 500 reale `Put`-Aufrufe (inkl. Schreiben+Sidecar+Rücklese-Verifikation) in 52,9 ms — **105,8 µs/Objekt**, weit unter der 10-ms-Grenze |
| 4 | Melde-Aufruf an Core LIC-05 bei Schreib- und Löschvorgang nachweislich ausgelöst, mit korrekter Größenangabe | **bestanden** `TestPut_ReportsUsageOnWriteAndDelete` (Fake-Reporter, exakte Delta-Werte); ZUSÄTZLICH real auf 131 gegen den laufenden `nexarch-resync-api.service` (API-11) bewiesen: echtes Service-Credential provisioniert, `Put``GetVerified``Delete` komplett durchlaufen, `usage_counters` zeigt reales Delta `+29` dann `-29` (Nettosumme 0 — beide Meldungen real angewendet, nicht nur eine) |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -> alle Mail-Pakete bestanden (storage, mimeparse, example, pflichttestgate)
```
## Gesamtergebnis
**Bestanden.** Alle vier Akzeptanzkriterien und alle vier
Pflichtprüfungen real erfüllt, inklusive eines echten End-zu-Ende-Laufs
gegen den live laufenden Core-API-11-Dienst (nicht nur einen Fake).
Entsperrt ARC-02ARC-10 sowie mehrere Ingestion-Tickets.
-71
View File
@@ -1,71 +0,0 @@
# ARC-02 Prüfprotokoll: Verschlüsselung at rest
Voraussetzung ARC-01 (Mail, Fertig), Core API-10 (Fertig) + API-12
(neu angelegt und fertig — API-10 war nicht als Dienst erreichbar,
siehe API-12-Prüfprotokoll).
## Umsetzung
Bewährtes Muster aus DMS FDN-09 übernommen (bewusste
Neuimplementierung, Mail kann DMS nicht importieren):
- `mail/internal/crypto``GenerateDEK`/`WrapDEK`/`UnwrapDEK`
(AES-256-GCM), `HTTPKEKProvider` (bezieht den Tenant-KEK über Core
API-12, `X-Nexarch-Client-Id/Secret`), `Service.Seal`/`Open`
(Envelope-Verfahren, KEK wird bei JEDEM Aufruf frisch bezogen, nie
zwischengespeichert).
- `mail/internal/encstorage` — verbindet ARC-01 (`storage.Service`) mit
ARC-02 (`crypto.Service`) OHNE eines der beiden Pakete zu ändern
(`git diff --stat mail/internal/storage/` bleibt leer): `Put`
verschlüsselt VOR dem Schreiben, legt Chiffretext + verpackten DEK
als zwei Objekte über `storage.Service` ab (Prüfsumme,
Nutzungsmeldung — ARC-01 unverändert mitgenutzt).
- **Reihenfolge beachtet** (Ticket "Bekannte Fehler vermeiden"):
`encstorage.Put` nimmt bereits fertigen Klartext entgegen — die
SHA-256-Dublettenerkennung (ARC-03) muss VOM AUFRUFER auf dem
Klartext berechnet werden, BEVOR er an `Put` übergeben wird; dieses
Paket verschlüsselt sofort und hält den Klartext nicht länger als
nötig im Speicher.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Zugriff auf Rohspeicher ohne Schlüssel liefert keine lesbaren Inhalte | **bestanden** `TestPut_RawStorageAccessWithoutKeyYieldsNoReadableContent`: Objekt über `encstorage.Put` geschrieben, DANACH die Datei DIREKT am Dateisystem gelesen (umgeht Service/Entschlüsselung vollständig) — Klartext UND erkennbare Fragmente sind real NICHT im Rohspeicher auffindbar |
| 2 | Test: falscher Mandantenschlüssel verweigert Entschlüsselung | **bestanden** `TestGetDecrypted_WrongTenantKeyDeniesDecryption`: korrekter Tenant entschlüsselt erfolgreich, ein ANDERER Tenant-Slug (anderer KEK) liefert real `ErrDecryptFailed` (GCM-Auth-Tag-Prüfung schlägt fehl); ZUSÄTZLICH real gegen den laufenden `kek-api` (API-12) bewiesen: nicht-existenter Tenant wird bereits beim KEK-Bezug abgelehnt (404), Entschlüsselung damit strukturell unmöglich |
| 3 | Performance-Test bestätigt akzeptablen Overhead durch Verschlüsselung | **bestanden** `TestPut_AcceptableEncryptionOverhead`: 50 Objekte à 64 KiB (realistische Anhanggröße) in 45,5 ms — **910 µs/Objekt** (inkl. AES-256-GCM, Prüfsumme, Sidecar-Schreiben, Rücklese-Verifikation aus ARC-01), weit unter der 50-ms-Grenze |
## Echter End-zu-Ende-Beweis auf 192.168.1.131
Vollständiger Roundtrip gegen den ECHT laufenden `nexarch-kek-api.service`
(API-12, kein Fake): echtes Modul registriert+provisioniert, echter
Tenant + Tenant-KEK real angelegt, `encstorage.Put``GetDecrypted`
über HTTP gegen API-12 — Inhalt kommt byteidentisch zurück. Zusätzlich:
Entschlüsselungsversuch mit nicht-existentem Tenant-Slug real
abgelehnt (Core liefert 404, kein KEK verfügbar). Testdaten
anschließend entfernt.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -> alle Mail-Pakete bestanden (encstorage, crypto indirekt getestet, storage, mimeparse, example, pflichttestgate)
```
**Hinweis (offener Punkt, ehrlich vermerkt):** `mail/internal/crypto`
selbst hat keine eigenen `_test.go`-Dateien — es wird vollständig
indirekt über `mail/internal/encstorage`s Tests abgedeckt. Zusätzlich:
`mail/internal/pflichttestgate`s Pfadmuster (`docs/TESTSTRATEGIE-MAIL.md`)
erfassen `internal/crypto/`/`internal/encstorage/` NICHT explizit als
"Compliance-kritisch" (nur `internal/arc/`) — sollte in einem
Folgeticket nachgezogen werden, da Verschlüsselungscode mindestens so
kritisch ist wie die dort bereits gelisteten Bereiche.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt, inklusive eines vollständigen
End-zu-Ende-Laufs gegen den live laufenden Core-API-12-Dienst.
Entsperrt ARC-08 (Schlüsselrotation).
-53
View File
@@ -1,53 +0,0 @@
# ARC-03 Prüfprotokoll: Dublettenerkennung E-Mail
Voraussetzung ARC-01 (Mail, Fertig).
## Umsetzung
- `mail/internal/dedup/hash.go``HashAndBuffer(plaintext io.Reader)`:
SHA-256-Inhalts-Hash, gebildet auf dem KLARTEXT (Bekannter Fehler
vermeiden: muss VOR mail/internal/crypto passieren, siehe ARC-02 —
ein Hash auf dem Chiffretext wäre wegen des zufälligen DEK je Objekt
bei jedem Import anders). Liefert zusätzlich einen erneut lesbaren
Reader zurück, da der Original-Reader beim Hashen verbraucht wird.
- `mail/internal/dedup/store.go``Store.Register(ctx, contentHash, objectKey)`:
Postgres-Tabelle `mail_content_hashes`, Primärschlüssel
`(tenant_slug, content_hash)``tenant_slug` fest im Store gebunden
(`NewStore(pool, tenantSlug)`, gleiches Muster wie
`storage.Service`/`encstorage.Service`), nicht nur Konvention.
`ON CONFLICT DO NOTHING` + Rücklese entscheidet, ob der gefundene
Eintrag der gerade übergebene ist (kein Duplikat) oder ein älterer
(Duplikat, Original-`object_key` wird zurückgegeben statt erneut
gespeichert — Akzeptanzkriterium 2).
- Kein Umbau: `mail/internal/storage`/`mail/internal/crypto`/
`mail/internal/encstorage` unverändert (`git diff --stat` bleibt für
alle drei leer). `dedup` kennt keines der drei Pakete — der Aufrufer
(spätere Ingest-Tickets) ruft `HashAndBuffer` VOR `encstorage.Put`
auf.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: dieselbe Nachricht aus zwei Quellen wird als Duplikat erkannt | **bestanden** `TestRegister_SameMessageFromTwoSourcesIsDuplicate`: gleicher Hash, zwei verschiedene `object_key` ("quelle-1/objekt", "quelle-2/objekt") — zweite Registrierung liefert real `isDuplicate=true` und referenziert das Original `quelle-1/objekt` |
| 2 | Test: zwei Mandanten mit identischem Mailinhalt werden nicht fälschlich verknüpft | **bestanden** `TestRegister_SameContentTwoTenantsNotLinked`: zwei `Store`-Instanzen mit unterschiedlichem `tenantSlug`, IDENTISCHER Hash — beide Registrierungen liefern real `isDuplicate=false`, keine Verknüpfung über die Mandantengrenze |
| 3 | Test mit knapp unterschiedlichen Nachrichten bestätigt korrekte Nicht-Erkennung | **bestanden** `TestHashAndBuffer_SlightlyDifferentContentDifferentHash`: zwei Nachrichten, die sich nur im letzten Zeichen unterscheiden (`.` vs `,`) — real unterschiedlicher SHA-256-Hash |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/dedup (5 Tests)
```
Testdaten (`mail_content_hashes`, Zeilen mit `tenant_slug` beginnend
`mandant-arc03-`) werden von den Tests selbst über `t.Cleanup`
entfernt.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt. Entsperrt SRC-01, SRC-02, SRC-07.
-87
View File
@@ -1,87 +0,0 @@
# ARC-08 Prüfprotokoll: Verschlüsselungsschlüssel-Rotation
Voraussetzung ARC-02 (Fertig).
## Architektur-Ausgangslage (real geprüft)
Core (API-10, `internal/kek.Store.RotateTenantKEK`, bereits Fertig)
ersetzt den Tenant-KEK bei Rotation durch einen komplett NEUEN Wert und
hält KEINE Historie vor — der laufende `kek-api`-Dienst (192.168.1.131,
Port 8102) exponiert ausschließlich `TenantKEKHandler`, der immer nur den
AKTUELLEN KEK liefert (real im Quelltext von
`/root/nexarch-code/internal/kek/handler.go` und `cmd/kek-api/main.go`
auf 131 verifiziert). Damit Mail nach einer Core-seitigen Rotation
Altbestand weiterhin lesen kann, MUSS Mail selbst jeden bezogenen
Tenant-KEK versioniert zwischenspeichern — das ist der Kern dieser
Kachel.
## Umsetzung
- `mail/internal/crypto/kekversions.go``KEKVersionStore`: persistiert
jede vom Core bezogene Tenant-KEK-Version lokal, verschlüsselt mit
einem eigenen, ausschließlich über Umgebungsvariable bezogenen
Wrap-Schlüssel (kein Klartext-KEK in der Datenbank). `RecordIfNew`
erkennt Rotation (neuer KEK-Wert ≠ letzter bekannter) und legt nur dann
eine neue Version an (Akzeptanzkriterium 1). `Revoke` sperrt gezielt
eine einzelne Version (Pflichtprüfung 2).
- `mail/internal/crypto/service.go``Service.WithVersionStore`
(optional, Rückwärtskompatibilität: ohne Aufruf verhält sich `Service`
exakt wie vor ARC-08). `Seal` zeichnet bei aktivierter Versionierung
die verwendete KEK-Version im `Envelope` auf. Neue Methode
`OpenAtVersion` entpackt mit der historischen statt der aktuellen
Tenant-KEK-Version (Akzeptanzkriterium 3) — `Open` bleibt unverändert
für Rückwärtskompatibilität.
- `mail/internal/encstorage/encstorage.go` — neuer Sidecar
`<key>.dek.version` (gleiches Muster wie der bestehende `.dek`-Sidecar
aus ARC-02) speichert die KEK-Version je Objekt. `GetDecrypted` nutzt
jetzt `OpenAtVersion` statt `Open`; fehlt der Sidecar (vor ARC-08
geschriebene Objekte), wird Version 0 angenommen (identisches
Verhalten wie vorher).
- Kein Umbau: `mail/internal/storage`/`mail/internal/dedup`/
`mail/internal/indexworker`/`mail/internal/search` unverändert;
bestehende ARC-02-Tests (`encstorage_test.go`) unverändert lauffähig
ohne Codeänderung an ihnen.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Rotation des Hauptschlüssels lässt Altbestand weiterhin lesbar | **bestanden** `TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation`: Objekt vor Rotation versiegelt (Version 1), Tenant-Hauptschlüssel real rotiert (Provider liefert ab dann einen anderen Wert, exakt wie `RotateTenantKEK` es bei Core bewirkt), neues Objekt nach Rotation versiegelt (Version 2), Altbestand über `OpenAtVersion` real weiterhin korrekt entschlüsselt — zusätzlich real bestätigt, dass der naive `Open()` mit dem neuen aktuellen KEK für das alte Objekt fehlschlägt (beweist, dass `OpenAtVersion` tatsächlich etwas leistet) |
| 2 | Test: kompromittierter alter Schlüssel kann gezielt gesperrt werden | **bestanden** `TestRotation_CompromisedOldKeyCanBeRevoked`: Version gesperrt, `OpenAtVersion` liefert danach real `ErrKEKVersionRevoked`; `TestKEKVersionStore_RevokeBlocksOnlyThatVersion` bestätigt zusätzlich, dass eine ANDERE Version davon unberührt bleibt |
| 3 | Dokumentierter Rotationsvorgang wurde einmal vollständig durchgespielt | **bestanden** siehe Abschnitt "Rotationsvorgang" unten, real durchlaufen als `TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation` |
### Rotationsvorgang (Pflichtprüfung 3, vollständig durchgespielt)
1. Objekt A wird mit Tenant-KEK-Version 1 versiegelt (`Seal`, Envelope
trägt `KEKVersion=1`, `KEKVersionStore` legt Version 1 real an).
2. Core rotiert den Tenant-Hauptschlüssel (in diesem Test durch den
`KEKProvider` simuliert, exakt am selben Punkt, an dem `Service` mit
dem echten `HTTPKEKProvider`/Core API-12 interagieren würde).
3. Objekt B wird versiegelt — automatisch mit der NEUEN Version 2, ohne
dass Objekt A angefasst wird (Akzeptanzkriterium 2: kein
Neuverschlüsseln des Bestands).
4. Objekt A wird über `OpenAtVersion(..., kekVersion=1, ...)` gelesen —
real erfolgreich, Klartext identisch zum Original.
5. Ein naiver Lesezugriff über `Open()` (aktueller KEK) auf Objekt A
schlägt real fehl — zeigt, dass ohne Versionsverfolgung der
Altbestand nach Rotation unlesbar geworden wäre.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/crypto (4 Tests, neu)
und internal/encstorage (4 Tests, unverändert weiterhin grün — Rückwärtskompatibilität
real bestätigt)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Trägt (gemeinsam mit SRC-02, SRC-04, SRC-05, SRC-09) zu
QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch SRC-08 und SRC-10
fertig sind.
-65
View File
@@ -1,65 +0,0 @@
# ING-04 Prüfprotokoll: MIME- & Anhang-Parsing
Keine Vorbedingungen (Wave 1, sofort startbar). ING-04 ist die
Voraussetzung für ARC-01 (Objekt-Speicher) — nicht nur eine
Ergänzung, sondern der direkte Blocker (`ARC-01.dependsOn = ["ING-04"]`).
## Bekannten Fehler vermieden
archivmail (`known-issues-archivmail.md` Punkt 3): Anhänge wurden über
`io.ReadAll` ohne Größenlimit gelesen — Speicherbombe durch große/
böswillige Anhänge. Hier läuft JEDER Anhang-Lesevorgang über
`io.LimitReader(r, maxSize+1)` — eine Überschreitung führt zu
`ErrAttachmentTooLarge`, nicht zu stillem Abschneiden oder
unbegrenztem Speicherwachstum.
## Umsetzung
- `mail/internal/mimeparse.Parse` — zerlegt eine MIME-Nachricht
vollständig, rekursiv über verschachtelte `multipart/*`-Container.
- Zeichensatz-Reparatur: `mime.WordDecoder` mit eigenem
`CharsetReader` (via `golang.org/x/text/encoding/htmlindex`) — ein
unbekannter/kaputter Zeichensatz reicht den Rohtext unverändert
durch statt abzubrechen.
- Content-Transfer-Encoding: `quoted-printable`/`base64` werden
dekodiert, unbekannte Encodings unverändert durchgereicht (defensiv).
- **Nur Parsing, keine Speicherung** — Objekt-Speicher ist explizit
ARC-01s Aufgabe (Ticket-"Nicht Bestandteil"), dieses Paket schreibt
nirgends in einen Objektspeicher.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test mit sehr großem simuliertem Anhang bestätigt harte Ablehnung statt Speicheranstieg | **bestanden** `TestParse_OversizedAttachmentRejectedNotMemoryExhausted`: ein UNBEGRENZTER `io.Reader` (liefert endlos Bytes) als Anhang-Body — `Parse` bricht real mit `ErrAttachmentTooLarge` ab, statt (wie ein `io.ReadAll`-basierter Parser) den Prozess durch unbegrenztes Speicherwachstum zum Absturz zu bringen. Test läuft in Millisekunden durch, kein Speicheranstieg |
| 2 | Testkorpus mit realitätsnahen Multipart-/Encoding-Varianten läuft fehlerfrei durch | **bestanden** `TestParse_RealisticCorpusRunsCleanly`: 4 realitätsnahe Varianten (einfacher Text, quoted-printable, multipart/alternative, leere Multipart-Hülle mit Präambel/Epilog) laufen alle fehlerfrei durch |
| 3 | Fuzz-/Grenzwerttest mit kaputten MIME-Strukturen bricht kontrolliert ab, kein Absturz | **bestanden** `FuzzParse`: ECHTES Go-Fuzzing (`go test -fuzz=FuzzParse -fuzztime=45s`), **728.164 reale Testläufe** mit mutierten/kaputten Byte-Sequenzen, 146 "interessante" (coverage-erweiternde) Eingaben gefunden, KEIN einziger Absturz (jeder `panic` hätte den Test sofort fehlschlagen lassen) |
**Zusätzliche Tests (je Akzeptanzkriterium mindestens ein Test):**
- `TestParse_NestedMultipartFullyDecomposed` (AC1: verschachtelte
Multipart-Teile vollständig zerlegt — `multipart/mixed` enthält
`multipart/alternative` UND einen Anhang, alle 3 Blatt-Teile
gefunden).
- `TestParse_AttachmentMetadataExtracted` (AC2: Dateiname,
Content-Type, Größe korrekt extrahiert).
- `TestParse_BrokenCharsetIsRepairedNotAborted`,
`TestParse_ISO88591FilenameDecoded` (AC3: kaputter/unbekannter
Zeichensatz repariert statt Abbruch; RFC-2047-kodierter,
ISO-8859-1-Dateiname real korrekt zu "Rechnung Ü" dekodiert).
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -> alle Mail-Pakete bestanden (inkl. mimeparse, example, pflichttestgate)
go test ./internal/mimeparse/... -fuzz=FuzzParse -fuzztime=45s -> PASS, 728.164 Ausführungen, 0 Abstürze
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt, inklusive eines echten, nicht nur
simulierten Fuzz-Laufs mit über 700.000 Testfällen. Entsperrt ARC-01
(Objekt-Speicher-Anbindung), IMP-02, ING-10, ARC-10.
-60
View File
@@ -1,60 +0,0 @@
# SRC-01 Prüfprotokoll: Manticore-Suchindex für Mails
Voraussetzung ARC-01, ARC-03 (beide Fertig).
## Umsetzung
- `mail/internal/search/fields.go` — statische Feld-Whitelist
(`FieldTenantSlug`, `FieldMessageID`, `FieldSubject`, `FieldBody`,
`FieldAttachmentText`, `FieldSentAt`) und `IndexName`. Bekannten Fehler
vermeiden (known-issues-archivmail.md #11/#12): archivmail baute
WHERE-Klauseln und teils Spalten-/Tabellennamen dynamisch über
`fmt.Sprintf`/`strings.Join`. Dieses Paket bezieht Feld-/Tabellennamen
ausschließlich aus den Konstanten dieser Datei.
- `mail/internal/search/migrations/0001_mail_documents.sql` — statisches,
versioniertes Schema (`go:embed`), einzige Quelle für `EnsureSchema`.
- `mail/internal/search/client.go``Client`:
- `EnsureSchema` legt den Index über den Manticore `/sql?mode=raw`-
Endpunkt an, ausschließlich mit dem statisch eingebetteten
Migrationstext (kein String-Zusammenbau).
- `Index`/`Search` laufen über die strukturierte Manticore-HTTP-JSON-API
(`/replace`, `/search`) — Werte (auch Tenant-Slug und Suchtext) landen
ausschließlich als JSON-Feldwerte, niemals als interpolierter
Feld-/Tabellenname.
- `Search` filtert zwingend über `FieldTenantSlug` (Akzeptanzkriterium 3).
- Kein Umbau: `mail/internal/storage`/`mail/internal/crypto`/
`mail/internal/encstorage`/`mail/internal/dedup` unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Codereview bestätigt: keine Sprintf/Join-basierte SQL-Klauselbildung im Index-Zugriff | **bestanden** `TestNoDynamicSQLClauseBuilding`: automatisierter Quelltext-Scan von `client.go` bestätigt, dass kein `fmt.Sprintf` verwendet wird und in der Nähe des `/sql?mode=raw`-Aufrufs kein `+`-String-Zusammenbau steht; die einzige SQL-Anfrage nutzt ausschließlich den statisch eingebetteten Migrationstext |
| 2 | Test: Abfrage mit manipulierten Eingabewerten verändert keine Spalten-/Tabellennamen | **bestanden** `TestSearch_MaliciousInputDoesNotAlterFieldNames`: `tenantSlug`/`queryText` mit SQL-Injection-artigen Zeichen (`acme"; DROP TABLE mail_documents; --`, `x' OR '1'='1`) übergeben, per `httptest.Server` das tatsächlich gesendete JSON-Payload abgefangen und geprüft — Feldnamen (`tenant_slug`, `subject,body,attachment_text`) bleiben unverändert statisch, die böswilligen Eingaben erscheinen unverändert nur als Werte |
| 3 | Funktionstest bestätigt: Volltextsuche liefert erwartete Treffer für Testkorpus | **bestanden** `TestSearch_FindsExpectedDocument`: zwei reale Dokumente gegen echtes Manticore auf 192.168.1.131 indexiert, Suche nach "Quartalsbericht" liefert genau das erwartete Dokument, nicht das themenfremde |
Zusätzlich (Akzeptanzkriterium 3, mandantengetrennt): `TestSearch_TenantIsolation`
— identischer Suchbegriff bei Mandant A indexiert, Suche bei Mandant B liefert
keinen Treffer aus Mandant A.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/search (4 Tests)
```
Manticore lief bereits produktiv auf 192.168.1.131 (Port 9308, Version 7.4.1,
Dienst `manticore.service` aktiv seit 2026-08-28). Testdaten
(`tenant_slug` beginnend `mandant-src01-`) sind reine RT-Index-Einträge,
keine Bereinigung über den Testlauf hinaus nötig (Testhost, freie
Nutzung erlaubt).
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt SRC-02, SRC-03, SRC-09.
-58
View File
@@ -1,58 +0,0 @@
# SRC-02 Prüfprotokoll: Indexierungs-Worker & Synchronisierung
Voraussetzung SRC-01, ARC-03 (beide Fertig).
## Umsetzung
- `mail/internal/indexworker/migrations/0001_mail_index_jobs.sql`
statisches, versioniertes Schema (`go:embed`) für `mail_index_jobs`
(`job_type` index/delete, `status` pending/processing/succeeded/failed,
`attempts`/`max_attempts`, `available_at`, `locked_at`/`locked_by`).
- `mail/internal/indexworker/queue.go``Queue`: `EnqueueIndex`/
`EnqueueDelete`, `dequeue` (Postgres `FOR UPDATE SKIP LOCKED` +
Stale-Lock-Wiedervorlage, gleiche Konvention wie
`dms/internal/jobqueue` aus FDN-04 — bewusst schlanker, keine DLQ, da
nicht Bestandteil der Akzeptanzkriterien dieser Kachel), `complete`/
`fail` (arithmetischer Backoff, kein String-Concat für Intervalle),
`Status` (Akzeptanzkriterium 3 als Go-API).
- `mail/internal/indexworker/worker.go``Worker.RunOnce`: holt einen
Job, ruft je nach `job_type` `search.Client.Index`/`search.Client.Delete`
auf, markiert abschließend `complete`/`fail`.
- `mail/internal/search`: minimale Erweiterung um `Client.Delete` und
`DocumentID(tenantSlug, messageID)` (deterministische FNV-1a-ID, damit
Index und Delete für dieselbe Mail immer dasselbe Dokument referenzieren,
ohne zusätzlichen Zustand im Worker).
- Kein Umbau: `mail/internal/storage`/`mail/internal/crypto`/
`mail/internal/encstorage`/`mail/internal/dedup` unverändert;
bestehende `search`-Tests/-Verhalten (SRC-01) unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Worker-Neustart mitten im Lauf verliert keinen offenen Auftrag | **bestanden** `TestDequeue_WorkerCrashMidRunLosesNoJob`: Job wird geholt und NICHT abgeschlossen (simulierter Absturz), vor Ablauf der Stale-Lock-Frist real kein zweiter Job verfügbar, nach Ablauf real erneut derselbe Job an einen zweiten Worker zugestellt |
| 2 | Test: Löschung einer Mail entfernt sie zuverlässig aus Suchtreffern | **bestanden** `TestDeleteJob_RemovesMailFromSearchResults`: Mail indexiert und Auffindbarkeit real bestätigt, danach Lösch-Job verarbeitet, anschließende Suche liefert real keinen Treffer mehr |
| 3 | Konsistenztest vergleicht Datenbankbestand mit Indexbestand stichprobenartig | **bestanden** `TestConsistency_DatabaseAndIndexMatchOnSample`: 3 Index-Jobs verarbeitet, je Stichprobe real geprüft, dass der DB-Job-Status `succeeded` UND das zugehörige Dokument tatsächlich im Manticore-Index auffindbar sind |
Zusätzlich (Akzeptanzkriterium 1, Funktionsnachweis): `TestIndexJob_MakesMailSearchable`
— eingereihte Indexierungsaufgabe macht die Mail nach Worker-Verarbeitung
real durchsuchbar.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/indexworker (5 Tests)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. SRC-02 ist der nächste Schritt in der Suche-Foundation-Kette
(Manticore-Schema → Schreib-/Suchzugriff → asynchrone Synchronisierung),
nicht nur eine nette Ergänzung — ohne ihn bliebe SRC-01 ein Index ohne
Befüllungspfad. Entsperrt QA-03.
-61
View File
@@ -1,61 +0,0 @@
# SRC-03 Prüfprotokoll: Such-API mit Ranking
Voraussetzung SRC-01 (Fertig).
## Umsetzung
- `mail/internal/search/client.go``Search` intern auf Manticores
`query_string`-Klausel umgestellt (statt `match`): unterstützt
Grundoperatoren nativ (Phrase in Anführungszeichen, Ausschluss mit `-`,
Akzeptanzkriterium 3). Der Wert landet unmittelbar als JSON-String,
keine dynamischen Feldnamen möglich (sogar strikter als das vorherige
`match`-Muster mit kommagetrenntem Feld-Schlüssel).
- `fieldWeights` (statische Konstanten: `subject`=10, `body`=3,
`attachment_text`=1) über die Manticore-Option `field_weights` — Ranking
berücksichtigt Relevanz UND Anhangstreffer (Akzeptanzkriterium 1).
Manticore liefert Treffer standardmäßig absteigend nach BM25-Score
sortiert zurück; `Result.Score` macht das Ranking nachvollziehbar.
- `Result` um `Score` und `SentAtUnixEpoch` erweitert (Datum als weiterer
Rankingfaktor gemäß Ticketbeschreibung verfügbar).
- Tenant-Trennung (Akzeptanzkriterium 2) unverändert über das strukturierte
`equals`-Feld aus SRC-01.
- Bestehenden SRC-01-Test `TestSearch_MaliciousInputDoesNotAlterFieldNames`
an die neue `query_string`-Struktur angepasst (gleiche Funktion
weiterentwickelt, kein Umbau angrenzender Bereiche).
- Kein Umbau: `mail/internal/dedup`/`mail/internal/indexworker`/
`mail/internal/storage`/`mail/internal/crypto`/`mail/internal/encstorage`
unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Suche eines Mandanten liefert keine Treffer eines anderen Mandanten | **bestanden** `TestSearch_TenantIsolation` (SRC-01, weiterhin gültig gegen die neue Search-Implementierung) |
| 2 | Test: Phrasensuche und Ausschlussoperator liefern erwartete Teilmengen | **bestanden** `TestSearch_PhraseAndExclusionOperators`: `"dritten Quartal"` liefert real genau die beiden Dokumente mit dieser Phrase, `Umsatz -Verlust` schließt real das "Verlust"-Dokument aus |
| 3 | Performance-Test mit großem Testkorpus bleibt innerhalb Zielzeit | **bestanden** `TestSearch_PerformanceWithLargeCorpus`: 1000 reale Dokumente indexiert, Suche nach eindeutigem Begriff in 775,8µs (Ziel 500ms) gegen echtes Manticore auf 192.168.1.131 |
Zusätzlich (Akzeptanzkriterium 1, Ranking-Nachvollziehbarkeit):
`TestSearch_RankingReflectsFieldWeightAndIsTraceable` — ein Treffer im
Betreff liegt real vor einem gleichlautenden Treffer nur im Anhangstext,
mit real höherem Score.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/search (7 Tests,
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. SRC-03 ist der nächste Schritt in der Suche-Foundation-Kette
(Index → Befüllung → abfragbare Such-API mit belastbarem Ranking), nicht
nur eine nette Ergänzung — ohne ihn bliebe der Index nur intern befüllt,
ohne nutzbare Relevanzsortierung und Suchoperatoren. Entsperrt INT-01,
SRC-04, SRC-05, SRC-08.
-69
View File
@@ -1,69 +0,0 @@
# SRC-04 Prüfprotokoll: Such-Oberfläche mit Hervorhebung
Voraussetzung SRC-03 (Fertig), SHL-01 (Core-Board, Fertig).
## Umsetzung
- `web/mail-search`: eigenständige Next.js/React/TypeScript-App (kein
Backend-Annex), auf `web/shl` (SHL-01) aufbauend — gleiche Konvention
wie `web/retention-admin` (RET-06).
- `app/api/search/route.ts`: Backend-for-Frontend-Route, spricht direkt
mit derselben Manticore-Instanz wie `mail/internal/search` (SRC-01/
SRC-03). Bewusst KEINE Kopie der vollständigen Go-Suchlogik — nur der
für Trefferliste + Snippet-Hervorhebung nötige minimale Ausschnitt
("Bereite höchstens die Schnittstelle dafür vor"; die allgemeine
REST-API v1 für Mail-Zugriff ist INT-01, nicht Bestandteil dieser
Kachel). Statische Feld-/Indexnamen, kein Sprintf/Join-Klauselbau
(gleiche Konvention wie `fields.go`). Fordert Manticore-Highlights mit
eigenen Markern (`⦃⦃`/`⦄⦄`) statt HTML an.
- `lib/highlight.ts`: `splitHighlighted` zerlegt den markierten Snippet-
Text in reine Textsegmente — die Komponente rendert sie als Textknoten,
**kein** `dangerouslySetInnerHTML`, damit Mailinhalte (nicht
vertrauenswürdig) niemals als HTML interpretiert werden können.
- `app/page.tsx`: Sucheingabe (`@nexarch/shl` `TextField`), Live-
Trefferliste mit `<mark>`-Hervorhebung, verständlicher Hinweis bei
leerem Ergebnis, Link je Treffer zur Mail-Detailseite.
- `app/mail/[messageId]/page.tsx`: öffnet mit Anker `#fundstelle` und
hervorgehobenem Snippet aus den Suchtreffer-Daten. Vollständiger
Mail-Inhaltsabruf per messageId existiert noch nicht (keine HTTP-API
dafür, folgt mit INT-01) — bis dahin trägt der Link Betreff-/Text-
Snippet als Kontext mit, damit die Fundstelle bereits jetzt real
anspring- und hervorhebbar ist.
- `lib/contrast.ts`/`lib/highlightColors.ts`: reale WCAG-2.1-
Kontrastberechnung statt behaupteter Werte.
- Kein Umbau: `mail/internal/*`, `web/shl`, `web/retention-admin`
unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Manueller Test mit typischen Suchanfragen bestätigt korrekte Hervorhebung | **bestanden** echter `next build` + `next start` auf 192.168.1.131 gegen die live laufende Manticore-Instanz: `GET /api/search?tenant=src04-manual&q=Umsatz` liefert real `"subjectSnippet":"Quartalsbericht ⦃⦃Umsatz⦄⦄"` — Marker um exakt den Suchbegriff. Zusätzlich automatisiert in `app/page.test.tsx` (Marker im DOM nach Suche) |
| 2 | Barrierefreiheits-Kontrastprüfung der Hervorhebung | **bestanden** `lib/highlightColors.test.ts`: echte WCAG-2.1-Berechnung, Hell-Modus 14,29:1, Dunkel-Modus 6,43:1 (beide ≥ 4.5:1 AA-Grenzwert für Fließtext) |
| 3 | Test mit Sonderzeichen in der Suchanfrage bricht die Anzeige nicht | **bestanden** real gegen den laufenden Server getestet: Anfrage mit `"dritten Quartal" -Verlust <script>` liefert `200 OK` mit `{"hits":[]}`, kein Absturz. Zusätzlich automatisiert `lib/highlight.test.ts` (Skript-Tags/Unicode/unvollständige Marker als reiner Text) und `app/page.test.tsx` (kein `<script>`-Element im DOM, da kein `dangerouslySetInnerHTML`) |
Zusätzlich (Akzeptanzkriterium 2/3, real geprüft): `GET /mail/m-manual-1?subject=...`
liefert `200 OK`; automatisiert `app/page.test.tsx` bestätigt Link-Struktur
(`/mail/<id>?...#fundstelle`) und den "Keine Treffer"-Hinweis bei leerem
Ergebnis.
## Build/Test-Ergebnis (192.168.1.131)
```
npx tsc --noEmit -> clean
npx next build -> Compiled successfully (4 Routen)
npx vitest run -> 3 Testdateien, 12/12 bestanden
next start (real) + curl gegen Manticore live -> Hervorhebung, leeres Ergebnis,
Sonderzeichen alle real bestätigt
```
Testprozess (`next start -p 4711`) und Testdokument (`mail_documents`-ID
992001) nach Prüfung entfernt.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. SRC-04 ist der nächste Schritt in der Suche-Foundation-Kette
(Index → Befüllung → Such-API → nutzbare Oberfläche), nicht nur eine nette
Ergänzung — ohne ihn bliebe die Such-API ohne für Anwenderinnen und
Anwender erreichbaren Zugang. Entsperrt QA-03 (gemeinsam mit SRC-02).
-54
View File
@@ -1,54 +0,0 @@
# SRC-05 Prüfprotokoll: Facetten- & Filter-API
Voraussetzung SRC-03 (Fertig).
## Umsetzung
- `mail/internal/search/migrations/0002..0005_*.sql`: vier eigene,
nummerierte `ALTER TABLE ADD COLUMN`-Migrationen für die neuen
Facettenfelder (`sender`, `mailbox`, `attachment_type`, `tag`) — Manticore
erlaubt nur eine Spalte je ALTER-Anweisung. `EnsureSchema` wendet sie
idempotent nach (Fehlertext `"already in schema"` gilt als bereits
angewendet, kein Fehlerzustand).
- `fields.go`: neue statische Feldkonstanten + `FacetFields`-Whitelist
(`sender`, `mailbox`, `attachment_type`, `tag`) — einzige Quelle
zulässiger Facettendimensionen, kein beliebiger Client-Feldname möglich.
- `facets.go``Client.Facets(ctx, tenantSlug, queryText, filters)`:
nutzt Manticores strukturierte `aggs.terms`/`aggs.range`-API (kein
dynamischer SQL-Klauselbau). Tenant-Filter + optionale
`FacetFilter`-Liste laufen als zusätzliche `equals`-Klauseln in
derselben `bool.must`-Liste (Akzeptanzkriterium 2: UND-Verknüpfung).
Zeitraum-Facette über feste Buckets (letzte 7 Tage/30 Tage/Jahr/älter)
via `aggs.range` auf `sent_at`.
- `Document` um optionale Facettenfelder erweitert (`Sender`, `Mailbox`,
`AttachmentType`, `Tag`).
- Kein Umbau: `Search`/`Delete`/`Index`-Verhalten aus SRC-01/SRC-03
unverändert, `mail/internal/dedup`/`indexworker`/`storage`/`crypto`/
`encstorage` unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Facettenzahlen stimmen mit tatsächlicher Treffermenge überein | **bestanden** `TestFacets_CountsMatchActualHits`: 3 reale Dokumente indexiert, Facette `sender` liefert real `alice@example.com`→2, `bob@example.com`→1, Facette `attachment_type` liefert real `pdf`→2 |
| 2 | Test: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer | **bestanden** `TestFacets_ThreeFiltersCombineWithAND`: 4 Dokumente, von denen 3 je genau einen der drei Filter (Sender/Postfach/Anhangstyp) verletzen — nach Kombination aller drei Filter bleibt real genau 1 Treffer übrig |
| 3 | Test: Facetten eines Mandanten enthalten keine Werte eines anderen | **bestanden** `TestFacets_TenantSeparation`: identische Feldstruktur bei zwei Mandanten, Facette bei Mandant B enthält real keinen Wert von Mandant A |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -p 1 -> alle Pakete bestanden, inkl. internal/search (10 Tests,
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt SRC-06, trägt (gemeinsam mit ARC-08, SRC-02,
SRC-04, SRC-08, SRC-09, SRC-10) zu QA-03 bei — QA-03 bleibt weiterhin
blockiert, bis auch die übrigen vier Tickets fertig sind.
-72
View File
@@ -1,72 +0,0 @@
# SRC-09 Prüfprotokoll: Suchindex-Neuaufbau/Reindexierung
Voraussetzung SRC-01 (Fertig).
## Umsetzung
- `mail/internal/search/reindex.go``Reindexer.Rebuild(ctx, onProgress)`:
1. legt eine neue physische Manticore-Tabelle an (Name aus striktem
Muster `mail_documents_reindex_<Ziffern>`, per Regex validiert —
Verteidigung in der Tiefe, obwohl der Wert ausschließlich
paketintern erzeugt wird),
2. kopiert alle Dokumente aus der lebenden Tabelle seitenweise
(Cursor-Paginierung über `id`, strukturierte JSON-API, kein
dynamischer SQL-Klauselbau) — die lebende Tabelle wird dabei nur
gelesen, nie verändert (Akzeptanzkriterium 1),
3. meldet Fortschritt über einen `onProgress`-Callback
(Akzeptanzkriterium 2),
4. vergleicht Trefferzahlen alt/neu — bei Abweichung kein Umschalten,
5. schaltet erst danach per Manticore `ALTER TABLE ... RENAME`
(reine Metadaten-Operation) atomar um. Schlägt ein Schritt vor dem
Umschalten fehl, wird die Zwischentabelle entfernt, die lebende
Tabelle bleibt unverändert (Akzeptanzkriterium 3 / Pflichtprüfung 2).
- Echtes Manticore-Verhalten entdeckt und behandelt: frisch eingefügte
Dokumente einer neu angelegten RT-Tabelle sind für `match_all`-Zählungen
erst nach explizitem `FLUSH RAMCHUNK` zuverlässig sichtbar (SQL-`SELECT`
sah sie sofort, `/search`-Zählung zeigte 0) — vor der
Konsistenzprüfung eingebaut.
- Echte Plattformgrenze gefunden und abgefangen: Manticore unterstützt kein
atomares Mehrfach-`RENAME` in einer Anweisung — zwischen den zwei
nötigen Einzel-`RENAME`s existiert ein Sub-Millisekunden-Fenster ohne
`mail_documents`-Tabelle. `Client.Search` bekam dafür einen begrenzten
Retry (bis zu 2 Wiederholungen, 20ms Pause) speziell auf den
Manticore-Fehler `"unknown local table"` — real durch eine parallele
Suchlast während des Umschaltens nachgewiesen (Pflichtprüfung 1).
- Nebenbei einen echten, latenten Fehler in `Search` gefunden und behoben:
ohne explizites `limit` begrenzte Manticore Ergebnisse standardmäßig auf
20 Treffer — unbemerkt, weil bisherige Tests (SRC-01/03/05) nur auf das
Vorhandensein einzelner Treffer prüften, nie auf die Gesamtzahl. Jetzt
`searchResultLimit = 1000`.
- Kein Umbau: `Index`/`Delete`/`Facets`-Verhalten sonst unverändert,
`mail/internal/dedup`/`indexworker`/`storage`/`crypto`/`encstorage`
unverändert.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Reindex während laufender Suchanfragen unterbricht die Suche nicht | **bestanden** `TestRebuild_SearchKeepsWorkingDuringReindex`: 30 reale Dokumente indexiert, parallele Sucher-Goroutine (alle 2ms) läuft während `Rebuild` mit — 0 fehlgeschlagene Suchen über den gesamten Umschaltvorgang, danach weiterhin real alle 30 Treffer auffindbar |
| 2 | Test: abgebrochener Reindex hinterlässt keinen inkonsistenten Zustand | **bestanden** `TestRebuild_AbortedReindexLeavesNoInconsistentState`: Kontext vor `Rebuild` abgebrochen, Fehler kommt real zurück, lebende Tabelle bleibt danach unverändert (weiterhin 1 Treffer real auffindbar), keine verwaisten Zwischentabellen über `SHOW TABLES` real bestätigt |
| 3 | Stichprobenvergleich Alt-/Neuindex bestätigt gleiche Trefferzahlen | **bestanden** `TestRebuild_SampleComparisonMatchesOldAndNewIndex`: 3 unterschiedliche Suchbegriffe vor und nach Reindex real verglichen, identische Trefferzahlen je Stichprobe |
Zusätzlich (Akzeptanzkriterium 2): `TestRebuild_ReportsProgress` bestätigt
reale Fortschrittsmeldungen bis zum vollständigen Abschluss.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/search (14 Tests,
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Trägt (gemeinsam mit ARC-08, SRC-02, SRC-04, SRC-05,
SRC-08, SRC-10) zu QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch
ARC-08, SRC-08 und SRC-10 fertig sind.
-110
View File
@@ -1,110 +0,0 @@
# NEXARCH Mail Teststrategie
Stand: 2026-08-30. Ticket: QA-01. Vorbild: Core `QA-01` (`docs/TESTSTRATEGIE-CORE.md`,
Fertig) — dieselbe Struktur, für das Mail-Modul übernommen, wo sinnvoll um
protokollspezifische Aspekte (IMAP/SMTP/MIME) ergänzt.
## 1. Warum dieses Dokument existiert
archivmail (Vorgängerprojekt) testete 2 von 18 Modulen trotz hoher Kritikalität
(Compliance-/Protokoll-Logik). Kein zentrales Issue-Tracking — Bugs wurden nur als
`BUG-N`-Kommentare im Code festgehalten (`known-issues-archivmail.md`). NEXARCH Mail
übernimmt denselben Grundsatz wie Core: **Testpflicht für Auth, Tenant-Scoping und
Protokoll-/Compliance-kritische Logik ist ein Merge-Gate, keine Nachrüstung.**
## 2. Testpyramide
| Ebene | Werkzeug | Umfang |
|---|---|---|
| Unit | `go test` (Standardbibliothek) | Einzelne Funktionen/Typen, keine externe Abhängigkeit (DB, Netzwerk, IMAP/SMTP-Socket) |
| Integration | `go test` gegen echte PostgreSQL-Instanz (`nexarch_test`-Rolle) | Repository-/Handler-Schicht, Tenant-Scoping, Objekt-Speicher |
| Protokoll-Zustandsmaschinen | `go test` gegen echten IMAP-/SMTP-Client-Roundtrip (kein reiner Parser-Unit-Test) | ING-01/ING-02/ING-03: Login-Zustände, Befehlssequenzen, Fehlerpfade |
| E2E | Echter HTTP-Roundtrip (`httptest.Server`) bis zum ersten Mail-Frontend-Ticket, danach Playwright/Jest gegen die echte UI | Vollständiger Request-Response-Zyklus, kein reiner Funktionsaufruf |
| Vertragstests | Analog Core `QA-07`/DMS-Äquivalent, sobald Mail öffentliche Modul-Adapter-Schnittstellen (RET-05-Konsument, siehe `ARC-11`) hat | Wire-Contract-Stabilität |
**E2E-Zwischenlösung begründet:** Mail hat aktuell kein Frontend-Ticket (0/66 Board).
Playwright/Jest bräuchte eine echte Browser-UI zum Testen — bis zum ersten
Mail-Frontend-Ticket ist ein echter HTTP-Roundtrip (kein reiner In-Process-Funktionsaufruf)
die ehrliche, tatsächlich verfügbare Untergrenze für "E2E". Siehe Beispiel in
Abschnitt 3.
## 3. Beispieltests je Testart (Akzeptanzkriterium/Pflichtprüfung 2)
`mail/internal/example` — kein Wegwerf-Demo, sondern eine kleine, tatsächlich nützliche
Funktion (E-Mail-Adress-Normalisierung), die spätere Ticket ohnehin brauchen:
- **Unit:** `normalize_test.go``TestNormalizeAddress_*`, keine externe Abhängigkeit.
- **Integration:** `store_integration_test.go``TestAddressStore_SaveAndCheckExists`,
echte Postgres-Instanz, `TEST_TENANT_DSN`, `t.Cleanup`.
- **E2E:** `handler_e2e_test.go``TestNormalizeHandler_RealHTTPRoundTrip`, echter
`httptest.Server`-Roundtrip (TCP, nicht nur Funktionsaufruf).
Alle sechs Tests real ausgeführt (siehe Prüfungen, Abschnitt 6).
## 4. Pflichttests als Merge-Gate (Akzeptanzkriterium 3/4)
Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche ändert:
- **Auth** (`mail/internal/auth/` — sobald durch ein späteres Ticket angelegt)
- **Tenant-Scoping** (`mail/internal/tenant/`, jede Repository-Schicht mit Mandanten-Bezug)
- **Protokoll-kritisch** (`mail/internal/ingest/`, `mail/internal/imap/`,
`mail/internal/smtp/` — Zustandsmaschinen, Auth-Handshakes der Protokolle selbst)
- **Compliance-kritisch** (`mail/internal/arc/` oder gleichwertig — RET-05-Konsument,
Löschung/Archivierung, siehe `ARC-11`)
Regel (identisch zu Core `QA-01`): **jede geänderte `.go`-Datei in einem dieser
Bereiche muss von einer geänderten oder neuen `_test.go`-Datei im selben Package
begleitet sein.**
`mail/internal/pflichttestgate` implementiert das Gate (Code-Kopie des Musters aus
Core `internal/pflichttestgate`, mit mail-spezifischen Pfadmustern statt Core-Pfaden
— bewusst keine Cross-Modul-Abhängigkeit, da Mail als eigenständiges Go-Modul Core
nicht importieren kann). `.gitea/workflows/mail-pflichttest-gate.yml` führt es gegen
jeden PR-Diff aus.
Negativtest des Gates selbst (Prüfung 1 dieses Tickets):
`mail/internal/pflichttestgate/gate_test.go` simuliert einen Diff mit geänderter
`mail/internal/auth/login.go` ohne begleitende Testdatei und erwartet, dass das Gate
das als Verstoß erkennt.
## 5. Bug-Tracking (Akzeptanzkriterium 3)
**Konvention: Gitea-Issues** auf `gitea.perlbach24.de/scripte/nexarch`, Label `mail`
plus Schweregrad-Label (`bug-kritisch`/`bug-normal`/`bug-kosmetisch`). Durchsuchbar
über Gitea-Suche/Label-Filter — explizit KEIN Code-Kommentar-Tracking (`BUG-N` wie in
archivmail), das laut `known-issues-archivmail.md` genau diese Sichtbarkeitslücke
verursacht hat.
**Realer Durchspiel-Nachweis (Prüfung 3):** Diese Session (nicht Mail-spezifisch, aber
derselbe reale Vorgang) fand mehrere echte Bugs, dokumentiert nach exakt diesem
Muster in den jeweiligen `*-PRUEFPROTOKOLL.md`-Dateien statt als Code-Kommentar, z. B.
`archive/docs/RET-10-PRUEFPROTOKOLL.md`: fehlende CORS-Header bei RET-06-API,
gefunden bei einer Sichtprüfung, Symptom (Browser hätte Fetch blockiert), Ursache
(kein `Access-Control-Allow-Origin`), Fix (RET-10-Ticket), Nachweis (curl-Test vorher/
nachher) — alles durchsuchbar in der Protokolldatei, nicht im Quelltext verstreut.
**Ehrlich vermerkt:** Ein ECHTER Gitea-Issue konnte in dieser Session nicht angelegt
werden (kein Gitea-API-Token verfügbar, nur Git-SSH/HTTPS-Push-Zugriff). Das oben
verlinkte Beispiel demonstriert das Vorgehen strukturell (Symptom → Ursache → Fix →
Nachweis, durchsuchbar abgelegt), aber NICHT über die Gitea-Issue-Oberfläche selbst.
Sobald ein Gitea-Zugriffstoken verfügbar ist, sollte mindestens ein Test-Issue real
angelegt werden, um die Konvention vollständig nachzuweisen — offener Punkt, siehe
Abschnitt 7.
## 6. Prüfungen (real durchgeführt)
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Dokument liegt vor und wurde von zweiter Person gegengelesen | **bestanden** — Dokument von der Nutzerin/dem Nutzer (zweite Person) gegengelesen und freigegeben (2026-08-30) |
| 2 | Stichprobe: mindestens ein Beispieltest je benannter Testart ist umgesetzt | **bestanden** — 6 Tests real ausgeführt auf 131: `go test ./mail/internal/example/... -v -p 1`, alle grün (3 Unit, 1 Integration, 2 E2E) |
| 3 | Bug-Tracking-Vorgehen wurde einmal exemplarisch für einen realen Befund durchgespielt | **teilweise bestanden** — Vorgehen strukturell durchgespielt anhand eines realen, bereits dokumentierten Befunds (RET-10), aber NICHT über die echte Gitea-Issue-Oberfläche (kein API-Token verfügbar). Siehe Abschnitt 5, offener Punkt in Abschnitt 7 |
## 7. Offene Punkte
- Echter Gitea-Issue als Nachweis der Bug-Tracking-Konvention noch nicht angelegt
(fehlendes API-Token in dieser Session). Sollte nachgeholt werden, sobald Zugriff
besteht.
- `mail/internal/auth/`, `mail/internal/tenant/`, `mail/internal/ingest/` etc. existieren
noch nicht — die Pflichttest-Gate-Pfadmuster sind auf Basis der geplanten
Modulstruktur vordefiniert, nicht an echtem Code verifiziert. Erste Nagelprobe: das
erste Ticket, das einen dieser Pfade tatsächlich anlegt (voraussichtlich `ING-01`).
-36
View File
@@ -1,36 +0,0 @@
module gitea.perlbach24.de/scripte/nexarch/mail
go 1.24
toolchain go1.24.4
require (
github.com/aws/aws-sdk-go-v2 v1.45.1
github.com/aws/aws-sdk-go-v2/config v1.33.1
github.com/aws/aws-sdk-go-v2/credentials v1.20.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1
github.com/aws/smithy-go v1.28.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/text v0.14.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
)
-64
View File
@@ -1,64 +0,0 @@
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 h1:s67hBfG5t9rn1NCvDuB4E3QIep3UFhHPtaIqFDjV3N8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1/go.mod h1:FpvjBMXtSNMLPmDJsWwcY5cRnqJlpS2y1R6n4pvzs4k=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+XYQne9PBvRiYpHZlNJJOZfE9WNfT0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4=
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-118
View File
@@ -1,118 +0,0 @@
// Package crypto implementiert ARC-02: Envelope-Encryption für Objekte
// at rest. Jedes Objekt bekommt einen eigenen, zufälligen
// Datenverschlüsselungsschlüssel (DEK, Akzeptanzkriterium 1), der mit
// dem Tenant-Hauptschlüssel (KEK) verpackt wird — der KEK selbst kommt
// AUSSCHLIESSLICH von Core API-10/API-12 (Akzeptanzkriterium 2), wird
// hier nie persistiert, nur flüchtig für eine Wrap-/Unwrap-Operation
// gehalten. Bewusste Neuimplementierung des bewährten DMS-FDN-09-
// Musters (Mail kann DMS' internal/ nicht importieren, eigenständiges
// Go-Modul).
package crypto
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
)
const (
DEKSize = 32
KEKSize = 32
)
// ErrDecryptFailed wird geliefert, wenn ein Chiffretext nicht
// entschlüsselt werden kann — falscher Schlüssel ODER manipulierte
// Daten (Pflichtprüfung 2: GCM-Auth-Tag erkennt Manipulation
// zuverlässig, AEAD unterscheidet die beiden Ursachen bewusst nicht).
var ErrDecryptFailed = errors.New("crypto: entschlüsselung fehlgeschlagen (falscher schlüssel oder manipulierte daten)")
func GenerateDEK() ([]byte, error) {
dek := make([]byte, DEKSize)
if _, err := rand.Read(dek); err != nil {
return nil, fmt.Errorf("crypto: dek erzeugen: %w", err)
}
return dek, nil
}
func seal(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("crypto: gcm erstellen: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("crypto: nonce erzeugen: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func open(key, sealed []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("crypto: aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("crypto: gcm erstellen: %w", err)
}
if len(sealed) < gcm.NonceSize() {
return nil, ErrDecryptFailed
}
nonce, ciphertext := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, ErrDecryptFailed
}
return plaintext, nil
}
func WrapDEK(kek, dek []byte) ([]byte, error) {
wrapped, err := seal(kek, dek)
if err != nil {
return nil, fmt.Errorf("crypto: dek verpacken: %w", err)
}
return wrapped, nil
}
func UnwrapDEK(kek, wrappedDEK []byte) ([]byte, error) {
return open(kek, wrappedDEK)
}
// EncryptStream verschlüsselt den gesamten Inhalt von r mit dek
// (AES-256-GCM). Liest r vollständig in den Speicher — dasselbe Muster
// wie mail/internal/storage.S3Driver.Put (ARC-01), das S3-PutObject
// ebenfalls vollständig puffert; ein segmentiertes AEAD-Verfahren für
// sehr große Anhänge ist bewusst nicht Teil der "kleinsten Lösung".
func EncryptStream(dek []byte, r io.Reader) (io.Reader, error) {
plaintext, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("crypto: klartext lesen: %w", err)
}
ciphertext, err := seal(dek, plaintext)
if err != nil {
return nil, fmt.Errorf("crypto: verschlüsseln: %w", err)
}
return bytes.NewReader(ciphertext), nil
}
// DecryptStream entschlüsselt einen zuvor mit EncryptStream erzeugten
// Chiffretext-Stream.
func DecryptStream(dek []byte, r io.Reader) (io.Reader, error) {
ciphertext, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("crypto: chiffretext lesen: %w", err)
}
plaintext, err := open(dek, ciphertext)
if err != nil {
return nil, err
}
return bytes.NewReader(plaintext), nil
}
-80
View File
@@ -1,80 +0,0 @@
package crypto
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// KEKProvider liefert den aktuellen, entschlüsselten Tenant-KEK
// (Akzeptanzkriterium 2: KEK kommt ausschließlich von Core API-10/
// API-12). Schmale Schnittstelle, damit Tests einen Fake statt eines
// echten HTTP-Aufrufs einsetzen können.
type KEKProvider interface {
TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error)
}
// tenantKEKResponse entspricht Core internal/kek.tenantKEKResponse
// (JSON-Vertrag: tenant_kek_base64) — dieselbe Struktur, hier
// gespiegelt, da Mail Cores internal/-Pakete nicht importieren kann.
type tenantKEKResponse struct {
TenantKEKBase64 string `json:"tenant_kek_base64"`
}
// HTTPKEKProvider bezieht den Tenant-KEK über Core API-12
// (internal/kek.Handler.TenantKEKHandler, echt laufender Dienst),
// authentifiziert über dasselbe Service-Credential-Verfahren wie jeder
// andere Modul-Core-Aufruf (API-02) — identisches Muster wie DMS
// FDN-09/internal/storage.HTTPUsageReporter aus ARC-01.
type HTTPKEKProvider struct {
endpointURL string
clientID string
clientSecret string
httpClient *http.Client
}
func NewHTTPKEKProvider(endpointURL, clientID, clientSecret string, httpClient *http.Client) *HTTPKEKProvider {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &HTTPKEKProvider{endpointURL: endpointURL, clientID: clientID, clientSecret: clientSecret, httpClient: httpClient}
}
func (p *HTTPKEKProvider) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) {
u, err := url.Parse(p.endpointURL)
if err != nil {
return nil, fmt.Errorf("crypto: kek-endpunkt-url ungültig: %w", err)
}
q := u.Query()
q.Set("tenant", tenantSlug)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("crypto: kek-anfrage aufbauen: %w", err)
}
req.Header.Set("X-Nexarch-Client-Id", p.clientID)
req.Header.Set("X-Nexarch-Client-Secret", p.clientSecret)
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("crypto: kek-anfrage senden: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("crypto: kek-bezug von core abgelehnt: status %d", resp.StatusCode)
}
var body tenantKEKResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("crypto: kek-antwort dekodieren: %w", err)
}
kek, err := base64.StdEncoding.DecodeString(body.TenantKEKBase64)
if err != nil {
return nil, fmt.Errorf("crypto: kek base64-dekodieren: %w", err)
}
return kek, nil
}
-144
View File
@@ -1,144 +0,0 @@
// ARC-08: Tenant-KEK-Rotation. Core (API-10, `internal/kek.Store.
// RotateTenantKEK`) ersetzt den Tenant-KEK durch einen komplett neuen
// Wert — Core selbst hält KEINE Historie vor, `TenantKEKHandler` liefert
// immer nur den AKTUELLEN Schlüssel (siehe kekprovider.go). Damit ARC-08s
// Akzeptanzkriterium 3 ("alte Schlüsselversionen bleiben für
// Lesezugriff kontrolliert verfügbar") erfüllbar ist, muss Mail selbst
// jeden von Core bezogenen Tenant-KEK versioniert zwischenspeichern —
// KEKVersionStore übernimmt genau das, lokal mit einem eigenen,
// ausschließlich über Umgebungsvariable bezogenen Wrap-Schlüssel
// verschlüsselt (kein Klartext-KEK in der Datenbank).
package crypto
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrKEKVersionNotFound wird geliefert, wenn die angefragte Version für
// den Mandanten nicht existiert.
var ErrKEKVersionNotFound = errors.New("crypto: kek-version nicht gefunden")
// ErrKEKVersionRevoked wird geliefert, wenn die angefragte Version gezielt
// gesperrt wurde (Pflichtprüfung 2: kompromittierter alter Schlüssel kann
// gezielt gesperrt werden) — der Lesezugriff auf mit dieser Version
// verschlüsselte Altobjekte ist dann bewusst blockiert.
var ErrKEKVersionRevoked = errors.New("crypto: kek-version wurde gesperrt")
// KEKVersionStore verwaltet die Versionshistorie der Tenant-KEKs, die
// dieses Mail-Modul im Lauf der Zeit von Core bezogen hat.
type KEKVersionStore struct {
pool *pgxpool.Pool
localWrapKey []byte
}
// NewKEKVersionStore erzeugt einen Store. localWrapKey verschlüsselt die
// zwischengespeicherten Tenant-KEKs lokal at rest (KEKSize Bytes,
// ausschließlich über Umgebungsvariable bezogen — nie im Code).
func NewKEKVersionStore(pool *pgxpool.Pool, localWrapKey []byte) *KEKVersionStore {
return &KEKVersionStore{pool: pool, localWrapKey: localWrapKey}
}
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
// gleiches Muster wie mail/internal/dedup/indexworker (kein zentraler
// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden).
func (s *KEKVersionStore) EnsureSchema(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS mail_kek_versions (
tenant_slug TEXT NOT NULL,
version INT NOT NULL,
wrapped_kek BYTEA NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, version)
)
`); err != nil {
return fmt.Errorf("crypto: kek-versionsschema anlegen: %w", err)
}
return nil
}
// RecordIfNew merkt sich plainKEK als neue Version für tenantSlug, FALLS
// er sich vom zuletzt gespeicherten Wert unterscheidet (Rotation
// erkannt) — bei unverändertem KEK wird keine neue Version angelegt,
// sondern die bestehende Versionsnummer zurückgegeben (Akzeptanzkriterium
// 1: rotierbar verwaltet, nicht bei jedem Aufruf eine neue Version).
func (s *KEKVersionStore) RecordIfNew(ctx context.Context, tenantSlug string, plainKEK []byte) (version int, err error) {
var latestVersion int
var latestWrapped []byte
err = s.pool.QueryRow(ctx, `
SELECT version, wrapped_kek FROM mail_kek_versions
WHERE tenant_slug = $1 ORDER BY version DESC LIMIT 1
`, tenantSlug).Scan(&latestVersion, &latestWrapped)
switch {
case errors.Is(err, pgx.ErrNoRows):
return s.insertVersion(ctx, tenantSlug, 1, plainKEK)
case err != nil:
return 0, fmt.Errorf("crypto: letzte kek-version lesen: %w", err)
}
latestPlain, err := open(s.localWrapKey, latestWrapped)
if err != nil {
return 0, fmt.Errorf("crypto: zwischengespeicherten kek entpacken: %w", err)
}
if bytes.Equal(latestPlain, plainKEK) {
return latestVersion, nil
}
return s.insertVersion(ctx, tenantSlug, latestVersion+1, plainKEK)
}
func (s *KEKVersionStore) insertVersion(ctx context.Context, tenantSlug string, version int, plainKEK []byte) (int, error) {
wrapped, err := seal(s.localWrapKey, plainKEK)
if err != nil {
return 0, fmt.Errorf("crypto: kek für zwischenspeicherung verpacken: %w", err)
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_kek_versions (tenant_slug, version, wrapped_kek) VALUES ($1, $2, $3)
`, tenantSlug, version, wrapped); err != nil {
return 0, fmt.Errorf("crypto: kek-version speichern: %w", err)
}
return version, nil
}
// Get liefert den entschlüsselten historischen Tenant-KEK einer
// bestimmten Version. Liefert ErrKEKVersionRevoked, wenn die Version
// gezielt gesperrt wurde (Pflichtprüfung 2).
func (s *KEKVersionStore) Get(ctx context.Context, tenantSlug string, version int) ([]byte, error) {
var wrapped []byte
var revoked bool
err := s.pool.QueryRow(ctx, `
SELECT wrapped_kek, revoked FROM mail_kek_versions
WHERE tenant_slug = $1 AND version = $2
`, tenantSlug, version).Scan(&wrapped, &revoked)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrKEKVersionNotFound
}
return nil, fmt.Errorf("crypto: kek-version lesen: %w", err)
}
if revoked {
return nil, ErrKEKVersionRevoked
}
return open(s.localWrapKey, wrapped)
}
// Revoke sperrt eine Tenant-KEK-Version gezielt (Pflichtprüfung 2):
// nachfolgende Get-Aufrufe für genau diese Version schlagen mit
// ErrKEKVersionRevoked fehl, andere Versionen bleiben unberührt.
func (s *KEKVersionStore) Revoke(ctx context.Context, tenantSlug string, version int) error {
tag, err := s.pool.Exec(ctx, `
UPDATE mail_kek_versions SET revoked = true WHERE tenant_slug = $1 AND version = $2
`, tenantSlug, version)
if err != nil {
return fmt.Errorf("crypto: kek-version sperren: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrKEKVersionNotFound
}
return nil
}
-111
View File
@@ -1,111 +0,0 @@
// Integrationstest (ARC-08): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/dedup/indexworker — TEST_TENANT_DSN.
package crypto
import (
"bytes"
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
var testLocalWrapKey = bytes.Repeat([]byte{0x7a}, KEKSize)
func setupKEKVersionStore(t *testing.T, tenantSlug string) *KEKVersionStore {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
store := NewKEKVersionStore(pool, testLocalWrapKey)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_kek_versions WHERE tenant_slug = $1`, tenantSlug)
})
return store
}
func TestKEKVersionStore_RecordIfNewDetectsRotationOnly(t *testing.T) {
tenant := "mandant-arc08-recordifnew"
store := setupKEKVersionStore(t, tenant)
ctx := context.Background()
kekV1 := bytes.Repeat([]byte{0x01}, KEKSize)
v1, err := store.RecordIfNew(ctx, tenant, kekV1)
if err != nil {
t.Fatalf("erste erfassung: %v", err)
}
if v1 != 1 {
t.Fatalf("erwartete version 1, habe %d", v1)
}
// Erneuter Aufruf mit UNVERÄNDERTEM KEK darf keine neue Version anlegen.
vAgain, err := store.RecordIfNew(ctx, tenant, kekV1)
if err != nil {
t.Fatalf("zweite erfassung (unverändert): %v", err)
}
if vAgain != 1 {
t.Fatalf("erwartete weiterhin version 1 bei unverändertem kek, habe %d", vAgain)
}
kekV2 := bytes.Repeat([]byte{0x02}, KEKSize)
v2, err := store.RecordIfNew(ctx, tenant, kekV2)
if err != nil {
t.Fatalf("dritte erfassung (rotiert): %v", err)
}
if v2 != 2 {
t.Fatalf("erwartete version 2 nach rotation, habe %d", v2)
}
gotV1, err := store.Get(ctx, tenant, 1)
if err != nil {
t.Fatalf("get v1: %v", err)
}
if !bytes.Equal(gotV1, kekV1) {
t.Fatal("v1 liefert nicht den ursprünglichen kek zurück")
}
gotV2, err := store.Get(ctx, tenant, 2)
if err != nil {
t.Fatalf("get v2: %v", err)
}
if !bytes.Equal(gotV2, kekV2) {
t.Fatal("v2 liefert nicht den rotierten kek zurück")
}
}
func TestKEKVersionStore_RevokeBlocksOnlyThatVersion(t *testing.T) {
tenant := "mandant-arc08-revoke"
store := setupKEKVersionStore(t, tenant)
ctx := context.Background()
kekV1 := bytes.Repeat([]byte{0x11}, KEKSize)
kekV2 := bytes.Repeat([]byte{0x22}, KEKSize)
if _, err := store.RecordIfNew(ctx, tenant, kekV1); err != nil {
t.Fatalf("v1 erfassen: %v", err)
}
if _, err := store.RecordIfNew(ctx, tenant, kekV2); err != nil {
t.Fatalf("v2 erfassen: %v", err)
}
if err := store.Revoke(ctx, tenant, 1); err != nil {
t.Fatalf("v1 sperren: %v", err)
}
if _, err := store.Get(ctx, tenant, 1); err != ErrKEKVersionRevoked {
t.Fatalf("erwartete ErrKEKVersionRevoked für gesperrte version 1, habe: %v", err)
}
if _, err := store.Get(ctx, tenant, 2); err != nil {
t.Fatalf("version 2 sollte unberührt bleiben: %v", err)
}
}
-131
View File
@@ -1,131 +0,0 @@
// ARC-08: End-zu-Ende-Rotationstest. Nutzt einen fake KEKProvider (echter
// Testkonvention aus ARC-02, siehe encstorage_test.go) statt eines echten
// HTTP-Aufrufs an Core API-12 — Core selbst hat keine rotierbare
// Testschnittstelle über HTTP exponiert (nur der aktuelle KEK ist
// abrufbar), die Rotation wird hier auf Höhe der KEKProvider-Schnittstelle
// simuliert, exakt wie ARC-02 es für Fehlerfälle bereits tut.
package crypto
import (
"bytes"
"context"
"io"
"strings"
"sync"
"testing"
)
// rotatableKEKProvider liefert für einen Mandanten einen aktuell
// gesetzten KEK, der zur Laufzeit "rotiert" werden kann (simuliert Core
// API-10s RotateTenantKEK, dessen Effekt auf API-12 exakt darin besteht,
// dass TenantKEK ab dann einen anderen Wert liefert).
type rotatableKEKProvider struct {
mu sync.Mutex
current []byte
}
func (p *rotatableKEKProvider) TenantKEK(_ context.Context, _ string) ([]byte, error) {
p.mu.Lock()
defer p.mu.Unlock()
return p.current, nil
}
func (p *rotatableKEKProvider) rotate(newKEK []byte) {
p.mu.Lock()
defer p.mu.Unlock()
p.current = newKEK
}
// TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation ist die
// geforderte Pflichtprüfung 1: Rotation des Hauptschlüssels lässt
// Altbestand weiterhin lesbar.
func TestRotation_OldArchiveStaysReadableAfterMasterKeyRotation(t *testing.T) {
tenant := "mandant-arc08-rotation-lesbar"
store := setupKEKVersionStore(t, tenant)
ctx := context.Background()
provider := &rotatableKEKProvider{current: bytes.Repeat([]byte{0x51}, KEKSize)}
svc := NewService(provider).WithVersionStore(store)
// Objekt VOR der Rotation versiegeln.
oldEnvelope, err := svc.Seal(ctx, tenant, strings.NewReader("altbestand vor rotation"))
if err != nil {
t.Fatalf("seal (alt): %v", err)
}
if oldEnvelope.KEKVersion != 1 {
t.Fatalf("erwartete kek-version 1 vor rotation, habe %d", oldEnvelope.KEKVersion)
}
oldCiphertext, err := io.ReadAll(oldEnvelope.Ciphertext)
if err != nil {
t.Fatalf("chiffretext (alt) lesen: %v", err)
}
// Core rotiert den Tenant-Hauptschlüssel — TenantKEK liefert ab jetzt
// einen komplett anderen Wert, exakt wie internal/kek.Store.
// RotateTenantKEK es real bei Core bewirkt.
provider.rotate(bytes.Repeat([]byte{0x52}, KEKSize))
// Objekt NACH der Rotation versiegeln (Akzeptanzkriterium 2: kein
// Neuverschlüsseln des Altbestands nötig, nur neue Objekte nutzen den
// neuen Schlüssel).
newEnvelope, err := svc.Seal(ctx, tenant, strings.NewReader("neuer inhalt nach rotation"))
if err != nil {
t.Fatalf("seal (neu): %v", err)
}
if newEnvelope.KEKVersion != 2 {
t.Fatalf("erwartete kek-version 2 nach rotation, habe %d", newEnvelope.KEKVersion)
}
// Altbestand bleibt über die aufgezeichnete Version lesbar
// (Akzeptanzkriterium 3).
openedOld, err := svc.OpenAtVersion(ctx, tenant, oldEnvelope.KEKVersion, oldEnvelope.WrappedDEK, bytes.NewReader(oldCiphertext))
if err != nil {
t.Fatalf("openatversion (alt, nach rotation): %v", err)
}
plainOld, err := io.ReadAll(openedOld)
if err != nil {
t.Fatalf("altbestand lesen: %v", err)
}
if string(plainOld) != "altbestand vor rotation" {
t.Fatalf("altbestand-inhalt stimmt nicht, habe %q", string(plainOld))
}
// Der naive Open() (aktueller KEK) darf für das ALTE Objekt inzwischen
// NICHT mehr funktionieren — das beweist, dass OpenAtVersion die
// Rotation tatsächlich überbrückt, statt zufällig auch so zu klappen.
if _, err := svc.Open(ctx, tenant, oldEnvelope.WrappedDEK, bytes.NewReader(oldCiphertext)); err == nil {
t.Fatal("erwartete fehler bei Open() des altbestands mit dem NEUEN aktuellen kek, habe nil")
}
}
// TestRotation_CompromisedOldKeyCanBeRevoked ist die geforderte
// Pflichtprüfung 2: kompromittierter alter Schlüssel kann gezielt
// gesperrt werden.
func TestRotation_CompromisedOldKeyCanBeRevoked(t *testing.T) {
tenant := "mandant-arc08-revoke-e2e"
store := setupKEKVersionStore(t, tenant)
ctx := context.Background()
provider := &rotatableKEKProvider{current: bytes.Repeat([]byte{0x61}, KEKSize)}
svc := NewService(provider).WithVersionStore(store)
compromisedEnvelope, err := svc.Seal(ctx, tenant, strings.NewReader("mit kompromittiertem schlüssel versiegelt"))
if err != nil {
t.Fatalf("seal: %v", err)
}
compromisedCiphertext, err := io.ReadAll(compromisedEnvelope.Ciphertext)
if err != nil {
t.Fatalf("chiffretext lesen: %v", err)
}
provider.rotate(bytes.Repeat([]byte{0x62}, KEKSize))
if err := store.Revoke(ctx, tenant, compromisedEnvelope.KEKVersion); err != nil {
t.Fatalf("kompromittierte version sperren: %v", err)
}
_, err = svc.OpenAtVersion(ctx, tenant, compromisedEnvelope.KEKVersion, compromisedEnvelope.WrappedDEK, bytes.NewReader(compromisedCiphertext))
if err == nil {
t.Fatal("erwartete fehler beim lesen mit gesperrter kek-version, habe nil")
}
}
-114
View File
@@ -1,114 +0,0 @@
package crypto
import (
"context"
"fmt"
"io"
)
// Envelope ist das Ergebnis einer Seal-Operation: der Chiffretext-
// Stream plus der mit dem Tenant-KEK verpackte DEK, der zusammen mit
// dem Objekt persistiert werden muss (siehe mail/internal/encstorage).
// KEKVersion identifiziert (ARC-08), MIT welcher Tenant-KEK-Version der
// DEK verpackt wurde — 0, solange kein KEKVersionStore konfiguriert ist
// (Rückwärtskompatibilität, siehe WithVersionStore).
type Envelope struct {
Ciphertext io.Reader
WrappedDEK []byte
KEKVersion int
}
// Service verbindet KEKProvider mit den Envelope-Operationen — Aufrufer
// (mail/internal/encstorage) rufen ausschließlich Service auf, nie die
// Einzelfunktionen aus envelope.go direkt.
type Service struct {
kek KEKProvider
versions *KEKVersionStore
}
func NewService(kek KEKProvider) *Service {
return &Service{kek: kek}
}
// WithVersionStore aktiviert die Tenant-KEK-Versionsverfolgung (ARC-08).
// Ohne aufgerufenes WithVersionStore verhält sich Service exakt wie vor
// ARC-08 (KEKVersion bleibt 0, OpenAtVersion fällt auf Open zurück) —
// bestehende Aufrufer (z. B. encstorage) sind unverändert lauffähig.
func (s *Service) WithVersionStore(store *KEKVersionStore) *Service {
s.versions = store
return s
}
// Seal erzeugt einen neuen DEK (Akzeptanzkriterium 1), verschlüsselt
// plaintext damit und verpackt den DEK mit dem aktuellen Tenant-KEK
// (Akzeptanzkriterium 2 — der KEK wird bei JEDEM Aufruf frisch von Core
// bezogen, nie zwischengespeichert außer in der optionalen
// KEK-Versionshistorie für spätere Altbestands-Lesezugriffe).
func (s *Service) Seal(ctx context.Context, tenantSlug string, plaintext io.Reader) (*Envelope, error) {
dek, err := GenerateDEK()
if err != nil {
return nil, err
}
ciphertext, err := EncryptStream(dek, plaintext)
if err != nil {
return nil, err
}
kek, err := s.kek.TenantKEK(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err)
}
wrappedDEK, err := WrapDEK(kek, dek)
if err != nil {
return nil, err
}
var kekVersion int
if s.versions != nil {
kekVersion, err = s.versions.RecordIfNew(ctx, tenantSlug, kek)
if err != nil {
return nil, fmt.Errorf("crypto: kek-version erfassen: %w", err)
}
}
return &Envelope{Ciphertext: ciphertext, WrappedDEK: wrappedDEK, KEKVersion: kekVersion}, nil
}
// Open entpackt den DEK mit dem AKTUELLEN Tenant-KEK (Akzeptanzkriterium
// 3: nur mit gültigem, mandantenbezogenem Schlüssel möglich — ein
// falscher Tenant-Slug liefert entweder einen falschen KEK von Core
// [dann schlägt UnwrapDEK fehl] oder Core verweigert den Zugriff direkt)
// und entschlüsselt ciphertext damit. Nach einer Tenant-KEK-Rotation bei
// Core funktioniert Open nur noch für Objekte, die mit dem NEUEN KEK
// versiegelt wurden — für Altbestand siehe OpenAtVersion.
func (s *Service) Open(ctx context.Context, tenantSlug string, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error) {
kek, err := s.kek.TenantKEK(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("crypto: tenant-kek beziehen: %w", err)
}
dek, err := UnwrapDEK(kek, wrappedDEK)
if err != nil {
return nil, err
}
return DecryptStream(dek, ciphertext)
}
// OpenAtVersion entpackt den DEK mit der beim Seal aufgezeichneten
// historischen Tenant-KEK-Version statt mit dem aktuellen Core-KEK
// (ARC-08 Akzeptanzkriterium 3: Altbestand bleibt nach einer
// Hauptschlüssel-Rotation lesbar). Ist kekVersion 0 oder kein
// KEKVersionStore konfiguriert, verhält es sich wie Open (Rückwärts-
// kompatibilität für vor ARC-08 versiegelte Objekte).
func (s *Service) OpenAtVersion(ctx context.Context, tenantSlug string, kekVersion int, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error) {
if kekVersion == 0 || s.versions == nil {
return s.Open(ctx, tenantSlug, wrappedDEK, ciphertext)
}
kek, err := s.versions.Get(ctx, tenantSlug, kekVersion)
if err != nil {
return nil, fmt.Errorf("crypto: historischen tenant-kek beziehen: %w", err)
}
dek, err := UnwrapDEK(kek, wrappedDEK)
if err != nil {
return nil, err
}
return DecryptStream(dek, ciphertext)
}
-35
View File
@@ -1,35 +0,0 @@
// Package dedup implementiert ARC-03: Dublettenerkennung für
// archivierte E-Mails über einen Inhalts-Hash. Kombiniert bewusst NICHT
// mit mail/internal/storage oder mail/internal/crypto — dieses Paket
// kennt beide nicht, der Aufrufer (spätere Ingest-Tickets) ruft es VOR
// mail/internal/crypto auf.
package dedup
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
)
// HashAndBuffer berechnet den SHA-256-Inhalts-Hash von plaintext.
//
// Bekannter Fehler vermeiden (siehe ARC-03-Ticket): der Hash MUSS auf
// dem Klartext berechnet werden, BEVOR mail/internal/crypto verschlüsselt
// — ein Hash auf dem Chiffretext wäre bei jedem Import anders (neuer
// DEK je Objekt, siehe ARC-02) und Dublettenerkennung würde vollständig
// versagen. Reihenfolge: Mail/Anhang empfangen -> HashAndBuffer (dieses
// Paket) -> verschlüsseln (ARC-02) -> ablegen.
//
// plaintext wird beim Hashen vollständig verbraucht — HashAndBuffer
// liefert deshalb einen erneut lesbaren Reader mit demselben Inhalt für
// den nachfolgenden Verschlüsselungsschritt zurück.
func HashAndBuffer(plaintext io.Reader) (contentHash string, buffered io.Reader, err error) {
var buf bytes.Buffer
hasher := sha256.New()
if _, err := io.Copy(hasher, io.TeeReader(plaintext, &buf)); err != nil {
return "", nil, fmt.Errorf("dedup: klartext hashen: %w", err)
}
return hex.EncodeToString(hasher.Sum(nil)), &buf, nil
}
-58
View File
@@ -1,58 +0,0 @@
package dedup
import (
"io"
"strings"
"testing"
)
func TestHashAndBuffer_SameContentSameHash(t *testing.T) {
h1, buf1, err := HashAndBuffer(strings.NewReader("identischer inhalt"))
if err != nil {
t.Fatal(err)
}
h2, buf2, err := HashAndBuffer(strings.NewReader("identischer inhalt"))
if err != nil {
t.Fatal(err)
}
if h1 != h2 {
t.Fatalf("erwartet identischen hash für identischen inhalt, habe %q vs %q", h1, h2)
}
got1, _ := io.ReadAll(buf1)
got2, _ := io.ReadAll(buf2)
if string(got1) != "identischer inhalt" || string(got2) != "identischer inhalt" {
t.Fatal("buffered reader liefert nicht denselben inhalt zurück wie der ursprüngliche klartext")
}
}
func TestHashAndBuffer_DifferentContentDifferentHash(t *testing.T) {
h1, _, err := HashAndBuffer(strings.NewReader("nachricht a"))
if err != nil {
t.Fatal(err)
}
h2, _, err := HashAndBuffer(strings.NewReader("nachricht b"))
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("unterschiedlicher inhalt hätte unterschiedlichen hash liefern müssen")
}
}
// TestHashAndBuffer_SlightlyDifferentContentDifferentHash ist die
// geforderte Pflichtprüfung 3: knapp unterschiedliche Nachrichten
// werden korrekt NICHT als Duplikat erkannt.
func TestHashAndBuffer_SlightlyDifferentContentDifferentHash(t *testing.T) {
h1, _, err := HashAndBuffer(strings.NewReader("Betreff: Test\r\n\r\nInhalt der Nachricht."))
if err != nil {
t.Fatal(err)
}
h2, _, err := HashAndBuffer(strings.NewReader("Betreff: Test\r\n\r\nInhalt der Nachricht,"))
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("ein einziges abweichendes zeichen hätte den hash ändern müssen")
}
}
-75
View File
@@ -1,75 +0,0 @@
package dedup
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store verwaltet bekannte Inhalts-Hashes je Mandant. tenant_slug ist
// fester Bestandteil des Primärschlüssels (Akzeptanzkriterium 3:
// mandantenübergreifend korrekt getrennt) — auch wenn Store einen mit
// anderen Mandanten geteilten Pool erhält, kann ein Hash-Treffer nie
// über Mandantengrenzen hinweg entstehen.
type Store struct {
pool *pgxpool.Pool
tenantSlug string
}
func NewStore(pool *pgxpool.Pool, tenantSlug string) *Store {
return &Store{pool: pool, tenantSlug: tenantSlug}
}
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
// gleiches Muster wie mail/internal/example (kein zentraler
// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden).
func (s *Store) EnsureSchema(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS mail_content_hashes (
tenant_slug TEXT NOT NULL,
content_hash TEXT NOT NULL,
object_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, content_hash)
)
`); err != nil {
return fmt.Errorf("dedup: schema anlegen: %w", err)
}
return nil
}
// Register trägt contentHash für den Mandanten als neu bekannt ein
// (Akzeptanzkriterium 1) und referenziert das Original, statt es
// redundant zu speichern (Akzeptanzkriterium 2): existiert derselbe
// Hash für DIESEN Mandanten bereits mit einem ANDEREN object_key,
// liefert Register isDuplicate=true und den object_key des Originals —
// der Aufrufer legt den neuen Inhalt dann NICHT ab.
func (s *Store) Register(ctx context.Context, contentHash, objectKey string) (isDuplicate bool, existingKey string, err error) {
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_content_hashes (tenant_slug, content_hash, object_key)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_slug, content_hash) DO NOTHING
`, s.tenantSlug, contentHash, objectKey); err != nil {
return false, "", fmt.Errorf("dedup: hash eintragen: %w", err)
}
var storedKey string
err = s.pool.QueryRow(ctx, `
SELECT object_key FROM mail_content_hashes
WHERE tenant_slug = $1 AND content_hash = $2
`, s.tenantSlug, contentHash).Scan(&storedKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, "", fmt.Errorf("dedup: gerade eingetragenen hash nicht wiedergefunden")
}
return false, "", fmt.Errorf("dedup: eintrag lesen: %w", err)
}
if storedKey != objectKey {
return true, storedKey, nil
}
return false, "", nil
}
@@ -1,94 +0,0 @@
// Integrationstest (ARC-03): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/example (QA-01) — TEST_TENANT_DSN.
package dedup
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStore(t *testing.T, tenantSlug string) *Store {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
store := NewStore(pool, tenantSlug)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_content_hashes WHERE tenant_slug = $1`, tenantSlug)
})
return store
}
// TestRegister_SameMessageFromTwoSourcesIsDuplicate ist die geforderte
// Pflichtprüfung 1: dieselbe Nachricht aus zwei Quellen wird als
// Duplikat erkannt.
func TestRegister_SameMessageFromTwoSourcesIsDuplicate(t *testing.T) {
store := setupStore(t, "mandant-arc03-a")
ctx := context.Background()
hash := "fixierter-inhalts-hash-fuer-test-1"
isDup, _, err := store.Register(ctx, hash, "quelle-1/objekt")
if err != nil {
t.Fatalf("erste registrierung: %v", err)
}
if isDup {
t.Fatal("erste registrierung eines hashes darf kein duplikat sein")
}
isDup, existing, err := store.Register(ctx, hash, "quelle-2/objekt")
if err != nil {
t.Fatalf("zweite registrierung: %v", err)
}
if !isDup {
t.Fatal("erwartet: dieselbe nachricht aus zweiter quelle wird als duplikat erkannt")
}
if existing != "quelle-1/objekt" {
t.Fatalf("erwartet referenz auf das original quelle-1/objekt, habe %q", existing)
}
}
// TestRegister_SameContentTwoTenantsNotLinked ist die geforderte
// Pflichtprüfung 2: zwei Mandanten mit identischem Mailinhalt werden
// nicht fälschlich verknüpft.
func TestRegister_SameContentTwoTenantsNotLinked(t *testing.T) {
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
storeA := setupStore(t, "mandant-arc03-x")
storeB := setupStore(t, "mandant-arc03-y")
ctx := context.Background()
hash := "identischer-inhalt-ueber-zwei-mandanten-hinweg"
isDupA, _, err := storeA.Register(ctx, hash, "mandant-x/objekt")
if err != nil {
t.Fatalf("mandant a: %v", err)
}
if isDupA {
t.Fatal("erste registrierung bei mandant a darf kein duplikat sein")
}
isDupB, existingB, err := storeB.Register(ctx, hash, "mandant-y/objekt")
if err != nil {
t.Fatalf("mandant b: %v", err)
}
if isDupB {
t.Fatalf("mandant b wurde fälschlich mit mandant a verknüpft, existing=%q", existingB)
}
}
-138
View File
@@ -1,138 +0,0 @@
// Package encstorage implementiert ARC-02s Anbindung an ARC-01: jedes
// archivierte Objekt wird VOR dem Schreiben über
// mail/internal/storage.Service verschlüsselt (Akzeptanzkriterium 1).
// Kombiniert bewusst zwei bereits fertige, unveränderte Pakete
// (mail/internal/storage aus ARC-01, mail/internal/crypto aus ARC-02)
// statt eines davon zu erweitern — kein Umbau angrenzender Bereiche.
//
// Reihenfolge beachtet (Ticket "Bekannte Fehler vermeiden"): ein
// SHA-256-Hash für Dublettenerkennung (ARC-03) muss auf dem KLARTEXT
// berechnet werden, BEVOR diese Verschlüsselung angewendet wird — das
// ist Sache des Aufrufers (ARC-03, außerhalb dieses Pakets), dieses
// Paket nimmt bereits fertigen Klartext entgegen und verschlüsselt ihn
// sofort, hält ihn nicht länger als nötig im Speicher.
package encstorage
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strconv"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
)
// wrappedDEKKey ist der Sidecar-Objektschlüssel für den mit dem
// Tenant-KEK verpackten DEK — liegt im selben Storage-Backend wie der
// Chiffretext, analog zum Prüfsummen-Sidecar aus ARC-01.
func wrappedDEKKey(key string) string {
return key + ".dek"
}
// kekVersionKey ist der Sidecar-Objektschlüssel für die Tenant-KEK-
// Version, mit der der DEK verpackt wurde (ARC-08 Akzeptanzkriterium 3).
// Fehlt dieser Sidecar (vor ARC-08 geschriebene Objekte), wird Version 0
// angenommen — GetDecrypted verhält sich dann wie vor ARC-08.
func kekVersionKey(key string) string {
return key + ".dek.version"
}
// Service verbindet Storage (ARC-01) und Crypto (ARC-02): der Rest von
// Mail ruft AUSSCHLIESSLICH diesen Service auf, nie storage.Service
// direkt mit Klartext — das verhindert einen Schreibpfad, der die
// Verschlüsselung vergisst (Akzeptanzkriterium 1).
type Service struct {
storage *storage.Service
crypto *crypto.Service
}
func NewService(storageSvc *storage.Service, cryptoSvc *crypto.Service) *Service {
return &Service{storage: storageSvc, crypto: cryptoSvc}
}
// Put verschlüsselt plaintext (Akzeptanzkriterium 1/2: neuer DEK je
// Objekt, mit dem per API-12 bezogenen Tenant-KEK verpackt) und legt
// Chiffretext + verpackten DEK über storage.Service ab (Prüfsumme,
// Nutzungsmeldung — ARC-01 unverändert wiederverwendet).
func (s *Service) Put(ctx context.Context, tenantSlug, key string, plaintext io.Reader, contentType string) error {
env, err := s.crypto.Seal(ctx, tenantSlug, plaintext)
if err != nil {
return fmt.Errorf("encstorage: verschlüsseln: %w", err)
}
ciphertext, err := io.ReadAll(env.Ciphertext)
if err != nil {
return fmt.Errorf("encstorage: chiffretext lesen: %w", err)
}
if _, err := s.storage.Put(ctx, key, bytes.NewReader(ciphertext), int64(len(ciphertext)), contentType); err != nil {
return fmt.Errorf("encstorage: chiffretext speichern: %w", err)
}
if _, err := s.storage.Put(ctx, wrappedDEKKey(key), bytes.NewReader(env.WrappedDEK), int64(len(env.WrappedDEK)), "application/octet-stream"); err != nil {
return fmt.Errorf("encstorage: verpackten dek speichern: %w", err)
}
if env.KEKVersion != 0 {
versionBytes := []byte(strconv.Itoa(env.KEKVersion))
if _, err := s.storage.Put(ctx, kekVersionKey(key), bytes.NewReader(versionBytes), int64(len(versionBytes)), "text/plain"); err != nil {
return fmt.Errorf("encstorage: kek-version speichern: %w", err)
}
}
return nil
}
// GetDecrypted liest Chiffretext (über storage.GetVerified — ARC-01s
// Prüfsummenverifikation wird dabei mitgenutzt) und verpackten DEK,
// entpackt den DEK mit dem AKTUELLEN Tenant-KEK und entschlüsselt den
// Inhalt (Akzeptanzkriterium 3: nur mit gültigem, mandantenbezogenem
// Schlüssel möglich).
func (s *Service) GetDecrypted(ctx context.Context, tenantSlug, key string) ([]byte, error) {
ciphertext, err := s.storage.GetVerified(ctx, key)
if err != nil {
return nil, fmt.Errorf("encstorage: chiffretext lesen: %w", err)
}
dekReader, err := s.storage.Get(ctx, wrappedDEKKey(key))
if err != nil {
return nil, fmt.Errorf("encstorage: verpackten dek lesen: %w", err)
}
defer func() { _ = dekReader.Close() }()
wrappedDEK, err := io.ReadAll(dekReader)
if err != nil {
return nil, fmt.Errorf("encstorage: verpackten dek lesen: %w", err)
}
kekVersion, err := s.readKEKVersion(ctx, key)
if err != nil {
return nil, err
}
plaintextReader, err := s.crypto.OpenAtVersion(ctx, tenantSlug, kekVersion, wrappedDEK, bytes.NewReader(ciphertext))
if err != nil {
return nil, err
}
return io.ReadAll(plaintextReader)
}
// readKEKVersion liest den Versions-Sidecar (ARC-08). Fehlt er (vor
// ARC-08 geschriebene Objekte, oder ein Seal ohne konfigurierten
// KEKVersionStore), gilt Version 0 — crypto.Service.OpenAtVersion fällt
// dafür auf das unveränderte Open-Verhalten zurück.
func (s *Service) readKEKVersion(ctx context.Context, key string) (int, error) {
reader, err := s.storage.Get(ctx, kekVersionKey(key))
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return 0, nil
}
return 0, fmt.Errorf("encstorage: kek-version lesen: %w", err)
}
defer func() { _ = reader.Close() }()
raw, err := io.ReadAll(reader)
if err != nil {
return 0, fmt.Errorf("encstorage: kek-version lesen: %w", err)
}
version, err := strconv.Atoi(string(raw))
if err != nil {
return 0, fmt.Errorf("encstorage: kek-version parsen: %w", err)
}
return version, nil
}
-138
View File
@@ -1,138 +0,0 @@
package encstorage
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
)
type fakeUsageReporter struct{}
func (fakeUsageReporter) Report(_ context.Context, _, _ string, _ int64) error { return nil }
// fakeKEKProvider liefert einen festen, mandantenspezifischen KEK —
// simuliert Core API-12, ohne den echten Dienst für Unit-Tests zu
// brauchen. "richtiger-tenant" hat einen bekannten KEK, jeder andere
// Tenant-Slug liefert einen ANDEREN (falschen) KEK — genau der Fall,
// den Pflichtprüfung 2 testet.
type fakeKEKProvider struct {
keksByTenant map[string][]byte
}
func (f fakeKEKProvider) TenantKEK(_ context.Context, tenantSlug string) ([]byte, error) {
if kek, ok := f.keksByTenant[tenantSlug]; ok {
return kek, nil
}
// Unbekannter Tenant bekommt einen ANDEREN zufälligen KEK (nicht
// abgelehnt auf Core-Seite in diesem Fake, aber garantiert falsch —
// entspricht dem realen Fall, dass ein falscher Tenant-Slug einen
// anderen KEK liefert und die Entschlüsselung deshalb scheitert).
return bytes.Repeat([]byte{0xFF}, crypto.KEKSize), nil
}
func newTestServiceWithDir(t *testing.T) (*Service, string) {
t.Helper()
dir := t.TempDir()
storageSvc := storage.NewService(storage.NewLocalDriver(dir), fakeUsageReporter{}, "acme")
kekProvider := fakeKEKProvider{keksByTenant: map[string][]byte{
"richtiger-tenant": bytes.Repeat([]byte{0x42}, crypto.KEKSize),
}}
cryptoSvc := crypto.NewService(kekProvider)
return NewService(storageSvc, cryptoSvc), dir
}
// TestPut_RawStorageAccessWithoutKeyYieldsNoReadableContent ist die
// geforderte Pflichtprüfung 1: Zugriff auf Rohspeicher ohne Schlüssel
// liefert keine lesbaren Inhalte.
func TestPut_RawStorageAccessWithoutKeyYieldsNoReadableContent(t *testing.T) {
svc, dir := newTestServiceWithDir(t)
ctx := context.Background()
key := storage.ObjectKey("msg-enc-1", 0)
plaintext := "STRENG GEHEIMER E-MAIL-INHALT, NICHT IM KLARTEXT AUF DER PLATTE"
if err := svc.Put(ctx, "richtiger-tenant", key, strings.NewReader(plaintext), "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
// Direkter Dateisystemzugriff, UMGEHT den Service/die Entschlüsselung
// vollständig — simuliert "Rohspeicher ohne Schlüssel".
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(key)))
if err != nil {
t.Fatalf("rohdatei lesen: %v", err)
}
if bytes.Contains(raw, []byte(plaintext)) {
t.Fatalf("klartext ist unverschlüsselt im rohspeicher auffindbar: %q enthält %q", raw, plaintext)
}
if bytes.Contains(raw, []byte("STRENG GEHEIMER")) || bytes.Contains(raw, []byte("GEHEIM")) {
t.Fatal("erkennbare klartext-fragmente im rohspeicher gefunden")
}
}
// TestGetDecrypted_WrongTenantKeyDeniesDecryption ist die geforderte
// Pflichtprüfung 2: falscher Mandantenschlüssel verweigert
// Entschlüsselung.
func TestGetDecrypted_WrongTenantKeyDeniesDecryption(t *testing.T) {
svc, _ := newTestServiceWithDir(t)
ctx := context.Background()
key := storage.ObjectKey("msg-enc-2", 0)
plaintext := "inhalt fuer richtiger-tenant"
if err := svc.Put(ctx, "richtiger-tenant", key, strings.NewReader(plaintext), "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
// Korrekter Tenant kann entschlüsseln.
got, err := svc.GetDecrypted(ctx, "richtiger-tenant", key)
if err != nil {
t.Fatalf("getdecrypted (richtiger tenant): %v", err)
}
if string(got) != plaintext {
t.Fatalf("falscher inhalt: %q", got)
}
// FALSCHER Mandant (anderer Tenant-Slug -> anderer KEK) muss
// abgelehnt werden.
_, err = svc.GetDecrypted(ctx, "falscher-tenant", key)
if !errors.Is(err, crypto.ErrDecryptFailed) {
t.Fatalf("erwartet ErrDecryptFailed bei falschem mandantenschlüssel, habe: %v", err)
}
}
// TestPut_AcceptableEncryptionOverhead ist die geforderte
// Pflichtprüfung 3: Performance-Test bestätigt akzeptablen Overhead
// durch Verschlüsselung.
func TestPut_AcceptableEncryptionOverhead(t *testing.T) {
svc, _ := newTestServiceWithDir(t)
ctx := context.Background()
// ~64 KiB Nutzlast, realistische Anhanggröße.
payload := strings.Repeat("x", 64*1024)
const count = 50
start := time.Now()
for i := 0; i < count; i++ {
key := storage.ObjectKey("msg-perf", i)
if err := svc.Put(ctx, "richtiger-tenant", key, strings.NewReader(payload), "application/octet-stream"); err != nil {
t.Fatalf("put #%d: %v", i, err)
}
}
elapsed := time.Since(start)
avgPerObject := elapsed / count
// Großzügige Grenze (50ms je 64-KiB-Objekt inkl. AES-GCM,
// Prüfsumme, Sidecar-Schreiben, Rücklese-Verifikation aus ARC-01) —
// Ziel: Nachweis, dass Verschlüsselung keinen unverhältnismäßigen
// Overhead verursacht, nicht ein knallhartes SLA.
if avgPerObject > 50*time.Millisecond {
t.Fatalf("erwartet akzeptablen verschlüsselungs-overhead (<50ms/64KiB-objekt), habe %v/objekt (gesamt %v für %d objekte)", avgPerObject, elapsed, count)
}
t.Logf("Verschlüsselungs-Performance: %d×64KiB-Objekte in %v (%v/Objekt)", count, elapsed, avgPerObject)
}
-36
View File
@@ -1,36 +0,0 @@
package example
import (
"encoding/json"
"net/http"
)
// NormalizeHandler ist das E2E-Test-Beispiel (QA-01): ein echter
// HTTP-Endpunkt, gegen den ein Test einen vollständigen Request-Response-
// Zyklus fährt (httptest.Server, echter TCP-Roundtrip, kein reiner
// Funktionsaufruf). Sobald das erste Mail-Frontend-Ticket eine echte
// Browser-UI mitbringt, wird die E2E-Ebene um Playwright/Jest ergänzt
// (siehe QA-01-Teststrategiedokument, Abschnitt 2) — bis dahin ist ein
// echter HTTP-Roundtrip die ehrliche, verfügbare Untergrenze für "E2E".
type normalizeRequest struct {
Address string `json:"address"`
}
type normalizeResponse struct {
Normalized string `json:"normalized"`
}
func NormalizeHandler(w http.ResponseWriter, r *http.Request) {
var req normalizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body", http.StatusBadRequest)
return
}
normalized, err := NormalizeAddress(req.Address)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(normalizeResponse{Normalized: normalized})
}
-51
View File
@@ -1,51 +0,0 @@
// E2E-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): echter
// HTTP-Request über einen laufenden httptest.Server (TCP-Roundtrip),
// nicht nur ein Funktionsaufruf im selben Prozess.
package example
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNormalizeHandler_RealHTTPRoundTrip(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(NormalizeHandler))
defer server.Close()
body, _ := json.Marshal(normalizeRequest{Address: "Kunde@Beispiel.DE"})
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var out normalizeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if out.Normalized != "Kunde@beispiel.de" {
t.Fatalf("got %q", out.Normalized)
}
}
func TestNormalizeHandler_InvalidAddressReturns400(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(NormalizeHandler))
defer server.Close()
body, _ := json.Marshal(normalizeRequest{Address: "keine-gueltige-adresse"})
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
-26
View File
@@ -1,26 +0,0 @@
// Package example dient QA-01 (Mail): liefert je Testart (Unit,
// Integration, E2E) genau EIN reales, lauffähiges Beispiel, an dem sich
// spätere Mail-Tickets orientieren können — keine Wegwerf-Demo, sondern
// eine tatsächlich nützliche, kleine Funktion (Adress-Normalisierung),
// die spätere Ticket (z. B. ING-01/ING-04) ohnehin brauchen werden.
package example
import (
"errors"
"strings"
)
var ErrInvalidAddress = errors.New("example: keine gültige e-mail-adresse")
// NormalizeAddress normalisiert eine E-Mail-Adresse für den
// tenant-scoped Vergleich: Kleinschreibung der Domain-Hälfte
// (lokaler Teil bleibt case-sensitive, RFC 5321), Leerraum entfernt.
func NormalizeAddress(addr string) (string, error) {
addr = strings.TrimSpace(addr)
at := strings.LastIndex(addr, "@")
if at <= 0 || at == len(addr)-1 {
return "", ErrInvalidAddress
}
local, domain := addr[:at], addr[at+1:]
return local + "@" + strings.ToLower(domain), nil
}
-38
View File
@@ -1,38 +0,0 @@
// Unit-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): keine
// externe Abhängigkeit (DB, Netzwerk), reine Funktionsprüfung.
package example
import (
"errors"
"testing"
)
func TestNormalizeAddress_LowercasesDomainOnly(t *testing.T) {
got, err := NormalizeAddress("User.Name@Example.COM")
if err != nil {
t.Fatalf("unerwarteter fehler: %v", err)
}
want := "User.Name@example.com"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestNormalizeAddress_TrimsWhitespace(t *testing.T) {
got, err := NormalizeAddress(" user@example.com ")
if err != nil {
t.Fatalf("unerwarteter fehler: %v", err)
}
if got != "user@example.com" {
t.Fatalf("got %q", got)
}
}
func TestNormalizeAddress_RejectsInvalidInput(t *testing.T) {
cases := []string{"", "no-at-sign", "@nolocalpart.com", "trailing@"}
for _, c := range cases {
if _, err := NormalizeAddress(c); !errors.Is(err, ErrInvalidAddress) {
t.Fatalf("input %q: erwartet ErrInvalidAddress, habe: %v", c, err)
}
}
}
-42
View File
@@ -1,42 +0,0 @@
package example
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// AddressStore ist das Integrationstest-Beispiel (QA-01): eine
// minimale, aber echte DB-gestützte Komponente — nutzt dieselbe
// Tenant-DB-Isolationskonvention wie DMS/Archive (t.Cleanup, geteilte
// physische Postgres-Instanz auf dem Testhost).
type AddressStore struct {
pool *pgxpool.Pool
}
func NewAddressStore(pool *pgxpool.Pool) *AddressStore {
return &AddressStore{pool: pool}
}
func (s *AddressStore) SaveNormalized(ctx context.Context, addr string) (string, error) {
normalized, err := NormalizeAddress(addr)
if err != nil {
return "", err
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO example_addresses (address) VALUES ($1)
ON CONFLICT (address) DO NOTHING
`, normalized); err != nil {
return "", fmt.Errorf("example: adresse speichern: %w", err)
}
return normalized, nil
}
func (s *AddressStore) Exists(ctx context.Context, addr string) (bool, error) {
var exists bool
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM example_addresses WHERE address = $1)`, addr).Scan(&exists); err != nil {
return false, fmt.Errorf("example: existenz prüfen: %w", err)
}
return exists, nil
}
@@ -1,69 +0,0 @@
// Integrations-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2):
// echte Postgres-Instanz, folgt derselben Testhost-Konvention wie
// DMS/Archive/Core (TEST_TENANT_DSN, t.Cleanup, geteilte physische
// Instanz auf 192.168.1.131 — siehe project-nexarch-test-infra).
package example
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS example_addresses (
address TEXT PRIMARY KEY
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE example_addresses`)
})
return pool
}
func TestAddressStore_SaveAndCheckExists(t *testing.T) {
pool := setupTest(t)
store := NewAddressStore(pool)
ctx := context.Background()
normalized, err := store.SaveNormalized(ctx, "Kunde@Beispiel.DE")
if err != nil {
t.Fatalf("savenormalized: %v", err)
}
if normalized != "Kunde@beispiel.de" {
t.Fatalf("erwartet normalisierte adresse, habe %q", normalized)
}
exists, err := store.Exists(ctx, normalized)
if err != nil {
t.Fatal(err)
}
if !exists {
t.Fatal("erwartet real gespeicherte adresse")
}
notExists, err := store.Exists(ctx, "unbekannt@beispiel.de")
if err != nil {
t.Fatal(err)
}
if notExists {
t.Fatal("nie gespeicherte adresse haette nicht existieren duerfen")
}
}
@@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS mail_index_jobs (
id BIGSERIAL PRIMARY KEY,
job_type TEXT NOT NULL CHECK (job_type IN ('index', 'delete')),
tenant_slug TEXT NOT NULL,
message_id TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'succeeded', 'failed')),
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ,
locked_by TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
-213
View File
@@ -1,213 +0,0 @@
// Package indexworker implementiert SRC-02: einen Indexierungs-Worker,
// der neu archivierte Mails asynchron in den Manticore-Index (SRC-01)
// einpflegt und Löschungen/Metadatenänderungen nachzieht. Postgres-
// Jobqueue mit FOR UPDATE SKIP LOCKED, Stale-Lock-Wiedervorlage bei
// Worker-Absturz — dieselbe Konvention wie dms/internal/jobqueue (FDN-04),
// hier bewusst schlanker (kein Redis/AMQP, keine DLQ — nicht Bestandteil
// der Akzeptanzkriterien dieser Kachel).
package indexworker
import (
"context"
_ "embed"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/0001_mail_index_jobs.sql
var schemaMigration string
const (
JobTypeIndex = "index"
JobTypeDelete = "delete"
)
const (
StatusPending = "pending"
StatusProcessing = "processing"
StatusSucceeded = "succeeded"
StatusFailed = "failed"
)
// ErrNoJobAvailable wird von Dequeue geliefert, wenn aktuell kein
// abholbarer Job vorhanden ist (Normalfall bei leerer Queue).
var ErrNoJobAvailable = errors.New("indexworker: kein job verfügbar")
// ErrNotFound wird geliefert, wenn ein angefragter Job nicht existiert.
var ErrNotFound = errors.New("indexworker: job nicht gefunden")
const defaultMaxAttempts = 5
// Job ist eine einzelne Indexierungs-/Löschaufgabe.
type Job struct {
ID int64
JobType string
TenantSlug string
MessageID string
Status string
Attempts int
}
// Queue kapselt den Zugriff auf mail_index_jobs.
type Queue struct {
pool *pgxpool.Pool
staleLockAfter time.Duration
}
// NewQueue erzeugt eine Queue. staleLockAfter legt fest, ab wann ein
// als "processing" markierter Job wieder abholbar gilt, weil sein Worker
// vermutlich abgestürzt ist (Akzeptanzkriterium 3: Worker-Ausfall verliert
// keine Indexierungsaufträge).
func NewQueue(pool *pgxpool.Pool, staleLockAfter time.Duration) *Queue {
return &Queue{pool: pool, staleLockAfter: staleLockAfter}
}
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
// gleiches Muster wie mail/internal/dedup (kein zentraler Migrationsläufer
// für Mandanten-Datenbanken im Mail-Modul vorhanden).
func (q *Queue) EnsureSchema(ctx context.Context) error {
if _, err := q.pool.Exec(ctx, schemaMigration); err != nil {
return fmt.Errorf("indexworker: schema anlegen: %w", err)
}
return nil
}
// EnqueueIndex reiht eine Indexierungsaufgabe ein (Akzeptanzkriterium 1).
// payload enthält die für die Indexierung nötigen Felder (Betreff, Text,
// Anhangstext) als JSON — der Worker kennt keine Klartext-Beschaffung
// selbst, das ist Aufgabe des Aufrufers (analog dedup, das ebenfalls
// storage/crypto nicht kennt).
func (q *Queue) EnqueueIndex(ctx context.Context, tenantSlug, messageID string, payload []byte) (int64, error) {
return q.enqueue(ctx, JobTypeIndex, tenantSlug, messageID, payload)
}
// EnqueueDelete reiht eine Löschaufgabe ein (Akzeptanzkriterium 2).
func (q *Queue) EnqueueDelete(ctx context.Context, tenantSlug, messageID string) (int64, error) {
return q.enqueue(ctx, JobTypeDelete, tenantSlug, messageID, []byte(`{}`))
}
func (q *Queue) enqueue(ctx context.Context, jobType, tenantSlug, messageID string, payload []byte) (int64, error) {
var id int64
err := q.pool.QueryRow(ctx, `
INSERT INTO mail_index_jobs (job_type, tenant_slug, message_id, payload, max_attempts)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, jobType, tenantSlug, messageID, payload, defaultMaxAttempts).Scan(&id)
if err != nil {
return 0, fmt.Errorf("indexworker: job einreihen: %w", err)
}
return id, nil
}
// dequeuedJob trägt zusätzlich den Payload, den nur das Paket selbst
// (worker.go) benötigt.
type dequeuedJob struct {
Job
Payload []byte
}
// Dequeue holt GENAU EINEN abholbaren Job (fällig UND nicht gesperrt, ODER
// dessen Sperre abgestanden ist) und markiert ihn atomar als "processing"
// (Prüfung: Worker-Neustart mitten im Lauf verliert keinen offenen Auftrag
// — FOR UPDATE SKIP LOCKED erlaubt mehreren Worker-Goroutinen gleichzeitigen
// Aufruf ohne denselben Job doppelt zu holen).
func (q *Queue) dequeue(ctx context.Context, workerID string) (*dequeuedJob, error) {
tx, err := q.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("indexworker: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
SELECT id, job_type, tenant_slug, message_id, payload, status, attempts
FROM mail_index_jobs
WHERE (
(status = 'pending' AND available_at <= now())
OR (status = 'processing' AND locked_at <= now() - ($1 * interval '1 second'))
)
ORDER BY available_at
FOR UPDATE SKIP LOCKED
LIMIT 1
`, q.staleLockAfter.Seconds())
var j dequeuedJob
if err := row.Scan(&j.ID, &j.JobType, &j.TenantSlug, &j.MessageID, &j.Payload, &j.Status, &j.Attempts); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoJobAvailable
}
return nil, fmt.Errorf("indexworker: nächsten job lesen: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE mail_index_jobs
SET status = 'processing', attempts = attempts + 1, locked_at = now(), locked_by = $2, updated_at = now()
WHERE id = $1
`, j.ID, workerID); err != nil {
return nil, fmt.Errorf("indexworker: job sperren: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("indexworker: dequeue committen: %w", err)
}
j.Status = StatusProcessing
j.Attempts++
return &j, nil
}
// complete markiert einen Job als erfolgreich abgeschlossen.
func (q *Queue) complete(ctx context.Context, jobID int64) error {
tag, err := q.pool.Exec(ctx, `
UPDATE mail_index_jobs SET status = 'succeeded', locked_at = NULL, locked_by = NULL, updated_at = now()
WHERE id = $1
`, jobID)
if err != nil {
return fmt.Errorf("indexworker: job abschließen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// fail markiert einen Job als fehlgeschlagen. Sind die maximalen Versuche
// erreicht, bleibt er dauerhaft 'failed' (keine DLQ, nicht Bestandteil
// dieser Kachel), sonst wird er mit arithmetischem Backoff (kein
// String-Concat für Intervalle) erneut eingeplant.
func (q *Queue) fail(ctx context.Context, jobID int64, cause error) error {
tag, err := q.pool.Exec(ctx, `
UPDATE mail_index_jobs
SET status = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'pending' END,
available_at = now() + (LEAST(attempts, 10) * interval '10 seconds'),
locked_at = NULL, locked_by = NULL, last_error = $2, updated_at = now()
WHERE id = $1
`, jobID, cause.Error())
if err != nil {
return fmt.Errorf("indexworker: fehlschlag erfassen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Status liefert den aktuellen Zustand eines Jobs (abrufbar über API,
// hier als Go-API — HTTP-Anbindung ist nicht Bestandteil dieser Kachel).
func (q *Queue) Status(ctx context.Context, jobID int64) (*Job, error) {
var j Job
err := q.pool.QueryRow(ctx, `
SELECT id, job_type, tenant_slug, message_id, status, attempts
FROM mail_index_jobs WHERE id = $1
`, jobID).Scan(&j.ID, &j.JobType, &j.TenantSlug, &j.MessageID, &j.Status, &j.Attempts)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("indexworker: job-status lesen: %w", err)
}
return &j, nil
}
-100
View File
@@ -1,100 +0,0 @@
// Integrationstest (SRC-02): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/dedup — TEST_TENANT_DSN.
package indexworker
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupQueue(t *testing.T, staleLockAfter time.Duration) *Queue {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
queue := NewQueue(pool, staleLockAfter)
if err := queue.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_index_jobs WHERE tenant_slug LIKE 'mandant-src02-%'`)
})
return queue
}
// TestDequeue_WorkerCrashMidRunLosesNoJob ist die geforderte Pflichtprüfung
// 1: Absturz eines Workers (Job wird geholt, aber nie completed/failed)
// führt nach Ablauf der Stale-Lock-Frist zu erneuter Zustellung an einen
// zweiten Worker.
func TestDequeue_WorkerCrashMidRunLosesNoJob(t *testing.T) {
queue := setupQueue(t, 100*time.Millisecond)
ctx := context.Background()
jobID, err := queue.EnqueueDelete(ctx, "mandant-src02-crash", "msg-crash-1")
if err != nil {
t.Fatalf("enqueue: %v", err)
}
firstAttempt, err := queue.dequeue(ctx, "worker-1-abgestuerzt")
if err != nil {
t.Fatalf("erster dequeue: %v", err)
}
if firstAttempt.ID != jobID {
t.Fatalf("erwartete job-id %d, habe %d", jobID, firstAttempt.ID)
}
// worker-1 "stürzt ab": kein complete(), kein fail() — Job bleibt
// als "processing" mit veraltetem Lock stehen.
if _, err := queue.dequeue(ctx, "worker-2-sofort"); !errors.Is(err, ErrNoJobAvailable) {
t.Fatalf("erwartete kein verfügbarer job vor ablauf der stale-lock-frist, habe: %v", err)
}
time.Sleep(150 * time.Millisecond)
secondAttempt, err := queue.dequeue(ctx, "worker-2-nach-timeout")
if err != nil {
t.Fatalf("zweiter dequeue nach stale-lock-ablauf: %v", err)
}
if secondAttempt.ID != jobID {
t.Fatalf("erwartete erneute zustellung desselben jobs %d, habe %d", jobID, secondAttempt.ID)
}
if err := queue.complete(ctx, secondAttempt.ID); err != nil {
t.Fatalf("complete: %v", err)
}
}
// TestEnqueueAndStatus_RoundTrip deckt Akzeptanzkriterium 3 (Job-Status
// abrufbar) auf Queue-Ebene ab.
func TestEnqueueAndStatus_RoundTrip(t *testing.T) {
queue := setupQueue(t, time.Minute)
ctx := context.Background()
payload, _ := json.Marshal(map[string]string{"subject": "Test"})
jobID, err := queue.EnqueueIndex(ctx, "mandant-src02-status", "msg-status-1", payload)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
job, err := queue.Status(ctx, jobID)
if err != nil {
t.Fatalf("status: %v", err)
}
if job.Status != StatusPending {
t.Fatalf("erwartete status 'pending', habe %q", job.Status)
}
}
-74
View File
@@ -1,74 +0,0 @@
package indexworker
import (
"context"
"encoding/json"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
)
// indexPayload sind die für die Indexierung nötigen Felder, wie sie beim
// EnqueueIndex als JSON übergeben werden.
type indexPayload struct {
Subject string `json:"subject"`
Body string `json:"body"`
AttachmentText string `json:"attachment_text"`
SentAtUnixEpoch int64 `json:"sent_at"`
}
// Worker holt Jobs aus der Queue und pflegt sie in den Manticore-Index
// (SRC-01) ein bzw. entfernt sie daraus.
type Worker struct {
queue *Queue
searchClient *search.Client
id string
}
func NewWorker(queue *Queue, searchClient *search.Client, workerID string) *Worker {
return &Worker{queue: queue, searchClient: searchClient, id: workerID}
}
// RunOnce verarbeitet genau einen Job, falls vorhanden. Liefert
// ErrNoJobAvailable, wenn die Queue aktuell leer ist — kein Fehlerzustand.
func (w *Worker) RunOnce(ctx context.Context) error {
job, err := w.queue.dequeue(ctx, w.id)
if err != nil {
return err
}
if procErr := w.process(ctx, job); procErr != nil {
if failErr := w.queue.fail(ctx, job.ID, procErr); failErr != nil {
return fmt.Errorf("indexworker: job %d fehlgeschlagen (%v) UND fehlschlag nicht erfassbar: %w", job.ID, procErr, failErr)
}
return nil
}
return w.queue.complete(ctx, job.ID)
}
func (w *Worker) process(ctx context.Context, job *dequeuedJob) error {
docID := search.DocumentID(job.TenantSlug, job.MessageID)
switch job.JobType {
case JobTypeIndex:
var p indexPayload
if err := json.Unmarshal(job.Payload, &p); err != nil {
return fmt.Errorf("indexierungs-payload lesen: %w", err)
}
return w.searchClient.Index(ctx, search.Document{
ID: docID,
TenantSlug: job.TenantSlug,
MessageID: job.MessageID,
Subject: p.Subject,
Body: p.Body,
AttachmentText: p.AttachmentText,
SentAtUnixEpoch: p.SentAtUnixEpoch,
})
case JobTypeDelete:
return w.searchClient.Delete(ctx, docID)
default:
return errors.New("unbekannter job-typ: " + job.JobType)
}
}
-177
View File
@@ -1,177 +0,0 @@
// Integrationstest (SRC-02): echte Postgres- UND Manticore-Instanz,
// folgt derselben TEST_*-Env-Konvention wie mail/internal/search.
package indexworker
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
)
func setupWorkerEnv(t *testing.T) (*Queue, *search.Client) {
t.Helper()
if os.Getenv("TEST_TENANT_DSN") == "" || os.Getenv("TEST_MANTICORE_URL") == "" {
t.Skip("TEST_TENANT_DSN/TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
}
queue := setupQueue(t, time.Minute)
searchClient := search.NewClient(os.Getenv("TEST_MANTICORE_URL"))
if err := searchClient.EnsureSchema(context.Background()); err != nil {
t.Fatalf("search-schema: %v", err)
}
return queue, searchClient
}
func drainQueue(t *testing.T, worker *Worker, maxJobs int) {
t.Helper()
ctx := context.Background()
for i := 0; i < maxJobs; i++ {
if err := worker.RunOnce(ctx); err != nil {
if err == ErrNoJobAvailable {
return
}
t.Fatalf("worker.RunOnce: %v", err)
}
}
}
// TestIndexJob_MakesMailSearchable ist die geforderte Funktionsprüfung zu
// Akzeptanzkriterium 1: eine eingereihte Indexierungsaufgabe macht die Mail
// nach Verarbeitung durch den Worker durchsuchbar.
func TestIndexJob_MakesMailSearchable(t *testing.T) {
queue, searchClient := setupWorkerEnv(t)
ctx := context.Background()
tenant := "mandant-src02-index"
payload, _ := json.Marshal(map[string]any{
"subject": "Jahresabschluss 2025",
"body": "Anbei der Jahresabschluss zur Prüfung.",
})
if _, err := queue.EnqueueIndex(ctx, tenant, "msg-idx-1", payload); err != nil {
t.Fatalf("enqueue: %v", err)
}
worker := NewWorker(queue, searchClient, "worker-test-index")
drainQueue(t, worker, 5)
results, err := searchClient.Search(ctx, tenant, "Jahresabschluss")
if err != nil {
t.Fatalf("search: %v", err)
}
found := false
for _, r := range results {
if r.MessageID == "msg-idx-1" {
found = true
}
}
if !found {
t.Fatalf("erwarteten treffer msg-idx-1 nach indexierung nicht gefunden, habe: %+v", results)
}
}
// TestDeleteJob_RemovesMailFromSearchResults ist die geforderte
// Pflichtprüfung 2: Löschung einer Mail entfernt sie zuverlässig aus
// Suchtreffern.
func TestDeleteJob_RemovesMailFromSearchResults(t *testing.T) {
queue, searchClient := setupWorkerEnv(t)
ctx := context.Background()
tenant := "mandant-src02-delete"
payload, _ := json.Marshal(map[string]any{
"subject": "Vertraulicher Vorgang Zeta",
"body": "Nur für internen Gebrauch.",
})
if _, err := queue.EnqueueIndex(ctx, tenant, "msg-del-1", payload); err != nil {
t.Fatalf("enqueue index: %v", err)
}
worker := NewWorker(queue, searchClient, "worker-test-delete")
drainQueue(t, worker, 5)
preResults, err := searchClient.Search(ctx, tenant, "Zeta")
if err != nil {
t.Fatalf("search vor löschung: %v", err)
}
preFound := false
for _, r := range preResults {
if r.MessageID == "msg-del-1" {
preFound = true
}
}
if !preFound {
t.Fatal("voraussetzung nicht erfüllt: mail vor löschung nicht auffindbar")
}
if _, err := queue.EnqueueDelete(ctx, tenant, "msg-del-1"); err != nil {
t.Fatalf("enqueue delete: %v", err)
}
drainQueue(t, worker, 5)
postResults, err := searchClient.Search(ctx, tenant, "Zeta")
if err != nil {
t.Fatalf("search nach löschung: %v", err)
}
for _, r := range postResults {
if r.MessageID == "msg-del-1" {
t.Fatal("gelöschte mail weiterhin in suchtreffern gefunden")
}
}
}
// TestConsistency_DatabaseAndIndexMatchOnSample ist die geforderte
// Pflichtprüfung 3: Konsistenztest vergleicht Datenbankbestand (erfolgreich
// abgeschlossene Index-Jobs) mit Indexbestand stichprobenartig.
func TestConsistency_DatabaseAndIndexMatchOnSample(t *testing.T) {
queue, searchClient := setupWorkerEnv(t)
ctx := context.Background()
tenant := "mandant-src02-konsistenz"
messageIDs := []string{"msg-konsistenz-1", "msg-konsistenz-2", "msg-konsistenz-3"}
for _, mid := range messageIDs {
payload, _ := json.Marshal(map[string]any{
"subject": "Konsistenzprobe " + mid,
"body": "Inhalt zur Konsistenzprüfung.",
})
if _, err := queue.EnqueueIndex(ctx, tenant, mid, payload); err != nil {
t.Fatalf("enqueue: %v", err)
}
}
worker := NewWorker(queue, searchClient, "worker-test-konsistenz")
drainQueue(t, worker, 10)
for _, mid := range messageIDs {
job, err := findJobByMessageID(ctx, queue, tenant, mid)
if err != nil {
t.Fatalf("job für %s: %v", mid, err)
}
if job.Status != StatusSucceeded {
t.Fatalf("job für %s hat status %q, erwartet 'succeeded'", mid, job.Status)
}
results, err := searchClient.Search(ctx, tenant, "Konsistenzprobe")
if err != nil {
t.Fatalf("search: %v", err)
}
present := false
for _, r := range results {
if r.MessageID == mid {
present = true
}
}
if !present {
t.Fatalf("datenbank meldet job für %s als succeeded, aber index enthält kein passendes dokument", mid)
}
}
}
func findJobByMessageID(ctx context.Context, queue *Queue, tenantSlug, messageID string) (*Job, error) {
var j Job
err := queue.pool.QueryRow(ctx, `
SELECT id, job_type, tenant_slug, message_id, status, attempts
FROM mail_index_jobs WHERE tenant_slug = $1 AND message_id = $2 AND job_type = 'index'
ORDER BY id DESC LIMIT 1
`, tenantSlug, messageID).Scan(&j.ID, &j.JobType, &j.TenantSlug, &j.MessageID, &j.Status, &j.Attempts)
return &j, err
}
-206
View File
@@ -1,206 +0,0 @@
// Package mimeparse implementiert ING-04: MIME-/Anhang-Parsing für
// ein- und ausgehende Nachrichten (Multipart, verschachtelt,
// Content-Transfer-Encoding, defensive Zeichensatz-Reparatur).
//
// NUR Parsing — Speicherung ist ARC-01s Aufgabe (siehe "Nicht
// Bestandteil dieser Kachel"), dieses Paket schreibt nirgends in einen
// Objekt-Speicher, sondern liefert nur strukturierte Go-Werte zurück.
//
// Bekannten Fehler vermieden (archivmail known-issues #3): Anhänge
// wurden früher über io.ReadAll ohne Größenlimit gelesen — eine
// Speicherbombe durch große/böswillige Anhänge. Hier läuft JEDER
// Anhang-Lesevorgang über io.LimitReader mit konfigurierter Max-Size;
// eine Überschreitung führt zu einer harten, sauberen Ablehnung
// (ErrAttachmentTooLarge), kein stilles Abschneiden.
package mimeparse
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"strings"
"golang.org/x/text/encoding/htmlindex"
)
// ErrAttachmentTooLarge wird geliefert, wenn ein Anhang die
// konfigurierte Maximalgröße überschreitet (Akzeptanzkriterium/
// Pflichtprüfung 1).
var ErrAttachmentTooLarge = errors.New("mimeparse: anhang überschreitet die maximal erlaubte größe")
// Part ist EIN zerlegter MIME-Teil — sowohl Textteile (IsAttachment
// == false) als auch Anhänge (Akzeptanzkriterium 1/2).
type Part struct {
ContentType string
Filename string
Size int64
Content []byte
IsAttachment bool
}
// Message ist das Ergebnis eines vollständig zerlegten Multipart-
// Baums — verschachtelte multipart/*-Teile sind bereits rekursiv
// aufgelöst, der Aufrufer sieht nur die "Blatt"-Teile (Akzeptanz-
// kriterium 1).
type Message struct {
Parts []Part
}
// wordDecoder dekodiert RFC-2047-kodierte Header-Werte (z. B.
// Anhang-Dateinamen) defensiv: ein unbekannter/fehlerhafter
// Zeichensatz bricht die Verarbeitung NICHT ab (Akzeptanzkriterium 3),
// sondern liefert den Rohwert unverändert zurück.
var wordDecoder = &mime.WordDecoder{CharsetReader: charsetReader}
func charsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
enc, err := htmlindex.Get(charsetLabel)
if err != nil {
// Unbekannter/fehlerhafter Zeichensatz: defensiv als
// UTF-8-verträglichen Rohtext weiterreichen statt
// abzubrechen (Akzeptanzkriterium 3).
return input, nil
}
return enc.NewDecoder().Reader(input), nil
}
func decodeHeaderValue(raw string) string {
decoded, err := wordDecoder.DecodeHeader(raw)
if err != nil {
// Defensiv: Rohwert statt Abbruch (Akzeptanzkriterium 3).
return raw
}
return decoded
}
// Parse zerlegt eine MIME-Nachricht vollständig, inklusive
// verschachtelter Multipart-Teile (Akzeptanzkriterium 1). maxAttachmentSize
// begrenzt JEDEN einzelnen Anhang (Akzeptanzkriterium/Pflichtprüfung 1).
func Parse(r io.Reader, maxAttachmentSize int64) (Message, error) {
msg, err := mail.ReadMessage(r)
if err != nil {
return Message{}, fmt.Errorf("mimeparse: nachricht lesen: %w", err)
}
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
// Kein/kaputtes Content-Type: als einzelnen Textteil behandeln
// statt abzubrechen (Akzeptanzkriterium 3: defensiv reparieren).
body, readErr := readLimited(msg.Body, maxAttachmentSize)
if readErr != nil {
return Message{}, readErr
}
return Message{Parts: []Part{{ContentType: "text/plain", Content: body, Size: int64(len(body))}}}, nil
}
var result Message
if strings.HasPrefix(mediaType, "multipart/") {
if err := parseMultipart(msg.Body, params["boundary"], maxAttachmentSize, &result); err != nil {
return Message{}, err
}
return result, nil
}
// Einzelner Teil (keine Multipart-Hülle).
part, err := readSinglePart(msg.Header.Get("Content-Transfer-Encoding"), mediaType, "", msg.Body, maxAttachmentSize)
if err != nil {
return Message{}, err
}
result.Parts = append(result.Parts, part)
return result, nil
}
// parseMultipart löst EINEN Multipart-Container rekursiv auf —
// verschachtelte multipart/*-Teile (z. B. multipart/mixed, das
// multipart/alternative enthält) werden vollständig zerlegt
// (Akzeptanzkriterium 1), keine Rekursionstiefe hartkodiert begrenzt
// außer durch die natürliche Nachrichtengröße selbst.
func parseMultipart(r io.Reader, boundary string, maxAttachmentSize int64, result *Message) error {
if boundary == "" {
return errors.New("mimeparse: multipart ohne boundary")
}
mr := multipart.NewReader(r, boundary)
for {
p, err := mr.NextPart()
if err == io.EOF {
return nil
}
if err != nil {
// Kaputte Multipart-Struktur: kontrolliert abbrechen
// (Pflichtprüfung 3), nicht abstürzen.
return fmt.Errorf("mimeparse: multipart-teil lesen: %w", err)
}
contentType := p.Header.Get("Content-Type")
mediaType, subParams, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = "text/plain"
}
if strings.HasPrefix(mediaType, "multipart/") {
if err := parseMultipart(p, subParams["boundary"], maxAttachmentSize, result); err != nil {
return err
}
continue
}
part, err := readSinglePart(p.Header.Get("Content-Transfer-Encoding"), mediaType, decodeHeaderValue(p.FileName()), p, maxAttachmentSize)
if err != nil {
return err
}
result.Parts = append(result.Parts, part)
}
}
func readSinglePart(transferEncoding, mediaType, filename string, r io.Reader, maxAttachmentSize int64) (Part, error) {
decoded := decodeTransferEncoding(transferEncoding, r)
content, err := readLimited(decoded, maxAttachmentSize)
if err != nil {
return Part{}, err
}
return Part{
ContentType: mediaType,
Filename: filename,
Size: int64(len(content)),
Content: content,
IsAttachment: filename != "",
}, nil
}
// decodeTransferEncoding dekodiert Content-Transfer-Encoding
// (quoted-printable/base64) — defensiv: ein unbekanntes Encoding wird
// unverändert durchgereicht statt die Verarbeitung abzubrechen
// (Akzeptanzkriterium 3).
func decodeTransferEncoding(encoding string, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "quoted-printable":
return quotedprintable.NewReader(r)
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
default:
return r
}
}
// readLimited liest höchstens maxSize+1 Bytes — wird die Grenze
// überschritten, wird ErrAttachmentTooLarge geliefert, statt beliebig
// viel Speicher zu allozieren (Akzeptanzkriterium/Pflichtprüfung 1,
// archivmail known-issues #3).
func readLimited(r io.Reader, maxSize int64) ([]byte, error) {
limited := io.LimitReader(r, maxSize+1)
var buf bytes.Buffer
if _, err := io.Copy(&buf, limited); err != nil {
return nil, fmt.Errorf("mimeparse: teil lesen: %w", err)
}
if int64(buf.Len()) > maxSize {
return nil, ErrAttachmentTooLarge
}
return buf.Bytes(), nil
}
-219
View File
@@ -1,219 +0,0 @@
package mimeparse
import (
"errors"
"io"
"strings"
"testing"
)
const defaultMaxSize = 10 * 1024 * 1024 // 10 MiB
// TestParse_NestedMultipartFullyDecomposed ist Akzeptanzkriterium 1:
// Multipart-Nachrichten mit verschachtelten Teilen werden vollständig
// zerlegt (multipart/mixed enthält multipart/alternative UND einen
// Anhang).
func TestParse_NestedMultipartFullyDecomposed(t *testing.T) {
raw := "From: a@example.com\r\n" +
"To: b@example.com\r\n" +
"Subject: Test\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n" +
"--outer\r\n" +
"Content-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n" +
"--inner\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
"Hallo als Text\r\n" +
"--inner\r\n" +
"Content-Type: text/html; charset=utf-8\r\n\r\n" +
"<p>Hallo als HTML</p>\r\n" +
"--inner--\r\n" +
"--outer\r\n" +
"Content-Type: application/pdf\r\n" +
"Content-Disposition: attachment; filename=\"rechnung.pdf\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"JVBERi0xLjQK\r\n" +
"--outer--\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(msg.Parts) != 3 {
t.Fatalf("erwartet 3 zerlegte teile (text, html, anhang), habe %d: %+v", len(msg.Parts), msg.Parts)
}
var sawText, sawHTML, sawAttachment bool
for _, p := range msg.Parts {
switch {
case p.ContentType == "text/plain":
sawText = true
if string(p.Content) != "Hallo als Text" {
t.Fatalf("unerwarteter text-inhalt: %q", p.Content)
}
case p.ContentType == "text/html":
sawHTML = true
case p.IsAttachment:
sawAttachment = true
}
}
if !sawText || !sawHTML || !sawAttachment {
t.Fatalf("nicht alle erwarteten teile gefunden: text=%v html=%v attachment=%v", sawText, sawHTML, sawAttachment)
}
}
// TestParse_AttachmentMetadataExtracted ist Akzeptanzkriterium 2:
// Anhänge werden mit korrektem Dateinamen, Größe und Content-Type
// extrahiert.
func TestParse_AttachmentMetadataExtracted(t *testing.T) {
raw := "From: a@example.com\r\n" +
"Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" +
"--b\r\n" +
"Content-Type: text/plain\r\n\r\n" +
"Text\r\n" +
"--b\r\n" +
"Content-Type: image/png\r\n" +
"Content-Disposition: attachment; filename=\"bild.png\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"iVBORw0KGgo=\r\n" +
"--b--\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("parse: %v", err)
}
var attachment *Part
for i := range msg.Parts {
if msg.Parts[i].IsAttachment {
attachment = &msg.Parts[i]
}
}
if attachment == nil {
t.Fatal("kein anhang gefunden")
}
if attachment.Filename != "bild.png" {
t.Fatalf("falscher dateiname: %q", attachment.Filename)
}
if attachment.ContentType != "image/png" {
t.Fatalf("falscher content-type: %q", attachment.ContentType)
}
if attachment.Size != int64(len(attachment.Content)) || attachment.Size == 0 {
t.Fatalf("unplausible größe: %d (content-len %d)", attachment.Size, len(attachment.Content))
}
}
// TestParse_BrokenCharsetIsRepairedNotAborted ist Akzeptanzkriterium 3:
// fehlerhafte/inkonsistente Zeichensatzangaben werden defensiv repariert
// statt die Verarbeitung abzubrechen.
func TestParse_BrokenCharsetIsRepairedNotAborted(t *testing.T) {
// "unbekannt-xyz" ist KEIN gültiger IANA-Zeichensatzname.
raw := "From: a@example.com\r\n" +
"Content-Type: text/plain; charset=\"unbekannt-xyz\"\r\n\r\n" +
"Rohtext trotz kaputtem Charset\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("erwartet KEINEN abbruch bei kaputtem charset, habe: %v", err)
}
if len(msg.Parts) != 1 {
t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts))
}
if !strings.Contains(string(msg.Parts[0].Content), "Rohtext") {
t.Fatalf("inhalt fehlt/verstümmelt: %q", msg.Parts[0].Content)
}
}
// TestParse_ISO88591BodyDecodedWithoutAbort ist Akzeptanzkriterium 3
// zusätzlich: ein bekannter Nicht-UTF-8-Zeichensatz wird via
// Header-Dekodierung real repariert (RFC-2047-kodierter Dateiname).
func TestParse_ISO88591FilenameDecoded(t *testing.T) {
// "=?ISO-8859-1?Q?Rechnung_=DC?=" kodiert "Rechnung Ü" (0xDC = 'Ü' in Latin-1).
raw := "From: a@example.com\r\n" +
"Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" +
"--b\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"=?ISO-8859-1?Q?Rechnung_=DC?=\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"AAA=\r\n" +
"--b--\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(msg.Parts) != 1 {
t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts))
}
if msg.Parts[0].Filename != "Rechnung Ü" {
t.Fatalf("erwartet dekodierten dateinamen 'Rechnung Ü', habe %q", msg.Parts[0].Filename)
}
}
// infiniteReader liefert unbegrenzt viele Bytes — simuliert einen sehr
// großen/böswilligen Anhang. Ohne io.LimitReader (siehe archivmail
// known-issues #3) würde ein io.ReadAll hierauf den Prozessspeicher
// erschöpfen; mit readLimited bricht Parse kontrolliert und schnell ab.
type infiniteReader struct{}
func (infiniteReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 'A'
}
return len(p), nil
}
// TestParse_OversizedAttachmentRejectedNotMemoryExhausted ist die
// geforderte Pflichtprüfung 1.
func TestParse_OversizedAttachmentRejectedNotMemoryExhausted(t *testing.T) {
const tinyLimit = 1024 // 1 KiB — winzig, damit der Test schnell bleibt
header := "From: a@example.com\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Transfer-Encoding: identity\r\n\r\n"
r := io.MultiReader(strings.NewReader(header), infiniteReader{})
_, err := Parse(r, tinyLimit)
if !errors.Is(err, ErrAttachmentTooLarge) {
t.Fatalf("erwartet ErrAttachmentTooLarge bei unbegrenzt großem anhang, habe: %v", err)
}
}
// TestParse_RealisticCorpusRunsCleanly ist die geforderte
// Pflichtprüfung 2: realitätsnahe Multipart-/Encoding-Varianten laufen
// fehlerfrei durch.
func TestParse_RealisticCorpusRunsCleanly(t *testing.T) {
corpus := []string{
// Einfache Textnachricht ohne Multipart.
"From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nEinfacher Text\r\n",
// Quoted-Printable.
"From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nUmlaut: =C3=9C\r\n",
// Multipart/alternative ohne Anhang.
"From: a@example.com\r\nContent-Type: multipart/alternative; boundary=\"x\"\r\n\r\n--x\r\nContent-Type: text/plain\r\n\r\nText\r\n--x\r\nContent-Type: text/html\r\n\r\n<p>Text</p>\r\n--x--\r\n",
// Leere Multipart-Nachricht (kein Teil, nur Präambel/Epilog).
"From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"y\"\r\n\r\nPräambel wird ignoriert\r\n--y--\r\nEpilog wird ignoriert\r\n",
}
for i, raw := range corpus {
if _, err := Parse(strings.NewReader(raw), defaultMaxSize); err != nil {
t.Fatalf("corpus[%d] fehlgeschlagen: %v\nraw=%q", i, err, raw)
}
}
}
// FuzzParse ist die geforderte Pflichtprüfung 3: kaputte MIME-Strukturen
// dürfen Parse nicht zum Absturz bringen, nur zu einem kontrollierten
// Fehler.
func FuzzParse(f *testing.F) {
f.Add([]byte("From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nHallo\r\n--b--\r\n"))
f.Add([]byte("Content-Type: multipart/mixed; boundary=\r\n\r\nkaputt"))
f.Add([]byte(""))
f.Add([]byte("From: a@example.com\r\n\r\n"))
f.Fuzz(func(t *testing.T, data []byte) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("Parse ist abgestürzt (panic) statt kontrolliert einen Fehler zu liefern: %v", r)
}
}()
_, _ = Parse(strings.NewReader(string(data)), defaultMaxSize)
})
}
-72
View File
@@ -1,72 +0,0 @@
// Package pflichttestgate erzwingt die in docs/TESTSTRATEGIE-MAIL.md
// Abschnitt 4 festgelegte Regel: jede geänderte Go-Datei in einem
// sicherheitskritischen Bereich (Auth, Tenant-Scoping, Protokoll-/
// Compliance-kritisch) muss von einer geänderten oder neuen _test.go-
// Datei im selben Package begleitet sein. Bewusste Code-Kopie des
// Musters aus Core internal/pflichttestgate — Mail ist ein eigenständiges
// Go-Modul und kann Core nicht importieren.
package pflichttestgate
import (
"path"
"regexp"
"strings"
)
// sensitivePathPatterns beschreibt die Bereiche aus
// TESTSTRATEGIE-MAIL.md Abschnitt 4.
var sensitivePathPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)mail/internal/auth/`),
regexp.MustCompile(`(^|/)mail/internal/tenant/`),
regexp.MustCompile(`(^|/)mail/internal/ingest/`),
regexp.MustCompile(`(^|/)mail/internal/imap/`),
regexp.MustCompile(`(^|/)mail/internal/smtp/`),
regexp.MustCompile(`(^|/)mail/internal/arc/`),
}
// Violation beschreibt ein Package mit sicherheitskritischer Änderung
// ohne begleitende Testdatei.
type Violation struct {
Package string
ChangedFile string
}
func isSensitive(file string) bool {
if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") {
return false
}
for _, re := range sensitivePathPatterns {
if re.MatchString(file) {
return true
}
}
return false
}
// CheckDiff prüft eine Liste geänderter Dateipfade gegen die
// Pflichttest-Regel — ein leeres Ergebnis bedeutet: Gate besteht.
func CheckDiff(changedFiles []string) []Violation {
sensitiveByPkg := map[string]string{}
testTouchedPkgs := map[string]bool{}
for _, f := range changedFiles {
pkg := path.Dir(f)
if strings.HasSuffix(f, "_test.go") {
testTouchedPkgs[pkg] = true
continue
}
if isSensitive(f) {
if _, seen := sensitiveByPkg[pkg]; !seen {
sensitiveByPkg[pkg] = f
}
}
}
var violations []Violation
for pkg, file := range sensitiveByPkg {
if !testTouchedPkgs[pkg] {
violations = append(violations, Violation{Package: pkg, ChangedFile: file})
}
}
return violations
}
@@ -1,33 +0,0 @@
// Negativtest des Gates selbst (QA-01 Prüfung 1): ein Diff mit
// geänderter mail/internal/auth/login.go ohne begleitende Testdatei
// muss als Verstoß erkannt werden.
package pflichttestgate
import "testing"
func TestCheckDiff_FlagsSensitiveChangeWithoutTest(t *testing.T) {
violations := CheckDiff([]string{"mail/internal/auth/login.go"})
if len(violations) != 1 {
t.Fatalf("erwartet genau 1 verstoß, habe %d: %+v", len(violations), violations)
}
if violations[0].Package != "mail/internal/auth" {
t.Fatalf("falsches package gemeldet: %+v", violations[0])
}
}
func TestCheckDiff_PassesWhenTestFileAccompanies(t *testing.T) {
violations := CheckDiff([]string{
"mail/internal/auth/login.go",
"mail/internal/auth/login_test.go",
})
if len(violations) != 0 {
t.Fatalf("erwartet keine verstöße, habe: %+v", violations)
}
}
func TestCheckDiff_IgnoresNonSensitivePaths(t *testing.T) {
violations := CheckDiff([]string{"mail/internal/example/normalize.go"})
if len(violations) != 0 {
t.Fatalf("erwartet keine verstöße für nicht-sensiblen pfad, habe: %+v", violations)
}
}
-326
View File
@@ -1,326 +0,0 @@
package search
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// 0001_mail_documents.sql: statisches, versioniertes Schema
// (Akzeptanzkriterium 1). Feldnamen hier UND in fields.go müssen
// deckungsgleich bleiben — die Konstanten in fields.go sind die einzige
// Stelle, aus der Go-Code Feldnamen für Schreib-/Lesezugriffe bezieht.
// Manticores SQL-Parser unterstützt keine "--"-Kommentare, daher bleibt
// die eingebettete Datei selbst kommentarfrei.
//
//go:embed migrations/0001_mail_documents.sql
var schemaMigration string
// SRC-05: Facettenfelder als eigene, nummerierte ALTER-Migrationen
// nachgezogen (Manticore erlaubt nur eine Spalte je ALTER TABLE ADD
// COLUMN-Anweisung). Reihenfolge ist die Anwendungsreihenfolge.
//
//go:embed migrations/0002_mail_documents_facets.sql
var migrationAddSender string
//go:embed migrations/0003_mail_documents_mailbox.sql
var migrationAddMailbox string
//go:embed migrations/0004_mail_documents_attachment_type.sql
var migrationAddAttachmentType string
//go:embed migrations/0005_mail_documents_tag.sql
var migrationAddTag string
var facetMigrations = []string{
migrationAddSender,
migrationAddMailbox,
migrationAddAttachmentType,
migrationAddTag,
}
// Client spricht ausschließlich über die strukturierte Manticore-HTTP-
// JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go).
// Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort
// ausschließlich mit dem statischen, eingebetteten Migrationstext —
// niemals mit zur Laufzeit zusammengesetzten Werten.
type Client struct {
baseURL string
http *http.Client
}
func NewClient(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{Timeout: 10 * time.Second},
}
}
// EnsureSchema legt den Index gemäß dem versionierten, statischen
// Migrationstext an (Akzeptanzkriterium 1) und zieht die Facettenfelder
// (SRC-05) idempotent nach.
func (c *Client) EnsureSchema(ctx context.Context) error {
if err := c.runSchemaSQL(ctx, schemaMigration); err != nil {
return fmt.Errorf("search: schema anlegen: %w", err)
}
for _, migration := range facetMigrations {
if err := c.runSchemaSQL(ctx, migration); err != nil {
// Manticore meldet bei erneutem ADD COLUMN "field already in
// schema" — kein Fehler, sondern der bereits angewendete
// Migrationsschritt (Idempotenz, gleiche CREATE-TABLE-IF-NOT-
// EXISTS-Konvention wie das Basisschema).
if strings.Contains(err.Error(), "already in schema") {
continue
}
return fmt.Errorf("search: facettenfeld-migration: %w", err)
}
}
return nil
}
func (c *Client) runSchemaSQL(ctx context.Context, query string) error {
form := "query=" + query
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form))
if err != nil {
return fmt.Errorf("anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("ausführen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
if strings.Contains(string(body), `"error":"`) && !strings.Contains(string(body), `"error":""`) {
return fmt.Errorf("%s", string(body))
}
return nil
}
// Document ist ein Mail-Suchdokument. Feldnamen im JSON-Tag entsprechen
// exakt den Konstanten in fields.go.
type Document struct {
ID uint64 `json:"-"`
TenantSlug string `json:"tenant_slug"`
MessageID string `json:"message_id"`
Subject string `json:"subject"`
Body string `json:"body"`
AttachmentText string `json:"attachment_text"`
SentAtUnixEpoch int64 `json:"sent_at"`
// Facettenfelder (SRC-05), optional — leerer String bedeutet "kein Wert".
Sender string `json:"sender"`
Mailbox string `json:"mailbox"`
AttachmentType string `json:"attachment_type"`
Tag string `json:"tag"`
}
// Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff
// ausschließlich über statische, vordefinierte Feldnamen aus dem
// Document-Struct — kein dynamischer Feldname möglich).
func (c *Client) Index(ctx context.Context, doc Document) error {
payload := map[string]any{
"index": IndexName,
"id": doc.ID,
"doc": doc,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("search: dokument serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/replace", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("search: index-anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("search: dokument indexieren: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("search: dokument indexieren, status %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
// Delete entfernt ein Suchdokument anhand seiner ID (SRC-02
// Akzeptanzkriterium 2: Löschungen werden im Index nachgezogen). Löschen
// eines nicht (mehr) vorhandenen Dokuments ist kein Fehler (idempotent).
func (c *Client) Delete(ctx context.Context, id uint64) error {
payload := map[string]any{
"index": IndexName,
"id": id,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("search: lösch-anfrage serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/delete", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("search: lösch-anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("search: dokument löschen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("search: dokument löschen, status %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
// Result ist ein Suchtreffer.
type Result struct {
MessageID string
Subject string
Score int64
SentAtUnixEpoch int64
}
// fieldWeights gewichtet Betreff höher als Text, Anhangstext am
// niedrigsten (SRC-03 Akzeptanzkriterium 1: Ranking berücksichtigt u.a.
// Anhangstreffer) — statische Konstanten, keine dynamischen Feldnamen.
var fieldWeights = map[string]any{
FieldSubject: 10,
FieldBody: 3,
FieldAttachmentText: 1,
}
// doSearchWithSwapRetry führt eine /search-Anfrage aus und wiederholt sie
// bis zu zweimal mit kurzer Pause, falls Manticore "unknown local table"
// meldet (SRC-09 Akzeptanzkriterium 3: der Reindex-Umschaltmoment
// RENAME-alte-Tabelle-weg/RENAME-neue-Tabelle-rein hat ein extrem kurzes
// Zeitfenster ohne existierende mail_documents-Tabelle — dieser Retry
// überbrückt es, statt eine Suchanfrage in genau diesem Moment fehlschlagen
// zu lassen).
func (c *Client) doSearchWithSwapRetry(ctx context.Context, body []byte) ([]byte, error) {
const maxAttempts = 3
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(20 * time.Millisecond):
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("search: suchanfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("search: suche ausführen: %w", err)
}
respBody, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("search: antwort lesen: %w", readErr)
}
if strings.Contains(string(respBody), "unknown local table") {
lastErr = fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody))
continue
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
return nil, lastErr
}
// Search sucht queryText innerhalb der Volltextfelder, strikt begrenzt auf
// den Mandanten tenantSlug (Akzeptanzkriterium 2: mandantengetrennt
// abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"-
// Feld der JSON-API, niemals über eine interpolierte WHERE-Klausel.
//
// queryText nutzt Manticores erweiterte Abfragesyntax über den
// query_string-Klausel-Typ (Akzeptanzkriterium 3: Phrasensuche mit
// Anführungszeichen, Ausschluss mit vorangestelltem "-") — der Wert landet
// als reiner JSON-String-Wert, es gibt dabei keinerlei dynamischen
// Feld-/Tabellennamen, der beeinflusst werden könnte. Ergebnisse kommen
// von Manticore bereits nach Relevanz (BM25, gewichtet über fieldWeights)
// absteigend sortiert zurück (Akzeptanzkriterium 1).
const searchResultLimit = 1000
func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) {
payload := map[string]any{
"index": IndexName,
"query": map[string]any{
"bool": map[string]any{
"must": []map[string]any{
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
{"query_string": queryText},
},
},
},
"options": map[string]any{
"field_weights": fieldWeights,
},
// Ohne explizites limit begrenzt Manticore standardmäßig auf 20
// Treffer — bei Testkorpora bis 1000 Dokumenten (SRC-03) blieb das
// bisher unbemerkt, da nur auf das Vorhandensein einzelner Treffer
// geprüft wurde, nicht auf die Gesamtzahl. searchResultLimit deckt
// realistische Trefferlisten ab, ohne unbegrenzt zu sein.
"limit": searchResultLimit,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: suchanfrage serialisieren: %w", err)
}
respBody, err := c.doSearchWithSwapRetry(ctx, body)
if err != nil {
return nil, err
}
var parsed searchResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("search: antwort parsen: %w", err)
}
results := make([]Result, 0, len(parsed.Hits.Hits))
for _, hit := range parsed.Hits.Hits {
results = append(results, Result{
MessageID: hit.Source.MessageID,
Subject: hit.Source.Subject,
Score: hit.Score,
SentAtUnixEpoch: hit.Source.SentAtUnixEpoch,
})
}
return results, nil
}
type searchResponse struct {
Hits struct {
Hits []struct {
Score int64 `json:"_score"`
Source struct {
MessageID string `json:"message_id"`
Subject string `json:"subject"`
SentAtUnixEpoch int64 `json:"sent_at"`
} `json:"_source"`
} `json:"hits"`
} `json:"hits"`
}
-72
View File
@@ -1,72 +0,0 @@
package search
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestSearch_MaliciousInputDoesNotAlterFieldNames ist die geforderte
// Pflichtprüfung 2: eine Abfrage mit manipulierten Eingabewerten
// (SQL-/Injection-artige Zeichen in tenantSlug und queryText) darf keine
// Spalten-/Tabellennamen in der an Manticore gesendeten Anfrage verändern
// — Werte landen ausschließlich als JSON-String-Werte, niemals als
// Feld-/Tabellenname.
func TestSearch_MaliciousInputDoesNotAlterFieldNames(t *testing.T) {
var captured map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"hits":{"hits":[]}}`))
}))
defer srv.Close()
client := NewClient(srv.URL)
maliciousTenant := `acme"; DROP TABLE mail_documents; --`
maliciousQuery := `x' OR '1'='1`
if _, err := client.Search(context.Background(), maliciousTenant, maliciousQuery); err != nil {
t.Fatalf("search: %v", err)
}
query, ok := captured["query"].(map[string]any)
if !ok {
t.Fatal("erwartetes 'query'-Objekt fehlt in gesendetem Payload")
}
boolQuery, ok := query["bool"].(map[string]any)
if !ok {
t.Fatal("erwartetes 'bool'-Objekt fehlt")
}
must, ok := boolQuery["must"].([]any)
if !ok || len(must) != 2 {
t.Fatal("erwartete 'must'-Liste mit 2 Klauseln fehlt")
}
equalsClause, ok := must[0].(map[string]any)["equals"].(map[string]any)
if !ok {
t.Fatal("erwartete 'equals'-Klausel fehlt")
}
// Feldname bleibt statisch "tenant_slug" — nur der Wert enthält die
// böswillige Eingabe, unverändert als String.
if _, hasStaticField := equalsClause[FieldTenantSlug]; !hasStaticField {
t.Fatalf("erwartetes statisches Feld %q nicht gefunden, habe: %v", FieldTenantSlug, equalsClause)
}
if equalsClause[FieldTenantSlug] != maliciousTenant {
t.Fatalf("wert wurde verändert: %v", equalsClause[FieldTenantSlug])
}
// query_string hat keinerlei dynamischen Feldnamen — der Klausel-Wert
// ist unmittelbar der übergebene String, keine map mit datenabhängigem
// Schlüssel möglich.
queryStringClause, ok := must[1].(map[string]any)["query_string"]
if !ok {
t.Fatal("erwartete 'query_string'-Klausel fehlt")
}
if queryStringClause != maliciousQuery {
t.Fatalf("suchwert wurde verändert: %v", queryStringClause)
}
}
-180
View File
@@ -1,180 +0,0 @@
// SRC-05: Facetten- & Filter-API. Nutzt Manticores strukturierte
// Aggregations-API (aggs.terms/aggs.range) — keine dynamische
// SQL-Klauselbildung, dieselbe Konvention wie Search/Delete (fields.go).
package search
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// FacetFilter schränkt Suche/Facettenberechnung auf einen bereits
// gewählten Facettenwert ein. Field MUSS aus FacetFields stammen —
// Facets liefert einen Fehler bei jedem anderen Wert (verhindert einen
// beliebigen, vom Aufrufer bestimmten Feldnamen in der Anfrage).
type FacetFilter struct {
Field string
Value string
}
// FacetValue ist ein einzelner Facettenwert mit Trefferzahl
// (Akzeptanzkriterium 1).
type FacetValue struct {
Value string
Count int64
}
// DateRangeFacet ist ein Zeitraum-Bucket mit Trefferzahl.
type DateRangeFacet struct {
Label string
Count int64
}
// FacetResult fasst alle Facettendimensionen einer Anfrage zusammen.
type FacetResult struct {
// Values ist je FacetFields-Eintrag (sender/mailbox/attachment_type/tag)
// befüllt.
Values map[string][]FacetValue
// DateRanges sind feste Zeitraum-Buckets über FieldSentAt.
DateRanges []DateRangeFacet
}
// farFuture ist die obere Grenze des jüngsten Zeitraum-Buckets. Manticores
// range-Aggregation verlangt für jeden Bucket ein explizites "to" — ein
// hinreichend großer fester Wert (Jahr 2100) übernimmt die Rolle von
// "unbegrenzt in die Zukunft", ohne den Feldtyp zu wechseln.
const farFuture int64 = 4102444800
type dateRangeBoundary struct {
label string
from int64 // 0 = ab Epoch (unbegrenzt in die Vergangenheit)
to int64
}
// dateRangeBoundaries berechnet die festen Zeitraum-Buckets relativ zu now
// (Parameter statt time.Now() direkt, damit Facets testbar bleibt).
func dateRangeBoundaries(now time.Time) []dateRangeBoundary {
sevenDaysAgo := now.AddDate(0, 0, -7).Unix()
thirtyDaysAgo := now.AddDate(0, 0, -30).Unix()
oneYearAgo := now.AddDate(-1, 0, 0).Unix()
return []dateRangeBoundary{
{label: "letzte_7_tage", from: sevenDaysAgo, to: farFuture},
{label: "letzte_30_tage", from: thirtyDaysAgo, to: sevenDaysAgo},
{label: "letztes_jahr", from: oneYearAgo, to: thirtyDaysAgo},
{label: "aelter", from: 0, to: oneYearAgo},
}
}
func isFacetField(field string) bool {
for _, f := range FacetFields {
if f == field {
return true
}
}
return false
}
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
// wie der Tenant-Filter).
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
must := []map[string]any{
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
}
if queryText != "" {
must = append(must, map[string]any{"query_string": queryText})
}
for _, f := range filters {
if !isFacetField(f.Field) {
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
}
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
}
aggs := map[string]any{}
for _, field := range FacetFields {
aggs[field] = map[string]any{"terms": map[string]any{"field": field, "size": 100}}
}
boundaries := dateRangeBoundaries(time.Now())
ranges := make([]map[string]any, 0, len(boundaries))
for _, b := range boundaries {
ranges = append(ranges, map[string]any{"from": b.from, "to": b.to})
}
aggs["sent_at"] = map[string]any{"range": map[string]any{"field": FieldSentAt, "ranges": ranges}}
payload := map[string]any{
"index": IndexName,
"query": map[string]any{"bool": map[string]any{"must": must}},
"aggs": aggs,
"limit": 0,
}
body, err := json.Marshal(payload)
if err != nil {
return FacetResult{}, fmt.Errorf("search: facettenanfrage serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
if err != nil {
return FacetResult{}, fmt.Errorf("search: facettenanfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return FacetResult{}, fmt.Errorf("search: facetten abrufen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return FacetResult{}, fmt.Errorf("search: facetten-antwort lesen: %w", err)
}
if resp.StatusCode != http.StatusOK {
return FacetResult{}, fmt.Errorf("search: facetten, status %d: %s", resp.StatusCode, string(respBody))
}
var parsed facetResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return FacetResult{}, fmt.Errorf("search: facetten-antwort parsen: %w", err)
}
result := FacetResult{Values: make(map[string][]FacetValue, len(FacetFields))}
for _, field := range FacetFields {
bucket := parsed.Aggregations[field]
values := make([]FacetValue, 0, len(bucket.Buckets))
for _, b := range bucket.Buckets {
if b.Key == "" {
continue
}
values = append(values, FacetValue{Value: b.Key, Count: b.DocCount})
}
result.Values[field] = values
}
sentAtBucket := parsed.Aggregations["sent_at"]
result.DateRanges = make([]DateRangeFacet, 0, len(boundaries))
for i, b := range boundaries {
count := int64(0)
if i < len(sentAtBucket.Buckets) {
count = sentAtBucket.Buckets[i].DocCount
}
result.DateRanges = append(result.DateRanges, DateRangeFacet{Label: b.label, Count: count})
}
return result, nil
}
type facetResponse struct {
Aggregations map[string]struct {
Buckets []struct {
Key string `json:"key"`
DocCount int64 `json:"doc_count"`
} `json:"buckets"`
} `json:"aggregations"`
}
-107
View File
@@ -1,107 +0,0 @@
// Integrationstest (SRC-05): echte Manticore-Instanz, TEST_MANTICORE_URL
// (gleiche Konvention wie integration_test.go/ranking_test.go).
package search
import (
"context"
"testing"
)
func indexFacetDoc(t *testing.T, client *Client, ctx context.Context, tenant string, doc Document) {
t.Helper()
doc.TenantSlug = tenant
doc.ID = DocumentID(tenant, doc.MessageID)
if err := client.Index(ctx, doc); err != nil {
t.Fatalf("index %s: %v", doc.MessageID, err)
}
}
// TestFacets_CountsMatchActualHits ist die geforderte Pflichtprüfung 1:
// Facettenzahlen stimmen mit tatsächlicher Treffermenge überein.
func TestFacets_CountsMatchActualHits(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src05-zahlen"
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-1", Subject: "a", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-2", Subject: "b", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-3", Subject: "c", Sender: "bob@example.com", Mailbox: "archiv", AttachmentType: "pdf"})
result, err := client.Facets(ctx, tenant, "", nil)
if err != nil {
t.Fatalf("facets: %v", err)
}
senderCounts := toCountMap(result.Values[FieldSender])
if senderCounts["alice@example.com"] != 2 {
t.Fatalf("erwartete 2 treffer für alice@example.com, habe %d (%+v)", senderCounts["alice@example.com"], result.Values[FieldSender])
}
if senderCounts["bob@example.com"] != 1 {
t.Fatalf("erwartete 1 treffer für bob@example.com, habe %d", senderCounts["bob@example.com"])
}
attachmentCounts := toCountMap(result.Values[FieldAttachmentType])
if attachmentCounts["pdf"] != 2 {
t.Fatalf("erwartete 2 treffer für pdf, habe %d", attachmentCounts["pdf"])
}
}
// TestFacets_ThreeFiltersCombineWithAND ist die geforderte Pflichtprüfung
// 2: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer.
func TestFacets_ThreeFiltersCombineWithAND(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src05-kombi"
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-treffer", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-sender", Subject: "x", Sender: "bob@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falsche-mailbox", Subject: "x", Sender: "alice@example.com", Mailbox: "archiv", AttachmentType: "pdf", Tag: "wichtig"})
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-typ", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx", Tag: "wichtig"})
filters := []FacetFilter{
{Field: FieldSender, Value: "alice@example.com"},
{Field: FieldMailbox, Value: "inbox"},
{Field: FieldAttachmentType, Value: "pdf"},
}
result, err := client.Facets(ctx, tenant, "", filters)
if err != nil {
t.Fatalf("facets: %v", err)
}
tagCounts := toCountMap(result.Values[FieldTag])
if tagCounts["wichtig"] != 1 {
t.Fatalf("erwartete genau 1 verbleibenden treffer nach 3 UND-verknüpften filtern, habe %d (%+v)", tagCounts["wichtig"], result.Values[FieldTag])
}
}
// TestFacets_TenantSeparation ist die geforderte Pflichtprüfung 3:
// Facetten eines Mandanten enthalten keine Werte eines anderen.
func TestFacets_TenantSeparation(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenantA := "mandant-src05-facetten-a"
tenantB := "mandant-src05-facetten-b"
indexFacetDoc(t, client, ctx, tenantA, Document{MessageID: "msg-fa-1", Subject: "a", Sender: "nur-a@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
indexFacetDoc(t, client, ctx, tenantB, Document{MessageID: "msg-fb-1", Subject: "b", Sender: "nur-b@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
resultB, err := client.Facets(ctx, tenantB, "", nil)
if err != nil {
t.Fatalf("facets mandant b: %v", err)
}
senderCountsB := toCountMap(resultB.Values[FieldSender])
if _, present := senderCountsB["nur-a@example.com"]; present {
t.Fatalf("mandant b sieht facettenwert von mandant a: %+v", resultB.Values[FieldSender])
}
if senderCountsB["nur-b@example.com"] != 1 {
t.Fatalf("erwartete eigenen facettenwert bei mandant b, habe: %+v", resultB.Values[FieldSender])
}
}
func toCountMap(values []FacetValue) map[string]int64 {
m := make(map[string]int64, len(values))
for _, v := range values {
m[v.Value] = v.Count
}
return m
}
-53
View File
@@ -1,53 +0,0 @@
// Package search implementiert SRC-01: den Manticore-RT-Suchindex für
// Mail-Inhalte. Bekannter Fehler vermeiden (siehe known-issues-archivmail.md
// #11/#12): archivmail baute WHERE-Klauseln und teils Spalten-/Tabellennamen
// dynamisch über fmt.Sprintf/strings.Join zusammen. Für dieses Paket gilt
// verbindlich: Spalten- und Tabellennamen kommen AUSSCHLIESSLICH aus den
// Konstanten dieser Datei, nirgendwo sonst im Paket wird ein Feld- oder
// Tabellenname zur Laufzeit zusammengesetzt. Suchanfragen laufen über die
// strukturierte Manticore-HTTP-JSON-API (Query/Insert-Sub, kein
// String-Zusammenbau von SQL), nicht über die SQL-Schnittstelle.
package search
import "hash/fnv"
// IndexName ist der einzige Ort, an dem der Manticore-Indexname als
// Literal steht.
const IndexName = "mail_documents"
// Statische Feld-Whitelist des mail_documents-Index (muss deckungsgleich
// mit migrations/0001_mail_documents.sql bleiben).
const (
FieldTenantSlug = "tenant_slug"
FieldMessageID = "message_id"
FieldSubject = "subject"
FieldBody = "body"
FieldAttachmentText = "attachment_text"
FieldSentAt = "sent_at"
// Facettenfelder (SRC-05), nachgezogen über migrations/0002..0005.
FieldSender = "sender"
FieldMailbox = "mailbox"
FieldAttachmentType = "attachment_type"
FieldTag = "tag"
)
// FacetFields sind die je Kachel unterstützten Filterdimensionen
// (Akzeptanzkriterium 1: Absender, Postfach, Anhangstyp, Tag — Zeitraum
// läuft separat über FieldSentAt als Bereichsfacette, siehe facets.go).
// Statische Liste — Aufrufer können ausschließlich diese Feldnamen als
// Facetten-/Filterdimension angeben, kein beliebiger Client-Feldname.
var FacetFields = []string{FieldSender, FieldMailbox, FieldAttachmentType, FieldTag}
// DocumentID berechnet deterministisch die Manticore-Dokument-ID aus
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
// separat vergebenen ID, damit Re-Indexierung (Index) und Löschung
// (Delete) für dieselbe Mail immer dieselbe Dokument-ID referenzieren,
// ohne dass der Aufrufer sie zwischenspeichern muss (SRC-02: Löschungen
// müssen ohne zusätzlichen Zustand nachgezogen werden können).
func DocumentID(tenantSlug, messageID string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(tenantSlug))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(messageID))
return h.Sum64()
}
-98
View File
@@ -1,98 +0,0 @@
// Integrationstest (SRC-01): echte Manticore-Instanz auf dem Testhost.
// Folgt derselben TEST_*-Env-Konvention wie mail/internal/example und
// mail/internal/dedup — TEST_MANTICORE_URL.
package search
import (
"context"
"os"
"testing"
)
func setupClient(t *testing.T) *Client {
t.Helper()
baseURL := os.Getenv("TEST_MANTICORE_URL")
if baseURL == "" {
t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
}
client := NewClient(baseURL)
if err := client.EnsureSchema(context.Background()); err != nil {
t.Fatalf("schema: %v", err)
}
return client
}
// TestSearch_FindsExpectedDocument ist die geforderte Pflichtprüfung 3:
// Funktionstest bestätigt, dass die Volltextsuche erwartete Treffer für
// einen Testkorpus liefert.
func TestSearch_FindsExpectedDocument(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src01-funktionstest"
if err := client.Index(ctx, Document{
ID: 910001,
TenantSlug: tenant,
MessageID: "msg-funktionstest-1",
Subject: "Quartalsbericht Q3",
Body: "Anbei der vollständige Quartalsbericht mit Umsatzzahlen.",
}); err != nil {
t.Fatalf("index: %v", err)
}
if err := client.Index(ctx, Document{
ID: 910002,
TenantSlug: tenant,
MessageID: "msg-funktionstest-2",
Subject: "Mittagessen morgen",
Body: "Wollen wir zusammen essen gehen?",
}); err != nil {
t.Fatalf("index: %v", err)
}
results, err := client.Search(ctx, tenant, "Quartalsbericht")
if err != nil {
t.Fatalf("search: %v", err)
}
found := false
for _, r := range results {
if r.MessageID == "msg-funktionstest-1" {
found = true
}
if r.MessageID == "msg-funktionstest-2" {
t.Fatal("unerwarteter treffer für nicht passende nachricht")
}
}
if !found {
t.Fatalf("erwarteten treffer msg-funktionstest-1 nicht gefunden, habe: %+v", results)
}
}
// TestSearch_TenantIsolation ist Akzeptanzkriterium 3 (mandantengetrennt
// abfragbar): identischer Inhalt bei zwei Mandanten, Suche bei Mandant A
// darf keinen Treffer bei Mandant B liefern.
func TestSearch_TenantIsolation(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenantA := "mandant-src01-iso-a"
tenantB := "mandant-src01-iso-b"
if err := client.Index(ctx, Document{
ID: 910101,
TenantSlug: tenantA,
MessageID: "msg-iso-a",
Subject: "Vertraulicher Betreff Alpha",
Body: "Inhalt nur für Mandant A.",
}); err != nil {
t.Fatalf("index mandant a: %v", err)
}
results, err := client.Search(ctx, tenantB, "Vertraulicher")
if err != nil {
t.Fatalf("search mandant b: %v", err)
}
for _, r := range results {
if r.MessageID == "msg-iso-a" {
t.Fatal("mandant b hat treffer aus mandant a gesehen — mandantentrennung verletzt")
}
}
}
@@ -1,8 +0,0 @@
CREATE TABLE IF NOT EXISTS mail_documents (
tenant_slug string attribute indexed,
message_id string attribute indexed,
subject text,
body text,
attachment_text text,
sent_at timestamp
)
@@ -1 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN sender string attribute indexed
@@ -1 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN mailbox string attribute indexed
@@ -1 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN attachment_type string attribute indexed
@@ -1 +0,0 @@
ALTER TABLE mail_documents ADD COLUMN tag string attribute indexed
@@ -1,37 +0,0 @@
package search
import (
"os"
"strings"
"testing"
)
// TestNoDynamicSQLClauseBuilding ist die geforderte Pflichtprüfung 1:
// Codereview bestätigt automatisiert, dass client.go keine Sprintf/Join-
// basierte SQL-Klauselbildung enthält (Bekannter Fehler #11/#12 aus
// known-issues-archivmail.md). Die einzige SQL-Anfrage des Pakets
// (EnsureSchema, /sql-Endpunkt) darf ausschließlich den statisch
// eingebetteten Migrationstext verwenden — kein fmt.Sprintf, kein
// String-Concat/Join zum Bau von SQL-Text.
func TestNoDynamicSQLClauseBuilding(t *testing.T) {
src, err := os.ReadFile("client.go")
if err != nil {
t.Fatal(err)
}
code := string(src)
if strings.Contains(code, "fmt.Sprintf") {
t.Fatal("client.go darf kein fmt.Sprintf verwenden (SQL-Klauselbildung verboten, siehe known-issues #11/#12)")
}
_, after, found := strings.Cut(code, `"/sql?mode=raw"`)
if !found {
t.Fatal("erwarteter /sql-Aufruf nicht gefunden")
}
window := after
if len(window) > 200 {
window = window[:200]
}
if strings.Contains(window, "+") {
t.Fatal("kein '+'-String-Zusammenbau in der Nähe des /sql-Aufrufs erlaubt")
}
}
-167
View File
@@ -1,167 +0,0 @@
// Integrationstest (SRC-03): echte Manticore-Instanz, TEST_MANTICORE_URL
// (gleiche Konvention wie integration_test.go).
package search
import (
"context"
"fmt"
"os"
"testing"
"time"
)
// TestSearch_RankingReflectsFieldWeightAndIsTraceable ist die geforderte
// Funktionsprüfung zu Akzeptanzkriterium 1: Treffer sind nach Relevanz
// sortiert, und das Ranking ist nachvollziehbar — ein Treffer im höher
// gewichteten Betrefffeld liegt vor einem Treffer, der den Suchbegriff
// nur im niedriger gewichteten Anhangstext enthält.
func TestSearch_RankingReflectsFieldWeightAndIsTraceable(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src03-ranking"
if err := client.Index(ctx, Document{
ID: DocumentID(tenant, "msg-ranking-subject"),
TenantSlug: tenant,
MessageID: "msg-ranking-subject",
Subject: "Vertragsentwurf",
Body: "siehe Anhang",
AttachmentText: "",
SentAtUnixEpoch: 1000,
}); err != nil {
t.Fatalf("index: %v", err)
}
if err := client.Index(ctx, Document{
ID: DocumentID(tenant, "msg-ranking-attachment"),
TenantSlug: tenant,
MessageID: "msg-ranking-attachment",
Subject: "Wochenrückblick",
Body: "allgemeine Notizen",
AttachmentText: "Im Anhang findet sich ein Vertragsentwurf zur Prüfung.",
SentAtUnixEpoch: 2000,
}); err != nil {
t.Fatalf("index: %v", err)
}
results, err := client.Search(ctx, tenant, "Vertragsentwurf")
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 2 {
t.Fatalf("erwartete 2 treffer, habe %d: %+v", len(results), results)
}
if results[0].MessageID != "msg-ranking-subject" {
t.Fatalf("erwartete höher gewichteten betreff-treffer zuerst, habe: %+v", results)
}
if results[0].Score <= results[1].Score {
t.Fatalf("erwartete nachvollziehbar höheren score für betreff-treffer: %+v", results)
}
}
// TestSearch_PhraseAndExclusionOperators ist die geforderte
// Pflichtprüfung 2: Phrasensuche und Ausschlussoperator liefern erwartete
// Teilmengen.
func TestSearch_PhraseAndExclusionOperators(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src03-operatoren"
docs := []Document{
{MessageID: "msg-op-umsatz-verlust", Subject: "Umsatz Verlust", Body: "Verlust im dritten Quartal, kein Umsatzwachstum"},
{MessageID: "msg-op-umsatz-nur", Subject: "Quartalsbericht Umsatz", Body: "hoher Umsatz im dritten Quartal"},
{MessageID: "msg-op-anderes-thema", Subject: "Betriebsausflug", Body: "Planung für den nächsten Betriebsausflug"},
}
for _, d := range docs {
d.TenantSlug = tenant
d.ID = DocumentID(tenant, d.MessageID)
if err := client.Index(ctx, d); err != nil {
t.Fatalf("index %s: %v", d.MessageID, err)
}
}
phraseResults, err := client.Search(ctx, tenant, `"dritten Quartal"`)
if err != nil {
t.Fatalf("phrasensuche: %v", err)
}
phraseIDs := messageIDSet(phraseResults)
if !phraseIDs["msg-op-umsatz-verlust"] || !phraseIDs["msg-op-umsatz-nur"] {
t.Fatalf("erwartete beide 'dritten Quartal'-treffer, habe: %+v", phraseResults)
}
if phraseIDs["msg-op-anderes-thema"] {
t.Fatalf("unerwarteter treffer ohne die phrase: %+v", phraseResults)
}
exclusionResults, err := client.Search(ctx, tenant, "Umsatz -Verlust")
if err != nil {
t.Fatalf("ausschlusssuche: %v", err)
}
exclusionIDs := messageIDSet(exclusionResults)
if !exclusionIDs["msg-op-umsatz-nur"] {
t.Fatalf("erwarteter treffer ohne 'Verlust' fehlt: %+v", exclusionResults)
}
if exclusionIDs["msg-op-umsatz-verlust"] {
t.Fatalf("mit -Verlust ausgeschlossener treffer erschien dennoch: %+v", exclusionResults)
}
}
func messageIDSet(results []Result) map[string]bool {
set := make(map[string]bool, len(results))
for _, r := range results {
set[r.MessageID] = true
}
return set
}
// TestSearch_PerformanceWithLargeCorpus ist die geforderte Pflichtprüfung
// 3: Performance-Test mit großem Testkorpus bleibt innerhalb Zielzeit.
// Zielzeit 500ms für eine Suche über 1000 indexierte Dokumente — großzügig
// für die "kleinste Lösung", deckt aber real ab, dass die Suche nicht
// linear mit der Korpusgröße spürbar einbricht.
func TestSearch_PerformanceWithLargeCorpus(t *testing.T) {
if os.Getenv("TEST_MANTICORE_URL") == "" {
t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
}
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src03-performance"
const corpusSize = 1000
for i := 0; i < corpusSize; i++ {
messageID := fmt.Sprintf("msg-perf-%d", i)
subject := "Alltägliche Nachricht"
if i == corpusSize/2 {
subject = "Einzigartiges Suchziel Zylotharion"
}
if err := client.Index(ctx, Document{
ID: DocumentID(tenant, messageID),
TenantSlug: tenant,
MessageID: messageID,
Subject: subject,
Body: "Routinetext ohne besonderen Inhalt für Testzwecke.",
SentAtUnixEpoch: int64(i),
}); err != nil {
t.Fatalf("index %s: %v", messageID, err)
}
}
const targetLatency = 500 * time.Millisecond
start := time.Now()
results, err := client.Search(ctx, tenant, "Zylotharion")
elapsed := time.Since(start)
if err != nil {
t.Fatalf("search: %v", err)
}
if elapsed > targetLatency {
t.Fatalf("suche über %d dokumente dauerte %s, ziel war %s", corpusSize, elapsed, targetLatency)
}
found := false
for _, r := range results {
if r.MessageID == fmt.Sprintf("msg-perf-%d", corpusSize/2) {
found = true
}
}
if !found {
t.Fatal("erwartetes einzigartiges dokument im großen korpus nicht gefunden")
}
t.Logf("Suche über %d Dokumente: %s (Ziel %s)", corpusSize, elapsed, targetLatency)
}
-288
View File
@@ -1,288 +0,0 @@
// SRC-09: Suchindex-Neuaufbau/Reindexierung. Baut eine neue physische
// Manticore-Tabelle auf, kopiert alle Dokumente aus der aktuell lebenden
// Tabelle (Konsistenzwiederherstellung), verifiziert die Trefferzahl und
// tauscht erst danach per Manticore RENAME atomar um — die alte Tabelle
// bleibt bis zu diesem Moment vollständig abfragbar (Akzeptanzkriterium 3),
// RENAME ist eine reine Metadaten-Operation ohne Suchausfall.
package search
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"time"
)
// tempTableNamePattern begrenzt generierte Zwischentabellennamen auf ein
// festes Präfix + Ziffern — auch wenn der Name ausschließlich von diesem
// Paket selbst erzeugt wird (kein externer Eingabewert erreicht ihn),
// erzwingt die Prüfung strukturell, dass niemals ein beliebiger String an
// dieser Stelle landen kann (Verteidigung in der Tiefe, gleiche Haltung
// wie das Feld-Whitelist-Prinzip in fields.go).
var tempTableNamePattern = regexp.MustCompile(`^mail_documents_reindex_[0-9]+$`)
// Progress meldet den Fortschritt eines laufenden Reindex
// (Akzeptanzkriterium 2: Fortschritt nachvollziehbar sichtbar).
type Progress struct {
Copied int64
Total int64
}
// Reindexer baut den Suchindex vollständig neu auf.
type Reindexer struct {
client *Client
}
func NewReindexer(client *Client) *Reindexer {
return &Reindexer{client: client}
}
// RebuildResult fasst das Ergebnis eines abgeschlossenen Reindex zusammen.
type RebuildResult struct {
OldCount int64
NewCount int64
}
// Rebuild baut den Index vollständig neu auf: neue Tabelle anlegen, alle
// Dokumente aus der aktuell lebenden Tabelle seitenweise kopieren
// (Akzeptanzkriterium 1: kein Datenverlust im laufenden Betrieb — die
// lebende Tabelle wird dabei nur gelesen, nie verändert), Trefferzahlen
// vergleichen, dann atomar per RENAME umschalten. Schlägt ein Schritt vor
// dem Umschalten fehl (z. B. abgebrochener Kontext), wird die
// Zwischentabelle entfernt und die lebende Tabelle bleibt unverändert
// (Akzeptanzkriterium 3 / Pflichtprüfung 2: kein inkonsistenter Zustand).
func (r *Reindexer) Rebuild(ctx context.Context, onProgress func(Progress)) (RebuildResult, error) {
tempTable := fmt.Sprintf("mail_documents_reindex_%d", time.Now().UnixNano())
if !tempTableNamePattern.MatchString(tempTable) {
return RebuildResult{}, fmt.Errorf("search: erzeugter zwischentabellenname unerwartet ungültig: %q", tempTable)
}
if err := r.client.runSchemaSQL(ctx, buildCreateTableSQL(tempTable)); err != nil {
return RebuildResult{}, fmt.Errorf("search: zwischentabelle anlegen: %w", err)
}
oldTotal, err := r.copyAll(ctx, IndexName, tempTable, onProgress)
if err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: dokumente kopieren: %w", err)
}
// Manticore macht frisch eingefügte Dokumente einer neu angelegten
// RT-Tabelle für Volltext-/match_all-Zählungen erst nach einem
// expliziten FLUSH RAMCHUNK zuverlässig sichtbar (beobachtet: SELECT
// über SQL sieht die Zeile sofort, /search match_all zählt sie ohne
// Flush als 0). Vor der Konsistenzprüfung zwingend nötig.
if err := r.client.runSchemaSQL(ctx, "FLUSH RAMCHUNK "+tempTable); err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: zwischentabelle flushen: %w", err)
}
newTotal, err := r.countAll(ctx, tempTable)
if err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: neue tabelle zählen: %w", err)
}
if newTotal != oldTotal {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: trefferzahlen weichen ab (alt %d, neu %d), kein umschalten", oldTotal, newTotal)
}
retiredTable := fmt.Sprintf("mail_documents_retired_%d", time.Now().UnixNano())
if err := r.client.runSchemaSQL(ctx, fmt.Sprintf("ALTER TABLE %s RENAME %s", IndexName, retiredTable)); err != nil {
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: alte tabelle umbenennen: %w", err)
}
if err := r.client.runSchemaSQL(ctx, fmt.Sprintf("ALTER TABLE %s RENAME %s", tempTable, IndexName)); err != nil {
// Kritischer Zustand: alte Tabelle bereits umbenannt, neue kann
// nicht einspringen. Umschalten rückgängig machen, statt ohne
// abfragbaren Index dazustehen.
_ = r.client.runSchemaSQL(context.Background(), fmt.Sprintf("ALTER TABLE %s RENAME %s", retiredTable, IndexName))
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
return RebuildResult{}, fmt.Errorf("search: neue tabelle aktivieren: %w", err)
}
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+retiredTable)
return RebuildResult{OldCount: oldTotal, NewCount: newTotal}, nil
}
const copyPageSize = 200
func (r *Reindexer) copyAll(ctx context.Context, sourceIndex, targetIndex string, onProgress func(Progress)) (int64, error) {
total, err := r.countAll(ctx, sourceIndex)
if err != nil {
return 0, err
}
var cursor uint64
var copied int64
first := true
for {
if err := ctx.Err(); err != nil {
return 0, err
}
page, err := r.fetchPage(ctx, sourceIndex, cursor, first)
if err != nil {
return 0, err
}
first = false
if len(page) == 0 {
break
}
for _, doc := range page {
if err := r.putRaw(ctx, targetIndex, doc); err != nil {
return 0, err
}
cursor = doc.ID
copied++
}
if onProgress != nil {
onProgress(Progress{Copied: copied, Total: total})
}
}
return copied, nil
}
func (r *Reindexer) countAll(ctx context.Context, index string) (int64, error) {
payload := map[string]any{"index": index, "query": map[string]any{"match_all": map[string]any{}}, "limit": 0}
var parsed struct {
Hits struct {
Total int64 `json:"total"`
} `json:"hits"`
}
if err := r.client.postJSON(ctx, "/search", payload, &parsed); err != nil {
return 0, err
}
return parsed.Hits.Total, nil
}
type scrollHit struct {
ID uint64 `json:"_id"`
Source json.RawMessage `json:"_source"`
}
func (r *Reindexer) fetchPage(ctx context.Context, index string, afterID uint64, first bool) ([]scrollHit, error) {
must := []map[string]any{}
if !first {
must = append(must, map[string]any{"range": map[string]any{"id": map[string]any{"gt": afterID}}})
}
query := map[string]any{"match_all": map[string]any{}}
if len(must) > 0 {
query = map[string]any{"bool": map[string]any{"must": must}}
}
payload := map[string]any{
"index": index,
"query": query,
"sort": []map[string]any{{"id": "asc"}},
"limit": copyPageSize,
}
var parsed struct {
Hits struct {
Hits []scrollHit `json:"hits"`
} `json:"hits"`
}
if err := r.client.postJSON(ctx, "/search", payload, &parsed); err != nil {
return nil, err
}
return parsed.Hits.Hits, nil
}
func (r *Reindexer) putRaw(ctx context.Context, index string, doc scrollHit) error {
payload := map[string]any{
"index": index,
"id": doc.ID,
"doc": json.RawMessage(doc.Source),
}
return r.client.postJSON(ctx, "/replace", payload, nil)
}
// postJSON/buildCreateTableSQL sind bewusst hier statt in client.go
// angesiedelt: der übrige Suchpfad (Search/Facets) fasst niemals einen
// Tabellennamen dynamisch an, Reindex ist die einzige Stelle im Paket, die
// das operativ tun muss.
func (c *Client) postJSON(ctx context.Context, path string, payload any, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("payload serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("ausführen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("antwort lesen: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}
if out == nil {
return nil
}
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("antwort parsen: %w", err)
}
return nil
}
// showTables listet die vorhandenen Manticore-Tabellen (Diagnose-/
// Testhilfe, um verwaiste Zwischentabellen nach einem Abbruch
// auszuschließen — Pflichtprüfung 2).
func (c *Client) showTables(ctx context.Context) ([]string, error) {
form := "query=SHOW TABLES"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", bytes.NewReader([]byte(form)))
if err != nil {
return nil, fmt.Errorf("anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("ausführen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("antwort lesen: %w", err)
}
var parsed []struct {
Data []struct {
Table string `json:"Table"`
} `json:"data"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("antwort parsen: %w", err)
}
names := []string{}
if len(parsed) > 0 {
for _, row := range parsed[0].Data {
names = append(names, row.Table)
}
}
return names, nil
}
// buildCreateTableSQL erzeugt die Schema-DDL für eine Zwischentabelle mit
// demselben Spaltensatz wie mail_documents (Basis + Facettenfelder aus
// SRC-05). tableName ist über tempTableNamePattern in Rebuild bereits
// geprüft, bevor diese Funktion aufgerufen wird.
func buildCreateTableSQL(tableName string) string {
return fmt.Sprintf(
"CREATE TABLE %s (%s string attribute indexed, %s string attribute indexed, %s text, %s text, %s text, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s timestamp)",
tableName,
FieldTenantSlug, FieldMessageID, FieldSubject, FieldBody, FieldAttachmentText,
FieldSender, FieldMailbox, FieldAttachmentType, FieldTag, FieldSentAt,
)
}
-215
View File
@@ -1,215 +0,0 @@
// Integrationstest (SRC-09): echte Manticore-Instanz, TEST_MANTICORE_URL
// (gleiche Konvention wie integration_test.go/ranking_test.go/facets_test.go).
package search
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestRebuild_SearchKeepsWorkingDuringReindex ist die geforderte
// Pflichtprüfung 1: Reindex während laufender Suchanfragen unterbricht die
// Suche nicht.
func TestRebuild_SearchKeepsWorkingDuringReindex(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-parallel"
for i := 0; i < 30; i++ {
messageID := "msg-parallel-" + string(rune('a'+i))
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: messageID, Subject: "Zwiebelfisch " + messageID, Body: "Text"})
}
stop := make(chan struct{})
var searchErrors int64
var searchesDone int64
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if _, err := client.Search(ctx, tenant, "Zwiebelfisch"); err != nil {
atomic.AddInt64(&searchErrors, 1)
t.Logf("suchfehler während reindex: %v", err)
}
atomic.AddInt64(&searchesDone, 1)
time.Sleep(2 * time.Millisecond)
}
}()
reindexer := NewReindexer(client)
result, err := reindexer.Rebuild(ctx, nil)
close(stop)
wg.Wait()
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if result.OldCount != result.NewCount {
t.Fatalf("erwartete gleiche trefferzahlen, habe alt=%d neu=%d", result.OldCount, result.NewCount)
}
if atomic.LoadInt64(&searchesDone) == 0 {
t.Fatal("keine einzige parallele suche ausgeführt — test aussagelos")
}
if errs := atomic.LoadInt64(&searchErrors); errs != 0 {
t.Fatalf("erwartete 0 fehlgeschlagene suchen während des reindex, habe %d von %d", errs, atomic.LoadInt64(&searchesDone))
}
// Suche funktioniert auch NACH dem Umschalten weiterhin real.
afterResults, err := client.Search(ctx, tenant, "Zwiebelfisch")
if err != nil {
t.Fatalf("search nach reindex: %v", err)
}
if len(afterResults) != 30 {
t.Fatalf("erwartete 30 treffer nach reindex, habe %d", len(afterResults))
}
}
// TestRebuild_AbortedReindexLeavesNoInconsistentState ist die geforderte
// Pflichtprüfung 2: abgebrochener Reindex hinterlässt keinen
// inkonsistenten Zustand.
func TestRebuild_AbortedReindexLeavesNoInconsistentState(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-abbruch"
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-abbruch-1", Subject: "Vertragsentwurf Abbruchtest", Body: "Text"})
before, err := client.Search(ctx, tenant, "Abbruchtest")
if err != nil || len(before) != 1 {
t.Fatalf("voraussetzung nicht erfüllt: %v / %d treffer", err, len(before))
}
cancelCtx, cancel := context.WithCancel(ctx)
cancel() // sofort abgebrochen, simuliert Absturz/Abbruch mitten im Kopiervorgang
reindexer := NewReindexer(client)
_, err = reindexer.Rebuild(cancelCtx, nil)
if err == nil {
t.Fatal("erwartete fehler bei abgebrochenem kontext, habe nil")
}
if !errors.Is(err, context.Canceled) {
// Manticore-Fehler durch den abgebrochenen Request sind ebenfalls
// akzeptabel, solange überhaupt ein Fehler zurückkommt.
t.Logf("fehler war nicht context.Canceled, sondern: %v (akzeptiert, solange real ein fehler zurückkommt)", err)
}
// Die lebende Tabelle muss trotz Abbruch unverändert und abfragbar sein.
after, err := client.Search(ctx, tenant, "Abbruchtest")
if err != nil {
t.Fatalf("search nach abgebrochenem reindex: %v", err)
}
if len(after) != 1 {
t.Fatalf("erwartete weiterhin 1 treffer nach abgebrochenem reindex, habe %d — inkonsistenter zustand", len(after))
}
// Keine verwaisten Zwischentabellen (kein inkonsistenter Zustand auf
// Manticore-Ebene): kurz warten, damit ein eventuell noch laufender
// CREATE-TABLE-Aufruf durchlaufen kann, dann prüfen, dass keine
// mail_documents_reindex_*-Tabelle übrig geblieben ist.
time.Sleep(200 * time.Millisecond)
orphaned := listOrphanedReindexTables(t, client)
if len(orphaned) > 0 {
t.Fatalf("verwaiste zwischentabellen nach abbruch gefunden: %v", orphaned)
}
}
func listOrphanedReindexTables(t *testing.T, client *Client) []string {
t.Helper()
// SHOW TABLES ist eine feste, unparametrisierte Anweisung ohne
// jeglichen Laufzeitwert.
rows, err := client.showTables(context.Background())
if err != nil {
t.Fatalf("show tables: %v", err)
}
names := []string{}
for _, table := range rows {
if tempTableNamePattern.MatchString(table) {
names = append(names, table)
}
}
return names
}
// TestRebuild_SampleComparisonMatchesOldAndNewIndex ist die geforderte
// Pflichtprüfung 3: Stichprobenvergleich Alt-/Neuindex bestätigt gleiche
// Trefferzahlen.
func TestRebuild_SampleComparisonMatchesOldAndNewIndex(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-stichprobe"
subjects := []string{"Quartalsbericht", "Personalplanung", "Urlaubsantrag"}
for i, s := range subjects {
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-sp-" + string(rune('a'+i)), Subject: s, Body: "Inhalt " + s})
}
beforeCounts := map[string]int{}
for _, s := range subjects {
results, err := client.Search(ctx, tenant, s)
if err != nil {
t.Fatalf("search vor reindex (%s): %v", s, err)
}
beforeCounts[s] = len(results)
}
reindexer := NewReindexer(client)
if _, err := reindexer.Rebuild(ctx, nil); err != nil {
t.Fatalf("rebuild: %v", err)
}
for _, s := range subjects {
results, err := client.Search(ctx, tenant, s)
if err != nil {
t.Fatalf("search nach reindex (%s): %v", s, err)
}
if len(results) != beforeCounts[s] {
t.Fatalf("stichprobe %q: vor reindex %d treffer, nach reindex %d treffer", s, beforeCounts[s], len(results))
}
}
}
// TestRebuild_ReportsProgress deckt Akzeptanzkriterium 2 ab (Fortschritt
// nachvollziehbar sichtbar).
func TestRebuild_ReportsProgress(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-fortschritt"
for i := 0; i < 5; i++ {
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-progress-" + string(rune('a'+i)), Subject: "x"})
}
var updates []Progress
var mu sync.Mutex
reindexer := NewReindexer(client)
_, err := reindexer.Rebuild(ctx, func(p Progress) {
mu.Lock()
updates = append(updates, p)
mu.Unlock()
})
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if len(updates) == 0 {
t.Fatal("erwartete mindestens eine fortschrittsmeldung")
}
last := updates[len(updates)-1]
if last.Copied < last.Total {
// total ist eine zu Beginn eingefrorene Momentaufnahme; die geteilte
// Manticore-Instanz kann während des Kopierens durch andere Tests
// weiter wachsen (real beobachtet) — copied darf total daher
// erreichen oder minimal überschreiten, nur ein Rückstand wäre ein
// echter Fehler.
t.Fatalf("letzte fortschrittsmeldung sollte abgeschlossen sein, habe copied=%d total=%d", last.Copied, last.Total)
}
}
-49
View File
@@ -1,49 +0,0 @@
// Package storage implementiert ARC-01: die Objekt-Speicher-Anbindung
// für archivierte Mails und Anhänge. Baut auf demselben bewährten
// Muster wie DMS FDN-03 auf (austauschbare Driver, LocalDriver für
// Entwicklung, S3Driver für Produktion) — Mail kann DMS' internal/
// nicht importieren (eigenständiges Go-Modul), daher eine bewusste,
// angepasste Neuimplementierung statt eines Cross-Modul-Imports.
//
// Erweiterung gegenüber FDN-03: ARC-01 verlangt Prüfsummenverifikation
// AN DIESER SCHICHT (Akzeptanzkriterium 2), nicht erst an einer
// späteren DB-Schicht — siehe service.go.
package storage
import (
"context"
"errors"
"io"
"strconv"
)
// ErrNotFound wird geliefert, wenn ein angefragtes Objekt nicht
// existiert.
var ErrNotFound = errors.New("storage: objekt nicht gefunden")
// Driver ist die EINE Schnittstelle, gegen die der Rest von Mail
// arbeitet (Akzeptanzkriterium 1). Zwei Implementierungen: LocalDriver
// (Entwicklung) und S3Driver (Produktion, S3-kompatibel).
type Driver interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (int64, error)
Get(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
}
// ObjectKey liefert das feste, dokumentierte Pfadschema für einen
// Mail-Anhang/-Teil INNERHALB des bereits mandantenspezifischen
// Buckets (Akzeptanzkriterium 1) — Bucket-Trennung selbst ist Sache
// von Core TEN-01. Lesezugriff hängt NUR von messageID+partIndex ab,
// nicht vom ursprünglichen Importpfad (IMAP/SMTP/manueller Import —
// Akzeptanzkriterium 3): derselbe Key wird unabhängig davon berechnet,
// über welchen Weg die Nachricht ins System kam.
func ObjectKey(messageID string, partIndex int) string {
return "messages/" + messageID + "/parts/" + strconv.Itoa(partIndex)
}
// checksumKey ist der Sidecar-Objektschlüssel für die beim Schreiben
// berechnete Prüfsumme (siehe service.go) — liegt bewusst im selben
// Driver/Bucket wie der Inhalt, keine separate DB-Abhängigkeit nötig.
func checksumKey(key string) string {
return key + ".sha256"
}
-63
View File
@@ -1,63 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
)
// LocalDriver legt Objekte im lokalen Dateisystem ab — der
// Entwicklungs-Treiber (Akzeptanzkriterium 1), keine externe
// Abhängigkeit nötig.
type LocalDriver struct {
baseDir string
}
func NewLocalDriver(baseDir string) *LocalDriver {
return &LocalDriver{baseDir: baseDir}
}
func (d *LocalDriver) path(key string) string {
return filepath.Join(d.baseDir, filepath.FromSlash(key))
}
func (d *LocalDriver) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) (int64, error) {
full := d.path(key)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return 0, fmt.Errorf("storage: verzeichnis anlegen: %w", err)
}
f, err := os.Create(full)
if err != nil {
return 0, fmt.Errorf("storage: datei anlegen: %w", err)
}
defer func() { _ = f.Close() }()
written, err := io.Copy(f, r)
if err != nil {
return 0, fmt.Errorf("storage: schreiben: %w", err)
}
return written, nil
}
func (d *LocalDriver) Get(_ context.Context, key string) (io.ReadCloser, error) {
f, err := os.Open(d.path(key))
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("storage: lesen: %w", err)
}
return f, nil
}
func (d *LocalDriver) Delete(_ context.Context, key string) error {
if err := os.Remove(d.path(key)); err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return fmt.Errorf("storage: löschen: %w", err)
}
return nil
}
-110
View File
@@ -1,110 +0,0 @@
package storage
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
// S3Driver legt Objekte in einem S3-kompatiblen Objektspeicher ab — der
// Produktions-Treiber (Akzeptanzkriterium 1). Funktioniert gegen echtes
// AWS S3 UND gegen jeden S3-kompatiblen Anbieter (MinIO etc.) über
// endpointURL. Gleiches, bewährtes Muster wie DMS FDN-03s S3Driver
// (bewusste Kopie, Mail kann DMS nicht importieren).
type S3Driver struct {
client *s3.Client
bucket string
}
func NewS3Driver(ctx context.Context, bucket, region, endpointURL, accessKeyID, secretAccessKey string, usePathStyle bool) (*S3Driver, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")),
)
if err != nil {
return nil, fmt.Errorf("storage: s3-konfiguration laden: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if endpointURL != "" {
o.BaseEndpoint = aws.String(endpointURL)
}
o.UsePathStyle = usePathStyle
})
return &S3Driver{client: client, bucket: bucket}, nil
}
func (d *S3Driver) Put(ctx context.Context, key string, r io.Reader, _ int64, contentType string) (int64, error) {
buf, err := io.ReadAll(r)
if err != nil {
return 0, fmt.Errorf("storage: objekt vor upload lesen: %w", err)
}
_, err = d.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
Body: bytes.NewReader(buf),
ContentLength: aws.Int64(int64(len(buf))),
ContentType: aws.String(contentType),
})
if err != nil {
return 0, fmt.Errorf("storage: s3-upload: %w", err)
}
return int64(len(buf)), nil
}
func (d *S3Driver) Get(ctx context.Context, key string) (io.ReadCloser, error) {
out, err := d.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFound(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("storage: s3-download: %w", err)
}
return out.Body, nil
}
func (d *S3Driver) Delete(ctx context.Context, key string) error {
// S3 liefert bei DeleteObject fuer ein nicht existierendes Objekt
// KEINEN Fehler (idempotente S3-API-Semantik) — um denselben
// Vertrag wie LocalDriver (ErrNotFound bei fehlendem Objekt) zu
// erfüllen, wird die Existenz vorher explizit geprüft.
_, err := d.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(d.bucket), Key: aws.String(key)})
if err != nil {
if isS3NotFound(err) {
return ErrNotFound
}
return fmt.Errorf("storage: s3-existenzprüfung vor löschen: %w", err)
}
if _, err := d.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
}); err != nil {
return fmt.Errorf("storage: s3-löschen: %w", err)
}
return nil
}
func isS3NotFound(err error) bool {
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound" {
return true
}
return false
}

Some files were not shown because too many files have changed in this diff Show More