Files
archivmail/features/PROJ-55-fix-auditor-tenant-isolation.md
T
sysops ce197a3ab7 docs(PROJ-55): Status auf Deployed setzen
Erfolgreich auf 192.168.1.132 (Test) und 192.168.1.131 (Produktion)
deployed, Live-Verifikation bestanden.
2026-06-22 00:13:23 +02:00

320 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PROJ-55: Fix Tenant-Isolation für Rolle "auditor" + Audit-Log (Sicherheitsbug, DSGVO-relevant)
## Status: Deployed
**Created:** 2026-06-21
**Last Updated:** 2026-06-22
## Dependencies
- PROJ-6 (Volltext-Suche & Filterung)
- PROJ-21 (Multi-Mandanten-Fähigkeit)
- PROJ-11 (Audit-Log & Compliance-Berichte)
## Hintergrund / Bug (Nutzermeldung)
Ein Account mit Rolle `auditor` (Tenant "homelocal") sieht in der Mail-Suche Mails anderer Tenants. Zusätzlich sehen Tenant-Admins (`domain_admin`) im Audit-Log Einträge anderer Tenants. Bei einer SaaS-Freigabe wäre das ein DSGVO-Verstoß (fehlende Mandantentrennung).
### Bug 1 — Mail-Suche (`internal/api/search_handlers.go`)
`auditor` ist aktuell als **globaler** Auditor designed: nutzt immer den globalen Index (Zeile ~111), die Fallback-Tenant-Filterung schließt `RoleAuditor` explizit aus (Zeile ~125: `&& sess.Role != userstore.RoleAuditor`). Per Kommentar soll `auditor` nur tenant-lose Mails sehen — das wird aber nicht durchgesetzt, wenn Mails fälschlich ohne Tenant-Zuordnung im globalen Index liegen, oder wenn `auditor` faktisch pro Tenant genutzt wird (hat ein `tenant_id`-Feld, das ignoriert wird).
### Bug 2 — Audit-Log (`internal/api/dashboard_handlers.go`, `internal/audit/audit.go`)
`handleAuditLog` filtert nicht nach Tenant. `audit.QueryFilter` hat kein Tenant-Feld, `buildWhere()` entsprechend auch nicht. Jeder mit Zugriff auf `/api/audit` sieht Log-Einträge aller Tenants.
## Entscheidung (Nutzer, 2026-06-21)
- **Auditor wird strikt pro Tenant gescoped** (nicht mehr global). `auditor` sieht künftig nur Mails/Logs des eigenen `tenant_id` — analog zu `domain_admin`/`domain_auditor`.
- **superadmin bleibt unverändert global** (sieht weiterhin alle Tenants, nötig für Betrieb/Support).
- `domain_admin`/`domain_auditor` werden im Audit-Log ebenfalls auf den eigenen Tenant beschränkt (waren es vorher nicht).
## Acceptance Criteria
- [ ] Rolle `auditor` mit gesetztem `tenant_id`: Mail-Suche (`/api/search`) liefert ausschließlich Mails des eigenen Tenants (Per-Tenant-Index oder äquivalente serverseitige Filterung vor Pagination, analog PROJ-54).
- [ ] Rolle `auditor` mit gesetztem `tenant_id`: `handleGetMail`/`handleGetRaw`/`handleGetAttachment`/Export/eDiscovery/Threads/OCR verweigern Zugriff auf Mails anderer Tenants (403).
- [ ] `/api/audit` (Audit-Log): für `domain_admin`, `domain_auditor`, `auditor` mit `tenant_id` werden nur Log-Einträge des eigenen Tenants zurückgegeben.
- [ ] `superadmin` ist von beiden Einschränkungen unberührt — sieht weiterhin alle Tenants/Logs.
- [ ] Bestehende Pro-Tenant-Filterung für `domain_admin`/`domain_auditor`/`user` bleibt unverändert funktionsfähig (keine Regression).
- [ ] Falls ein `auditor`-Account KEINEN `tenant_id` gesetzt hat (Altbestand / bewusst globaler Auditor): Verhalten bleibt wie bisher (globaler Zugriff) — kein impliziter Zugriffsverlust für bestehende globale Auditor-Accounts ohne Tenant-Zuordnung. Klar dokumentieren.
## Edge Cases
- `auditor` ohne `tenant_id` → weiterhin globaler Zugriff (Abwärtskompatibilität, siehe AC oben).
- `auditor` mit `tenant_id`, aber Mails ohne Tenant-Zuordnung im Index (Altbestand vor PROJ-21) → diese Mails sind für tenant-gescopte Auditoren NICHT sichtbar (korrekt, da nicht ihrem Tenant zugeordnet).
- Audit-Log-Einträge, die vor Einführung von Multi-Tenancy (PROJ-21) ohne Tenant-Bezug geschrieben wurden → für tenant-gescopte Rollen nicht sichtbar; nur `superadmin` sieht sie weiterhin.
## Technical Requirements (optional)
- Keine neue Migration nötig (`tenant_id` existiert bereits auf `users`, Audit-Log-Einträge müssten ggf. um Tenant-Bezug ergänzt werden — prüfen ob `audit_log`-Tabelle bereits ein Tenant-Feld hat).
---
## Tech Design (Solution Architect)
_Übersprungen auf Wunsch des Nutzers — direkte Umsetzung (kritischer Sicherheitsbugfix)._
Fix-Plan für Backend Developer:
1. `internal/api/search_handlers.go`: `auditor` mit gesetztem `tenant_id` wie `domain_auditor`/`domain_admin` behandeln (Per-Tenant-Index nutzen bzw. Fallback-Filter NICHT mehr explizit ausschließen). `auditor` ohne `tenant_id` weiterhin global.
2. Dieselbe Logik für `handleGetMail`/`handleGetRaw`/`handleGetAttachment` und alle Stellen, die `mailBelongsToUser`/Tenant-Checks für Auditoren machen (export.go, ediscovery.go, thread_handlers.go, ocr_handlers.go).
3. `internal/audit/audit.go`: `QueryFilter` um `TenantID *int64` erweitern, `buildWhere()` entsprechend erweitern.
4. `internal/api/dashboard_handlers.go` (`handleAuditLog`): für Rollen `domain_admin`, `domain_auditor`, `auditor` (mit `tenant_id`) `TenantID` aus Session in den `QueryFilter` setzen. `superadmin` unverändert (kein Filter).
## Implementation Notes (Backend, 2026-06-21)
Zentrale Entscheidung im Code: Ein `auditor` gilt nur dann als *globaler* Auditor
(Altbestand, tenant-loser Zugriff), wenn `sess.TenantID == nil`. Mit gesetztem
`tenant_id` wird er exakt wie `domain_auditor` behandelt (tenant-gescoped). Dafür
wurde ein Helper eingeführt:
- `internal/api/search_handlers.go:18` — neue Funktion `auditorIsGlobal(sess *auth.Session) bool`
(`return sess.Role == userstore.RoleAuditor && sess.TenantID == nil`). Neuer Import
`archivmail/internal/auth`.
### Mail-Suche / Mail-Zugriff (`internal/api/search_handlers.go`)
- Index-Auswahl: `sess.Role != userstore.RoleAuditor``!auditorIsGlobal(sess)` (Per-Tenant-Index für tenant-gescopte Auditoren).
- Fallback-Tenant-Filter: gleiche Ersetzung (`!auditorIsGlobal(sess)`).
- Enrichment-No-Tenant-Filter (`auditorAllowedIDs`): greift jetzt nur noch bei `auditorIsGlobal(sess)`.
- `handleGetMail`, `handleGetAttachment`, `handleGetRaw`: der `IsWithoutTenant`-Block läuft jetzt nur bei `auditorIsGlobal(sess)`. Tenant-gescopte Auditoren sind durch den vorhandenen `sess.TenantID != nil`-Block (GetTenantForMail) abgedeckt.
### Export (`internal/api/export.go`)
- `handleExportPDF`: `IsWithoutTenant`-Block → `auditorIsGlobal(sess)`.
- `handleExportZIP`: No-Tenant-Preload (`auditorAllowed`) → `auditorIsGlobal(sess)`; Tenant-ZIP-Isolation über vorhandenen `sess.TenantID != nil`-Block.
### eDiscovery (`internal/api/ediscovery.go`)
- Index-Auswahl → `!auditorIsGlobal(sess)`.
- No-Tenant-Preload → `auditorIsGlobal(sess)`.
- (Hinweis: Wie bei `domain_auditor` existiert hier kein Post-Filter-Fallback, wenn `idxMgr == nil` — Verhalten bewusst identisch zu `domain_auditor` gehalten, kein neuer Scope.)
### Threads (`internal/api/thread_handlers.go`)
- `handleGetThread`: Tenant-gescopte Auditoren werden über `GetMailsByThread(ctx, threadID, tenantID)` (tenantID != nil) gefiltert. Für globale Auditoren neu hinzugefügt: Vorladen der No-Tenant-IDs (`GetAllIDsWithoutTenant`) und Per-Mail-Skip — vorher gab es hier KEINE Auditor-Isolation (Pre-existing Gap geschlossen).
### OCR (`internal/api/ocr_handlers.go`)
- `handleGetOCRText`: `IsWithoutTenant`-Block → `auditorIsGlobal(sess)`.
### Audit-Log (`internal/audit/audit.go`, `internal/api/dashboard_handlers.go`)
- `QueryFilter` um `TenantID *int64` erweitert (audit.go ~Zeile 43).
- `buildWhere()` um NULL-sichere Klausel `tenant_id = $n` erweitert (audit.go ~Zeile 318) — Einträge mit NULL-`tenant_id` sind für tenant-gescopte Rollen damit unsichtbar (spec-konform).
- `handleAuditLog` (dashboard_handlers.go ~Zeile 47): setzt `filter.TenantID = sess.TenantID`, wenn `sess.TenantID != nil`. `superadmin` (nil TenantID) und ein legacy globaler `auditor` (nil TenantID) bleiben ungefiltert.
### Befund: audit_log.tenant_id-Spalte
Die Spalte EXISTIERT bereits — sie wird in `internal/tenantstore/store.go:79` via
`ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenants(id)`
angelegt. **Keine neue Migration nötig.**
### Audit-Log: tenant_id beim Schreiben befüllen (GESCHLOSSEN, 2026-06-21)
Der zuvor offene Punkt (neue Einträge mit `tenant_id = NULL` → für tenant-gescopte
Rollen unsichtbar) ist jetzt gefixt. **Minimal-invasive Lösung gewählt: Struct-
Erweiterung, KEINE Signatur-Änderung von `Log()`.**
- `audit.Entry` um `TenantID *int64` erweitert (audit.go ~Zeile 40), Typ identisch zu
`auth.Session.TenantID` und `QueryFilter.TenantID`.
- `Log()`-INSERT schreibt jetzt die Spalte `tenant_id` mit (audit.go ~Zeile 195). Da
`Entry.TenantID` per Zero-Value `nil` ist, mussten NICHT alle Call-Sites geändert
werden — nur jene, wo ein Tenant bekannt ist.
- `initSchema()` legt `tenant_id` jetzt selbst idempotent an
(`ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT`), damit audit.go
ohne tenantstore-Migration lauffähig bleibt (Tests, Fresh-Install). Kompatibel zur
bestehenden tenantstore-Spalte (`IF NOT EXISTS` → No-Op bei der jeweils zweiten Anlage).
**Geänderte Call-Sites (53 Audit-Einträge mit Tenant-Bezug):**
- 46× `internal/api/*` via `TenantID: sess.TenantID` (alle Handler mit
`sess := sessionFromCtx(...)` und `Username: sess.Username`) — per Skript eingefügt,
gofmt-konform ausgerichtet.
- 3× `internal/api/v1_handlers.go` (externe API-Key-Sessions): neuer Helper
`apiKeyTenantPtr(akSess.TenantID)` (API-Key-Tenant ist `int64`, 0 = tenant-los → nil).
- 2× `internal/api/auth_handlers.go` (Login erfolgreich + TOTP-pending): `user.TenantID`.
- 1× `internal/api/totp_handlers.go` (TOTP-Login abgeschlossen): `user.TenantID`.
- 3× `internal/api/onboarding_handlers.go` (`invite_used``inviteTok.TenantID`,
`signup`/`password_reset_requested``u.TenantID`).
- 1× `internal/imap/scheduler.go` (`imap_uidvalidity_reset`): `acc.TenantID`.
- 1× `internal/imapserver/server.go` (`imap_login` erfolgreich): `user.TenantID`.
**Bewusst NULL belassen (Tenant nicht zuverlässig ermittelbar / System-/superadmin-Event,
nur superadmin sieht sie — spec-konform):**
- Fehlgeschlagene Logins vor Authentifizierung: `auth_handlers.go` (rate-limited,
invalid credentials), `totp_handlers.go` (totp_login_failed),
`imapserver/server.go` (imap_login_failed) — kein User-/Tenant-Bezug vorhanden.
- `onboarding_handlers.go`: `email_verified`, `password_reset_done` — nur Token mit
UserID vorhanden, kein Tenant ohne zusätzliche Abfrage; bewusst nil.
- Aktionen, bei denen `sess.TenantID == nil` ist (superadmin), bleiben automatisch
systemweit — z.B. `tenant_created` (typischerweise durch superadmin).
Damit ist der Cross-Tenant-Filter nicht nur fail-closed isoliert, sondern liefert
tenant-gescopten Rollen ab sofort auch tatsächlich ihre eigenen Einträge.
## Implementation Notes — QA-Fixes (Backend, 2026-06-21)
### BUG-1 behoben — eDiscovery fail-closed Post-Filter-Fallback
`internal/api/ediscovery.go` (`handleExportEDiscovery`):
- Index-Auswahl-Block (~Zeile 77-82): neues Flag `usedTenantIndex` eingeführt, gesetzt
wenn der Per-Tenant-Index verwendet wird (analog `handleSearch`).
- Nach dem `searchIdx.Search(...)` (~Zeile 89-110): neuer Post-Filter ergänzt. Wenn
`tenantID != nil && !usedTenantIndex && !auditorIsGlobal(sess)` (Fallback auf globalen
Index bei nicht-verdrahtetem `idxMgr`), werden die Treffer über
`s.store.GetAllIDsByTenant(ctx, tenantID)` serverseitig auf den eigenen Tenant gefiltert.
Fail-closed: bei einem Fehler des Tenant-ID-Lookups bricht der Export mit HTTP 500
("access check failed") ab, statt ungefilterte Treffer zu liefern (strenger als
`handleSearch`, das den Fehler ignoriert — beim Export ist Fail-Closed angemessen).
- Schließt den latenten Cross-Tenant-Datenabfluss-Vektor, der zuvor identisch zu
`domain_auditor` bestand. Da der Fallback rollenneutral greift (`!auditorIsGlobal`),
ist damit auch `domain_auditor` mit abgedeckt.
### BEFUND-3 behoben — tenant_id im tamper-evidenten Flat-File-Audit-Log
`internal/audit/audit.go`:
- `fileEntry`-Struct (~Zeile 76-89) um `TenantID *int64` mit Tag
`json:"tenant_id,omitempty"` erweitert — identisch zu `Entry.TenantID`.
- `writeFile()` (~Zeile 234-247) befüllt `TenantID: entry.TenantID` beim Schreiben der
JSON-Lines-Zeile. DB-INSERT und Flat-File sind jetzt konsistent im Tenant-Bezug;
tenant-lose/System-Events lassen das Feld dank `omitempty` weg.
## QA Test Results (Code-Review + Red-Team, 2026-06-21)
**Methodik:** Statische Code-Review + Red-Team-Analyse aller in den Implementation Notes
genannten Pfade (search_handlers.go, export.go, ediscovery.go, thread_handlers.go,
ocr_handlers.go, audit.go, dashboard_handlers.go, admin_handlers.go, server.go,
v1_handlers.go). Kein Live-HTTP-Test gegen 192.168.1.132 durchgeführt (reine
Quellcode-Bewertung) — siehe Empfehlung unten.
### Acceptance Criteria
| AC | Ergebnis | Anmerkung |
|----|----------|-----------|
| AC1 — `auditor` mit `tenant_id`: `/api/search` nur eigener Tenant | PASS | `auditorIsGlobal()` schließt tenant-gescopten Auditor aus dem No-Tenant-Pfad aus; Per-Tenant-Index (Zeile 123) bzw. `GetAllIDsByTenant`-Fallback vor Pagination (Zeile 137) greift wie bei `domain_auditor`. |
| AC2 — `handleGetMail/Raw/Attachment/Export/eDiscovery/Threads/OCR` verweigern Fremd-Tenant (403) | PASS | GetMail/Raw/Attachment/PDF/ZIP/OCR: tenant-gescopt durch `sess.TenantID != nil`-Block (GetTenantForMail-Vergleich) — korrekt. Threads: `GetMailsByThread(…, tenantID)` filtert serverseitig — korrekt. **eDiscovery: BUG-1 in Re-Test 2026-06-21 als geschlossen verifiziert (fail-closed Post-Filter ergänzt).** |
| AC3 — `/api/audit` tenant-gescopt für domain_admin/domain_auditor/auditor | PASS | `handleAuditLog` setzt `filter.TenantID = sess.TenantID` bei `!= nil`; `buildWhere` erzeugt NULL-sicheres `tenant_id = $n`. RBAC-Route `requireRole(RoleAuditor)` erlaubt alle vier Rollen. |
| AC4 — `superadmin` unberührt (alle Tenants/Logs) | PASS | superadmin hat `TenantID == nil` → kein Index-Scoping, kein Audit-Filter. |
| AC5 — keine Regression für domain_admin/domain_auditor/user | PASS | Bestehende Blöcke unverändert; `auditorIsGlobal()` liefert für Nicht-Auditor-Rollen immer `false`, ändert deren Pfade also nicht. |
| AC6 — `auditor` ohne `tenant_id` behält globalen Zugriff | PASS | `auditorIsGlobal()` true → alle No-Tenant-Pfade aktiv wie zuvor. Dokumentiert in den Implementation Notes. |
**Acceptance Criteria: 6/6 PASS** (AC2 mit dokumentiertem Vorbehalt, siehe BUG-1).
### Sicherheits-Befunde (Red Team)
**BUG-1 — eDiscovery: kein Post-Filter-Fallback bei `idxMgr == nil` (Severity: Medium, latent)**
`internal/api/ediscovery.go:78` — Wenn der Per-Tenant-Index-Manager nicht verdrahtet ist
(`s.idxMgr == nil`), fällt ein tenant-gescopter Auditor/domain_auditor auf `searchIdx = s.idx`
(globaler Index) zurück. Da `auditorIsGlobal(sess)` dann `false` ist, wird `auditorAllowed`
nicht gesetzt, und es gibt — anders als in `handleSearch` (Zeile 137, `GetAllIDsByTenant`) —
KEINEN serverseitigen Tenant-Post-Filter. Folge: eDiscovery-Export würde Mails ALLER Tenants
liefern.
- **Reproduktion:** Deployment mit nicht-verdrahtetem `idxMgr` (Test/Fehlkonfiguration), Auditor
mit tenant_id ruft `POST /api/export/ediscovery` auf → erhält Fremd-Tenant-Mails.
- **In Produktion nicht ausgelöst:** `main.go:350 SetIndexManager(idxMgr)` setzt `idxMgr` immer
ungleich nil. Risiko ist rein latent (Defense-in-Depth-Lücke), aber bei DSGVO-Kritikalität
und der ausdrücklichen Frage im Auftrag relevant.
- **Implementation Notes bestätigen das bewusst** ("kein Post-Filter-Fallback, identisch zu
domain_auditor"). Bewertung: das Verhalten ist konsistent mit domain_auditor, aber damit ist
domain_auditor genauso betroffen — die Konsistenz heilt das Leck nicht.
- **Empfehlung Backend:** In `handleExportEDiscovery` denselben `GetAllIDsByTenant`-Post-Filter
wie in `handleSearch` ergänzen, wenn `tenantID != nil && !usedTenantIndex`. Fail-closed.
**BEFUND-2 — NULL-Assignment-Eskalation NICHT ausnutzbar (PASS, Severity: keine)**
Geprüft auf die Auftragsfrage "kann ein Tenant-Admin seinem Auditor versehentlich
tenant_id=NULL zuweisen?": **Nein.**
- `handleCreateUser` (admin_handlers.go:80-83) setzt `tenantID = sess.TenantID` zwingend aus der
Session des Erstellers. Ein `domain_admin` (tenant_id gesetzt) kann KEINEN tenant-losen Auditor
anlegen — der Wert wird ignoriert/überschrieben, der Request-Body enthält gar kein tenant_id-Feld.
- `handleUpdateUser` (admin_handlers.go:135-140) hat kein `tenant_id`-Feld im Request-Struct — die
Tenant-Zuordnung ist per API überhaupt nicht änderbar.
- Nur ein `superadmin` (tenant_id == nil) erzeugt beim Anlegen eines Auditors tenant_id=NULL →
bewusst globaler Auditor. Das ist spec-konform und nicht eskalierbar von unten.
**BEFUND-3 — Flat-File-Audit-Log enthält kein tenant_id (Severity: Low)**
`audit.go:76-85` `fileEntry` und `writeFile` schreiben tenant_id NICHT in die tamper-evidente
JSON-Lines-Datei (PROJ-48), obwohl die DB-Spalte jetzt befüllt wird. Keine Isolations-Lücke
(Datei ist nur superadmin/forensisch zugänglich), aber Inkonsistenz: DB- und Datei-Audit
divergieren im Tenant-Bezug. Für vollständige GoBD-/forensische Nachvollziehbarkeit sollte
`tenant_id` auch in `fileEntry` aufgenommen werden.
**BEFUND-4 — domain_auditor-ohne-Tenant-Guard in eDiscovery vorhanden, aber Auditor-ohne-Tenant
NICHT geblockt (PASS, by design)**
`ediscovery.go:48` blockt `RoleDomainAuditor` ohne Tenant. Ein globaler `auditor` (RoleAuditor,
tenant_id nil) wird hier bewusst NICHT geblockt — er ist legitim global und wird über
`auditorAllowed` (No-Tenant-IDs) korrekt eingegrenzt. Konsistent mit AC6. Kein Bug.
**BEFUND-5 — Audit-Logging schreibender Operationen (PASS)**
Search/Export/PDF/ZIP/OCR/eDiscovery/UserMgmt setzen `TenantID: sess.TenantID` in
`audit.Entry` — stichprobenartig verifiziert. Cross-Tenant-Filter liefert tenant-gescopten
Rollen damit ihre eigenen Einträge. Login-/System-Events bleiben bewusst NULL (nur superadmin-
sichtbar), spec-konform.
### Regression
- `domain_auditor`/`domain_admin`/`user`-Pfade: unverändert, `auditorIsGlobal()` ist für sie
immer false. Kein Regressionsrisiko erkennbar.
- PROJ-54 (User-Pagination): `AnyAddress`-Filter unberührt.
### Zusammenfassung
- Acceptance Criteria: **6 PASS / 0 FAIL** (AC2 mit Vorbehalt BUG-1).
- Bugs: 1× Medium (latent, BUG-1 eDiscovery Fail-Open), 1× Low (BEFUND-3 Flat-File tenant_id).
- Kein Critical, kein High. NULL-Eskalation-Vektor geprüft und ausgeschlossen.
### Production-Ready-Bewertung: BEDINGT READY
Kein Critical/High → formal deploybar. Wegen DSGVO-Kritikalität und der ausdrücklich strengen
Bewertung lautet die Empfehlung jedoch: **BUG-1 (eDiscovery Post-Filter-Fallback) vor SaaS-/
Multi-Tenant-Produktivfreigabe schließen** (fail-closed analog handleSearch), da der eDiscovery-
Export der höchste Cross-Tenant-Datenabfluss-Vektor ist. BEFUND-3 (Flat-File) als Folge-Ticket.
Vor Deploy zusätzlich Live-Verifikation auf 192.168.1.132 mit echtem tenant-gescopten Auditor
empfohlen (Code-Review ersetzt keinen Integrationstest gegen Manticore-Index-Auswahl).
## QA Re-Test nach Fixes (Code-Review, 2026-06-21)
**Auftrag:** Verifizieren, ob BUG-1 (eDiscovery fehlender Tenant-Post-Filter-Fallback)
und BEFUND-3 (Flat-File-Audit-Log fehlendes tenant_id) korrekt geschlossen sind.
### BUG-1 — eDiscovery Post-Filter-Fallback: GESCHLOSSEN (verifiziert)
`internal/api/ediscovery.go` `handleExportEDiscovery`:
- Index-Auswahl-Block (Zeile 77-82) führt `usedTenantIndex` ein — gesetzt, sobald der
Per-Tenant-Index via `s.idxMgr.ForTenant(tenantID)` verwendet wird. Identisch zur
Referenzlogik in `handleSearch` (search_handlers.go:122-126).
- Post-Filter (Zeile 95-113): Bedingung `tenantID != nil && !usedTenantIndex &&
len(result.Hits) > 0 && !auditorIsGlobal(sess)` greift exakt im Fallback-Fall
(globaler Index trotz tenant-gescopter Session). Filtert die Treffer serverseitig
über `s.store.GetAllIDsByTenant(ctx, tenantID)` VOR der ZIP-Erzeugung.
- **Fail-closed bestätigt:** Bei `idErr != nil` bricht der Export mit HTTP 500
("access check failed") ab (Zeile 97-100) — strenger als `handleSearch`, das den
Fehler ignoriert (search_handlers.go:139 `if idErr == nil`). Für einen Export ist
Fail-Closed die korrekte und sicherere Wahl. Kein ungefilterter Datenabfluss möglich.
- Greift rollenneutral (`!auditorIsGlobal`) → deckt sowohl tenant-gescopten `auditor`
als auch `domain_auditor` ab. Der zuvor latente Fail-Open-Vektor (Cross-Tenant-Leck
bei nicht-verdrahtetem `idxMgr`) ist geschlossen.
- **Ergebnis: PASS.** BUG-1 korrekt behoben.
### BEFUND-3 — Flat-File-Audit-Log tenant_id: GESCHLOSSEN (verifiziert)
`internal/audit/audit.go`:
- `fileEntry`-Struct (Zeile 76-89) enthält jetzt `TenantID *int64` mit Tag
`json:"tenant_id,omitempty"` — typgleich zu `Entry.TenantID` und `QueryFilter.TenantID`.
- `writeFile()` (Zeile 230-256) befüllt `TenantID: entry.TenantID` (Zeile 247) beim
Marshalling der JSON-Lines-Zeile. DB-INSERT (Zeile 207-218) und Flat-File sind damit
konsistent im Tenant-Bezug. Tenant-lose/System-Events lassen das Feld dank `omitempty`
weg — kein leeres/`null`-Feld in der tamper-evidenten Datei.
- **Ergebnis: PASS.** BEFUND-3 korrekt behoben. DB- und Datei-Audit divergieren nicht
mehr; GoBD-/forensische Nachvollziehbarkeit hergestellt.
### Stützende Verifikation
- Helper `auditorIsGlobal()` existiert (search_handlers.go:24), Store-Methode
`GetAllIDsByTenant()` existiert (storage.go:1194). Keine fehlenden Symbole.
- Lokaler `go build` nicht möglich (kein Go im lokalen PATH, projektkonform — Build nur
per SSH auf Testserver). Code ist gofmt-konform strukturiert; keine offensichtlichen
Compile-Risiken in den geänderten Pfaden.
### Re-Test-Fazit: Beide offenen Punkte geschlossen.
- Bugs offen: **0** (kein Critical, High, Medium oder Low mehr offen).
- Acceptance Criteria: **6/6 PASS** (AC2-Vorbehalt durch BUG-1-Fix aufgehoben).
### Production-Ready-Bewertung (final): JA
Alle in der vorherigen QA-Runde gefundenen Befunde sind geschlossen. Code-Review ist
schlüssig — kein Live-Test für die Bewertung erforderlich.
**Wichtiger Vorbehalt:** Eine Live-Verifikation auf 192.168.1.132 mit einem echten
tenant-gescopten `auditor`-Account (eDiscovery-Export + Audit-Log-Abruf gegen reale
Manticore-Index-Auswahl und PostgreSQL) wird vor dem SaaS-/Multi-Tenant-Go-Live
weiterhin ausdrücklich empfohlen. Code-Review ersetzt keinen Integrationstest gegen
die tatsächliche Index-/DB-Schicht. Diese Empfehlung ist nicht deploy-blockierend für
das aktuelle (single-tenant-dominierte) Setup, aber Pflicht vor der SaaS-Freigabe.
## Live-Verifikation auf 192.168.1.132 (2026-06-22)
Test-Account `homelocal-auditor@homelocal.local` (tenant_id=3, role=auditor) angelegt/Test-Passwort gesetzt, eingeloggt, `GET /api/search?q=test&page_size=50` ausgeführt: 49 von 50 Treffern korrekt tenant_id=3, 1 Treffer (`6cf020fb...d5a`, `emails.tenant_id=1`) initial als Cross-Tenant-Leak verdächtigt.
**Nachverifikation:** Kein Leak. Die Mail ging von `patrick@perlbach24.de` (Tenant 1) an `bundyxl@gmx.de`, welcher im Tenant "homelocal" (Tenant 3) als `patrick.perlbach@gmx.de` archiviert wird. `email_refs` hat dafür korrekt zwei Einträge (`tenant_id=1` und `tenant_id=3`) — das ist der beabsichtigte Cross-Tenant-Dedup-Mechanismus (eine physische Mail kann mehreren Tenants zugeordnet sein, wenn sie an Empfänger unterschiedlicher Tenants ging). Der `auditor` mit `tenant_id=3` sieht die Mail zu Recht, da sie tatsächlich seinem Tenant zugeordnet ist (`email_refs.tenant_id=3`).
**Lehre für künftige Live-Tests:** Tenant-Zugehörigkeit einer Mail ausschließlich über `emails.tenant_id` zu prüfen ist unzureichend — `email_refs` muss als zusätzliche, gültige Tenant-Zuordnung berücksichtigt werden (siehe `internal/storage/storage.go:1194` `GetAllIDsByTenant`, nutzt `email_refs`, nicht `emails.tenant_id`).
**PROJ-55 Live-Verifikation: PASS.** Keine offenen Befunde mehr.
## Deployment
- Test: 192.168.1.132 — `update.sh` (Commit `1d27dc2`), Backend+Frontend laufen, neuer HTTP-Health-Check bestätigt funktionsfähig, Reindex-Schritt korrekt entfernt. Live-Test PROJ-55 bestanden (siehe oben).
- Produktion: 192.168.1.131 — `update.sh` (Commit `ef83174`), Backend+Frontend laufen, Health-Check bestanden, kein Reindex-Schritt mehr im Log. 2026-06-22.