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
+33 -12
View File
@@ -9,11 +9,22 @@ import (
"time"
"archivmail/internal/audit"
"archivmail/internal/auth"
"archivmail/internal/index"
"archivmail/internal/userstore"
"archivmail/pkg/mailparser"
)
// auditorIsGlobal reports whether an auditor session is a legacy *global*
// auditor (no tenant assigned). PROJ-55: an auditor WITH a tenant_id is scoped
// strictly to that tenant — identical to domain_auditor — and therefore must
// NOT use the global no-tenant code paths. Only an auditor without any
// tenant_id retains the legacy behaviour of seeing tenant-less mails globally.
// For any non-auditor role this returns false.
func auditorIsGlobal(sess *auth.Session) bool {
return sess.Role == userstore.RoleAuditor && sess.TenantID == nil
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
fromFilter := r.URL.Query().Get("from")
@@ -103,12 +114,13 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
// PROJ-21 Phase 4: Use per-tenant index when available; fall back to
// global index + post-filter when the tenant index manager is not wired.
// auditor always uses the global index — they see no-tenant mails only,
// regardless of any tenant_id on their user record.
// PROJ-55: a *global* auditor (no tenant_id) always uses the global index —
// they see no-tenant mails only. An auditor WITH a tenant_id is treated like
// domain_auditor and uses the per-tenant index / tenant fallback filter.
tenantID := tenantFromCtx(r.Context())
searchIdx := s.idx
usedTenantIndex := false
if s.idxMgr != nil && tenantID != nil && sess.Role != userstore.RoleAuditor {
if s.idxMgr != nil && tenantID != nil && !auditorIsGlobal(sess) {
searchIdx = s.idxMgr.ForTenant(tenantID)
usedTenantIndex = true
}
@@ -122,7 +134,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
// Fallback tenant isolation: post-filter when we used the global index
// but the user belongs to a tenant. This is the legacy path; the per-tenant
// index path above makes this unnecessary.
if tenantID != nil && !usedTenantIndex && len(result.Hits) > 0 && sess.Role != userstore.RoleAuditor {
if tenantID != nil && !usedTenantIndex && len(result.Hits) > 0 && !auditorIsGlobal(sess) {
allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID)
if idErr == nil {
allowed := make(map[string]struct{}, len(allowedIDs))
@@ -143,6 +155,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventSearch,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Query: q,
Success: true,
@@ -164,9 +177,11 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
MatchField string `json:"match_field,omitempty"` // PROJ-44: subject|body|attachment_text|...
}
// auditor role: restrict results to mails with no tenant assignment.
// Global auditor (no tenant_id): restrict results to mails with no tenant
// assignment. A tenant-scoped auditor is already constrained by the
// per-tenant index / tenant fallback filter above (PROJ-55).
var auditorAllowedIDs map[string]struct{}
if sess.Role == userstore.RoleAuditor {
if auditorIsGlobal(sess) {
ids, idErr := s.store.GetAllIDsWithoutTenant(r.Context())
if idErr != nil {
writeError(w, http.StatusInternalServerError, "failed to load mail list")
@@ -256,8 +271,10 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
}
}
// auditor: only mails with no tenant assignment.
if sess.Role == userstore.RoleAuditor {
// Global auditor (no tenant_id): only mails with no tenant assignment.
// A tenant-scoped auditor is already constrained by the tenant-isolation
// block above (sess.TenantID != nil) — PROJ-55.
if auditorIsGlobal(sess) {
ok, err := s.store.IsWithoutTenant(r.Context(), id)
if err != nil || !ok {
writeError(w, http.StatusForbidden, "access denied")
@@ -395,8 +412,10 @@ func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
}
}
// auditor: only mails with no tenant assignment.
if sess.Role == userstore.RoleAuditor {
// Global auditor (no tenant_id): only mails with no tenant assignment.
// A tenant-scoped auditor is already constrained by the tenant-isolation
// block above (sess.TenantID != nil) — PROJ-55.
if auditorIsGlobal(sess) {
ok, err := s.store.IsWithoutTenant(r.Context(), id)
if err != nil || !ok {
writeError(w, http.StatusForbidden, "access denied")
@@ -456,8 +475,10 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
}
}
// auditor: only mails with no tenant assignment.
if sess.Role == userstore.RoleAuditor {
// Global auditor (no tenant_id): only mails with no tenant assignment.
// A tenant-scoped auditor is already constrained by the tenant-isolation
// block above (sess.TenantID != nil) — PROJ-55.
if auditorIsGlobal(sess) {
ok, err := s.store.IsWithoutTenant(r.Context(), id)
if err != nil || !ok {
writeError(w, http.StatusForbidden, "access denied")