Initial verdächtigter Cross-Tenant-Treffer als beabsichtigtes email_refs-Dedup-Verhalten verifiziert (kein Leak). PROJ-55 ist production-ready, Live-Test PASS.
24 KiB
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-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).
auditorsieht künftig nur Mails/Logs des eigenentenant_id— analog zudomain_admin/domain_auditor. - superadmin bleibt unverändert global (sieht weiterhin alle Tenants, nötig für Betrieb/Support).
domain_admin/domain_auditorwerden im Audit-Log ebenfalls auf den eigenen Tenant beschränkt (waren es vorher nicht).
Acceptance Criteria
- Rolle
auditormit gesetztemtenant_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
auditormit gesetztemtenant_id:handleGetMail/handleGetRaw/handleGetAttachment/Export/eDiscovery/Threads/OCR verweigern Zugriff auf Mails anderer Tenants (403). /api/audit(Audit-Log): fürdomain_admin,domain_auditor,auditormittenant_idwerden nur Log-Einträge des eigenen Tenants zurückgegeben.superadminist von beiden Einschränkungen unberührt — sieht weiterhin alle Tenants/Logs.- Bestehende Pro-Tenant-Filterung für
domain_admin/domain_auditor/userbleibt unverändert funktionsfähig (keine Regression). - Falls ein
auditor-Account KEINENtenant_idgesetzt 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
auditorohnetenant_id→ weiterhin globaler Zugriff (Abwärtskompatibilität, siehe AC oben).auditormittenant_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
superadminsieht sie weiterhin.
Technical Requirements (optional)
- Keine neue Migration nötig (
tenant_idexistiert bereits aufusers, Audit-Log-Einträge müssten ggf. um Tenant-Bezug ergänzt werden — prüfen obaudit_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:
internal/api/search_handlers.go:auditormit gesetztemtenant_idwiedomain_auditor/domain_adminbehandeln (Per-Tenant-Index nutzen bzw. Fallback-Filter NICHT mehr explizit ausschließen).auditorohnetenant_idweiterhin global.- Dieselbe Logik für
handleGetMail/handleGetRaw/handleGetAttachmentund alle Stellen, diemailBelongsToUser/Tenant-Checks für Auditoren machen (export.go, ediscovery.go, thread_handlers.go, ocr_handlers.go). internal/audit/audit.go:QueryFilterumTenantID *int64erweitern,buildWhere()entsprechend erweitern.internal/api/dashboard_handlers.go(handleAuditLog): für Rollendomain_admin,domain_auditor,auditor(mittenant_id)TenantIDaus Session in denQueryFiltersetzen.superadminunverä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 FunktionauditorIsGlobal(sess *auth.Session) bool(return sess.Role == userstore.RoleAuditor && sess.TenantID == nil). Neuer Importarchivmail/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 beiauditorIsGlobal(sess). handleGetMail,handleGetAttachment,handleGetRaw: derIsWithoutTenant-Block läuft jetzt nur beiauditorIsGlobal(sess). Tenant-gescopte Auditoren sind durch den vorhandenensess.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 vorhandenensess.TenantID != nil-Block.
eDiscovery (internal/api/ediscovery.go)
- Index-Auswahl →
!auditorIsGlobal(sess). - No-Tenant-Preload →
auditorIsGlobal(sess). - (Hinweis: Wie bei
domain_auditorexistiert hier kein Post-Filter-Fallback, wennidxMgr == nil— Verhalten bewusst identisch zudomain_auditorgehalten, kein neuer Scope.)
Threads (internal/api/thread_handlers.go)
handleGetThread: Tenant-gescopte Auditoren werden überGetMailsByThread(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)
QueryFilterumTenantID *int64erweitert (audit.go ~Zeile 43).buildWhere()um NULL-sichere Klauseltenant_id = $nerweitert (audit.go ~Zeile 318) — Einträge mit NULL-tenant_idsind für tenant-gescopte Rollen damit unsichtbar (spec-konform).handleAuditLog(dashboard_handlers.go ~Zeile 47): setztfilter.TenantID = sess.TenantID, wennsess.TenantID != nil.superadmin(nil TenantID) und ein legacy globalerauditor(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.EntryumTenantID *int64erweitert (audit.go ~Zeile 40), Typ identisch zuauth.Session.TenantIDundQueryFilter.TenantID.Log()-INSERT schreibt jetzt die Spaltetenant_idmit (audit.go ~Zeile 195). DaEntry.TenantIDper Zero-Valuenilist, mussten NICHT alle Call-Sites geändert werden — nur jene, wo ein Tenant bekannt ist.initSchema()legttenant_idjetzt 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/*viaTenantID: sess.TenantID(alle Handler mitsess := sessionFromCtx(...)undUsername: sess.Username) — per Skript eingefügt, gofmt-konform ausgerichtet. - 3×
internal/api/v1_handlers.go(externe API-Key-Sessions): neuer HelperapiKeyTenantPtr(akSess.TenantID)(API-Key-Tenant istint64, 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_loginerfolgreich):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 == nilist (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
usedTenantIndexeingeführt, gesetzt wenn der Per-Tenant-Index verwendet wird (analoghandleSearch). - Nach dem
searchIdx.Search(...)(~Zeile 89-110): neuer Post-Filter ergänzt. WenntenantID != nil && !usedTenantIndex && !auditorIsGlobal(sess)(Fallback auf globalen Index bei nicht-verdrahtetemidxMgr), werden die Treffer übers.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 alshandleSearch, das den Fehler ignoriert — beim Export ist Fail-Closed angemessen). - Schließt den latenten Cross-Tenant-Datenabfluss-Vektor, der zuvor identisch zu
domain_auditorbestand. Da der Fallback rollenneutral greift (!auditorIsGlobal), ist damit auchdomain_auditormit abgedeckt.
BEFUND-3 behoben — tenant_id im tamper-evidenten Flat-File-Audit-Log
internal/audit/audit.go:
fileEntry-Struct (~Zeile 76-89) umTenantID *int64mit Tagjson:"tenant_id,omitempty"erweitert — identisch zuEntry.TenantID.writeFile()(~Zeile 234-247) befülltTenantID: entry.TenantIDbeim Schreiben der JSON-Lines-Zeile. DB-INSERT und Flat-File sind jetzt konsistent im Tenant-Bezug; tenant-lose/System-Events lassen das Feld dankomitemptyweg.
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 ruftPOST /api/export/ediscoveryauf → erhält Fremd-Tenant-Mails. - In Produktion nicht ausgelöst:
main.go:350 SetIndexManager(idxMgr)setztidxMgrimmer 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
handleExportEDiscoverydenselbenGetAllIDsByTenant-Post-Filter wie inhandleSearchergänzen, wenntenantID != 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) setzttenantID = sess.TenantIDzwingend aus der Session des Erstellers. Eindomain_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 keintenant_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
usedTenantIndexein — gesetzt, sobald der Per-Tenant-Index vias.idxMgr.ForTenant(tenantID)verwendet wird. Identisch zur Referenzlogik inhandleSearch(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 übers.store.GetAllIDsByTenant(ctx, tenantID)VOR der ZIP-Erzeugung. - Fail-closed bestätigt: Bei
idErr != nilbricht der Export mit HTTP 500 ("access check failed") ab (Zeile 97-100) — strenger alshandleSearch, das den Fehler ignoriert (search_handlers.go:139if 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-gescoptenauditorals auchdomain_auditorab. Der zuvor latente Fail-Open-Vektor (Cross-Tenant-Leck bei nicht-verdrahtetemidxMgr) 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 jetztTenantID *int64mit Tagjson:"tenant_id,omitempty"— typgleich zuEntry.TenantIDundQueryFilter.TenantID.writeFile()(Zeile 230-256) befülltTenantID: 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 dankomitemptyweg — 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-MethodeGetAllIDsByTenant()existiert (storage.go:1194). Keine fehlenden Symbole. - Lokaler
go buildnicht 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(Commit1d27dc2), 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 — ausstehend