# 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.
