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.
This commit is contained in:
sysops
2026-06-21 22:26:06 +02:00
parent 4a8b8964e5
commit 92e57431c3
28 changed files with 526 additions and 27 deletions
+35 -4
View File
@@ -71,10 +71,14 @@ func (s *Server) handleExportEDiscovery(w http.ResponseWriter, r *http.Request)
}
}
// Choose index (per-tenant or global)
// Choose index (per-tenant or global). PROJ-55: a tenant-scoped auditor
// (tenant_id set) uses the per-tenant index like domain_auditor; only a
// global auditor (no tenant_id) stays on the global index.
searchIdx := s.idx
if s.idxMgr != nil && tenantID != nil && sess.Role != userstore.RoleAuditor {
usedTenantIndex := false
if s.idxMgr != nil && tenantID != nil && !auditorIsGlobal(sess) {
searchIdx = s.idxMgr.ForTenant(tenantID)
usedTenantIndex = true
}
result, err := searchIdx.Search(searchReq)
@@ -83,9 +87,35 @@ func (s *Server) handleExportEDiscovery(w http.ResponseWriter, r *http.Request)
return
}
// Auditor: restrict to no-tenant mails
// BUG-1 (PROJ-55): fail-closed tenant post-filter when we fell back to the
// global index but the session is tenant-scoped (idxMgr not wired). Without
// this a tenant-scoped auditor/domain_auditor would otherwise receive mails
// of ALL tenants via eDiscovery. Mirrors the GetAllIDsByTenant fallback in
// handleSearch (search_handlers.go).
if tenantID != nil && !usedTenantIndex && len(result.Hits) > 0 && !auditorIsGlobal(sess) {
allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID)
if idErr != nil {
writeError(w, http.StatusInternalServerError, "access check failed")
return
}
allowed := make(map[string]struct{}, len(allowedIDs))
for _, id := range allowedIDs {
allowed[id] = struct{}{}
}
filtered := result.Hits[:0]
for _, h := range result.Hits {
if _, ok := allowed[h.ID]; ok {
filtered = append(filtered, h)
}
}
result.Hits = filtered
result.Total = len(filtered)
}
// Global auditor (no tenant_id): restrict to no-tenant mails. A tenant-scoped
// auditor is already constrained by the per-tenant index above (PROJ-55).
var auditorAllowed map[string]struct{}
if sess.Role == userstore.RoleAuditor {
if auditorIsGlobal(sess) {
ids, err := s.store.GetAllIDsWithoutTenant(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "access check failed")
@@ -276,6 +306,7 @@ func (s *Server) handleExportEDiscovery(w http.ResponseWriter, r *http.Request)
s.audlog.Log(audit.Entry{
EventType: audit.EventExport,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("ediscovery: case=%q mails=%d", caseName, exported),
Success: true,