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
+5
View File
@@ -110,6 +110,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: "created user: " + user.Username,
Success: true,
@@ -184,6 +185,7 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("updated user %d", id),
Success: true,
@@ -250,6 +252,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf(
"deleted user %d (%s, role=%s); %d IMAP account(s) removed; emails retained per GoBD",
@@ -437,6 +440,7 @@ func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "service." + body.Action,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: name,
Success: true,
@@ -459,6 +463,7 @@ func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "service." + body.Action,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: name,
Success: true,
+2
View File
@@ -86,6 +86,7 @@ func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
Detail: fmt.Sprintf("created api key %q (id=%d, role=%s)", req.Name, keyID, req.Role),
Success: true,
})
@@ -190,6 +191,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
Detail: fmt.Sprintf("deleted api key id=%d", keyID),
Success: true,
})
+1
View File
@@ -123,6 +123,7 @@ func (s *Server) auditRule(r *http.Request, event, detail string) {
s.audlog.Log(audit.Entry{
EventType: event,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: detail,
+3
View File
@@ -56,6 +56,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventLogin,
Username: user.Username,
TenantID: user.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "totp_pending",
@@ -72,6 +73,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventLogin,
Username: user.Username,
TenantID: user.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
@@ -142,6 +144,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventLogout,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
+13 -2
View File
@@ -56,12 +56,23 @@ func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
pageSize = 50
}
entries, total, err := s.audlog.Query(audit.QueryFilter{
// PROJ-55: tenant isolation for the audit log. Any role with a tenant_id
// (domain_admin, domain_auditor, and a tenant-scoped auditor) sees only the
// audit entries of its own tenant. superadmin has no tenant_id and stays
// unfiltered (sees all tenants). A legacy global auditor (no tenant_id) is
// likewise unfiltered — its access scope is unchanged.
sess := sessionFromCtx(r.Context())
filter := audit.QueryFilter{
Username: username,
EventType: eventType,
PageSize: pageSize,
Page: page,
})
}
if sess.TenantID != nil {
filter.TenantID = sess.TenantID
}
entries, total, err := s.audlog.Query(filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "audit query failed")
return
+3
View File
@@ -160,6 +160,7 @@ func (s *Server) handleCreateDSGVORequest(w http.ResponseWriter, r *http.Request
s.audlog.Log(audit.Entry{
EventType: audit.EventDSGVORequest,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: fmt.Sprintf("dsgvo: address=%q hits=%d rejected=%d deletable=%d truncated=%t",
@@ -274,6 +275,7 @@ func (s *Server) handleDeleteDSGVOMails(w http.ResponseWriter, r *http.Request)
s.audlog.Log(audit.Entry{
EventType: audit.EventDSGVORequest,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
MailID: m.MailID,
Success: true,
@@ -335,6 +337,7 @@ func (s *Server) handleExportDSGVORequest(w http.ResponseWriter, r *http.Request
s.audlog.Log(audit.Entry{
EventType: audit.EventExport,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: fmt.Sprintf("dsgvo: export request_id=%d", req.ID),
+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,
+10 -4
View File
@@ -356,8 +356,10 @@ func (s *Server) handleExportPDF(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")
@@ -391,6 +393,7 @@ func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventExport,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
MailID: id,
Detail: "pdf",
@@ -446,9 +449,11 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
}
}
// Auditor: pre-load the set of no-tenant mail IDs for efficient per-mail checks.
// Global auditor (no tenant_id): pre-load the set of no-tenant mail IDs for
// efficient per-mail checks. A tenant-scoped auditor is already constrained
// by the tenant-isolation block 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, "tenant check failed")
@@ -560,6 +565,7 @@ func (s *Server) handleExportZIP(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("zip: %d mails", exported),
Success: true,
+1
View File
@@ -53,6 +53,7 @@ func (s *Server) handleSetIMAPMode(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "imap_mode_changed",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: "imap_mode set to " + req.Mode,
Success: true,
+1
View File
@@ -66,6 +66,7 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "invite_created",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
+2
View File
@@ -30,6 +30,7 @@ func (s *Server) handleSyncTenantLDAP(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_sync",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: len(res.Errors) == 0,
Detail: fmt.Sprintf("%d Benutzer synchronisiert", res.Synced),
@@ -53,6 +54,7 @@ func (s *Server) handleAdminSyncTenantLDAP(w http.ResponseWriter, r *http.Reques
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_sync",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: len(res.Errors) == 0,
Detail: fmt.Sprintf("%d Benutzer synchronisiert (tenant %d)", res.Synced, id),
+14
View File
@@ -98,6 +98,7 @@ func (s *Server) handleSaveLDAP(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "ldap_config_saved",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "LDAP-Konfiguration gespeichert",
@@ -126,6 +127,7 @@ func (s *Server) handleDeleteLDAP(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "ldap_config_deleted",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "LDAP-Konfiguration gelöscht",
@@ -184,6 +186,7 @@ func (s *Server) handleTestLDAP(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "ldap_connection_test",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: result.OK,
Detail: result.Message,
@@ -279,6 +282,7 @@ func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "tenant_created",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "Mandant erstellt: " + req.Name,
@@ -369,6 +373,7 @@ func (s *Server) handleDeleteTenant(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "tenant_deleted",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "Mandant gelöscht: " + strconv.FormatInt(id, 10),
@@ -549,6 +554,7 @@ func (s *Server) handleSaveTenantLDAP(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_config_saved",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "Mandant-LDAP-Konfiguration gespeichert",
@@ -580,6 +586,7 @@ func (s *Server) handleDeleteTenantLDAP(w http.ResponseWriter, r *http.Request)
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_config_deleted",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "Mandant-LDAP-Konfiguration gelöscht",
@@ -619,6 +626,7 @@ func (s *Server) handleTestTenantLDAP(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_connection_test",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: result.OK,
Detail: result.Message,
@@ -691,6 +699,7 @@ func (s *Server) handleAdminSaveTenantLDAP(w http.ResponseWriter, r *http.Reques
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_config_saved",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "Mandant-LDAP-Konfiguration gespeichert (tenant " + strconv.FormatInt(id, 10) + ")",
@@ -723,6 +732,7 @@ func (s *Server) handleAdminDeleteTenantLDAP(w http.ResponseWriter, r *http.Requ
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_config_deleted",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "Mandant-LDAP-Konfiguration gelöscht (tenant " + strconv.FormatInt(id, 10) + ")",
@@ -763,6 +773,7 @@ func (s *Server) handleAdminTestTenantLDAP(w http.ResponseWriter, r *http.Reques
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_connection_test",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: result.OK,
Detail: result.Message + " (tenant " + strconv.FormatInt(id, 10) + ")",
@@ -847,6 +858,7 @@ func (s *Server) handleDeleteTenantLogo(w http.ResponseWriter, r *http.Request)
s.audlog.Log(audit.Entry{
EventType: "tenant_logo_deleted",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "Mandant-Logo gelöscht (tenant " + strconv.FormatInt(id, 10) + ")",
@@ -914,6 +926,7 @@ func (s *Server) handleDeleteOwnTenantLogo(w http.ResponseWriter, r *http.Reques
s.audlog.Log(audit.Entry{
EventType: "tenant_logo_deleted",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "Mandant-Logo gelöscht",
@@ -970,6 +983,7 @@ func (s *Server) saveTenantLogo(w http.ResponseWriter, r *http.Request, tenantID
s.audlog.Log(audit.Entry{
EventType: "tenant_logo_uploaded",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: fmt.Sprintf("Mandant-Logo hochgeladen (%d bytes, %s, tenant %d)", len(data), contentType, tenantID),
+5 -2
View File
@@ -38,8 +38,10 @@ func (s *Server) handleGetOCRText(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")
@@ -143,6 +145,7 @@ func (s *Server) handleGetOCRText(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventOCRDownload,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
MailID: id,
Success: true,
+3
View File
@@ -61,6 +61,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
if s.audlog != nil {
s.audlog.Log(audit.Entry{
EventType: "invite_used",
TenantID: inviteTok.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
@@ -109,6 +110,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "signup",
Username: u.Username,
TenantID: u.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
@@ -201,6 +203,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "password_reset_requested",
Username: u.Username,
TenantID: u.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
+2
View File
@@ -82,6 +82,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "change_password",
@@ -143,6 +144,7 @@ func (s *Server) handleChangeEmail(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "change_email",
+1
View File
@@ -106,6 +106,7 @@ func (s *Server) handleSetTenantQuota(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "tenant_quota_changed",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: fmt.Sprintf("tenant_id=%d", tenantID),
+1
View File
@@ -64,6 +64,7 @@ func (s *Server) handleSetTenantRetention(w http.ResponseWriter, r *http.Request
s.audlog.Log(audit.Entry{
EventType: "tenant_retention_changed",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: fmt.Sprintf("tenant_id=%d retention_days=%d", tenantID, body.RetentionDays),
+2
View File
@@ -83,6 +83,7 @@ func (s *Server) handleCreateSavedSearch(w http.ResponseWriter, r *http.Request)
s.audlog.Log(audit.Entry{
EventType: "saved_search_create",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("saved search id=%d name=%q", ss.ID, name),
Success: true,
@@ -122,6 +123,7 @@ func (s *Server) handleDeleteSavedSearch(w http.ResponseWriter, r *http.Request)
s.audlog.Log(audit.Entry{
EventType: "saved_search_delete",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("saved search id=%d", id),
Success: true,
+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")
+2
View File
@@ -58,6 +58,7 @@ func (s *Server) handleSaveSMTPOut(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "smtp_out_config_saved",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
@@ -81,6 +82,7 @@ func (s *Server) handleDeleteSMTPOut(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: "smtp_out_config_deleted",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
+23
View File
@@ -36,6 +36,22 @@ func (s *Server) handleGetThread(w http.ResponseWriter, r *http.Request) {
return
}
// PROJ-55: a global auditor (no tenant_id) may only see mails without a
// tenant assignment. A tenant-scoped auditor (tenant_id set) is already
// constrained by GetMailsByThread's tenant filter above.
var auditorAllowed map[string]struct{}
if auditorIsGlobal(sess) {
noTenant, idErr := s.store.GetAllIDsWithoutTenant(r.Context())
if idErr != nil {
writeError(w, http.StatusInternalServerError, "access check failed")
return
}
auditorAllowed = make(map[string]struct{}, len(noTenant))
for _, nid := range noTenant {
auditorAllowed[nid] = struct{}{}
}
}
type mailSummary struct {
ID string `json:"id"`
From string `json:"from,omitempty"`
@@ -63,6 +79,13 @@ func (s *Server) handleGetThread(w http.ResponseWriter, r *http.Request) {
}
}
// global auditor isolation: skip mails that belong to a tenant.
if auditorAllowed != nil {
if _, ok := auditorAllowed[id]; !ok {
continue
}
}
var dateStr string
if !pm.Date.IsZero() {
dateStr = pm.Date.UTC().Format(time.RFC3339)
+4
View File
@@ -103,6 +103,7 @@ func (s *Server) handleTOTPSetupPost(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: "totp_enabled",
Success: true,
@@ -156,6 +157,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: "totp_disabled",
Success: true,
@@ -193,6 +195,7 @@ func (s *Server) handleTOTPLogin(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventLogin,
Username: user.Username,
TenantID: user.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: "totp_login_completed",
@@ -254,6 +257,7 @@ func (s *Server) handleTOTPReset(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventUserMgmt,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("totp_reset_by_admin: TOTP reset by %s for user %s (id=%d)", sess.Username, target.Username, id),
Success: true,
+12
View File
@@ -13,6 +13,15 @@ import (
"archivmail/pkg/mailparser"
)
// apiKeyTenantPtr converts an API-key session tenant id (0 = no tenant) into a
// *int64 for audit logging (PROJ-55). Returns nil for the tenant-less case.
func apiKeyTenantPtr(id int64) *int64 {
if id == 0 {
return nil
}
return &id
}
// handleV1MethodNotAllowed returns 405 for non-GET methods on v1 endpoints.
func (s *Server) handleV1MethodNotAllowed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "GET")
@@ -121,6 +130,7 @@ func (s *Server) handleV1SearchMails(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventSearch,
Username: fmt.Sprintf("apikey:%s", akSess.KeyName),
TenantID: apiKeyTenantPtr(akSess.TenantID),
Query: q,
Detail: fmt.Sprintf("v1_api contact=%s from=%s to=%s", contactFilter, fromFilter, toFilter),
Success: true,
@@ -217,6 +227,7 @@ func (s *Server) handleV1GetMail(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventMailView,
Username: fmt.Sprintf("apikey:%s", akSess.KeyName),
TenantID: apiKeyTenantPtr(akSess.TenantID),
MailID: id,
Detail: "v1_api",
Success: true,
@@ -303,6 +314,7 @@ func (s *Server) handleV1GetMailRaw(w http.ResponseWriter, r *http.Request) {
s.audlog.Log(audit.Entry{
EventType: audit.EventExport,
Username: fmt.Sprintf("apikey:%s", akSess.KeyName),
TenantID: apiKeyTenantPtr(akSess.TenantID),
MailID: id,
Detail: "v1_api raw download",
Success: true,