Files
archivmail/features/PROJ-55-fix-auditor-tenant-isolation.md
T
sysops 92e57431c3 fix(PROJ-55): Tenant-Isolation für Rolle "auditor" + Audit-Log korrigieren
Kritischer Sicherheitsbug: Auditoren mit zugewiesenem Tenant sahen Mails
und Audit-Log-Einträge anderer Tenants (DSGVO-relevant). auditor wird
jetzt analog zu domain_auditor pro Tenant gescoped, sofern tenant_id
gesetzt ist (Abwärtskompatibilität: ohne tenant_id bleibt der bisherige
globale Zugriff erhalten). Betrifft Mail-Suche, Mail-Detailzugriff,
Export, eDiscovery, Threads, OCR sowie das Audit-Log (DB + tamper-evidentes
Flat-File), inkl. Befüllung von tenant_id an allen Audit-Log-Schreibstellen.
2026-06-21 22:26:06 +02:00

22 KiB
Raw Blame History

PROJ-55: Fix Tenant-Isolation für Rolle "auditor" + Audit-Log (Sicherheitsbug, DSGVO-relevant)

Status: In Review

Created: 2026-06-21 Last Updated: 2026-06-21

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_usedinviteTok.TenantID, signup/password_reset_requestedu.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.

Deployment

To be added by /deploy