feat: auditor sieht Mails ohne Tenant-Zuordnung

- auditor-Rolle sieht jetzt Mails wo tenant_id IS NULL und kein
  email_refs-Eintrag existiert (statt nur eigene Mails)
- Neues storage.IsWithoutTenant() für effizienten Direktzugriff
- Neues storage.GetAllIDsWithoutTenant() für Suche + ZIP-Export
- Konsistente Prüfung in Search, GetMail, GetAttachment, GetRaw, Export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-04 19:34:45 +02:00
parent c8ab4afef0
commit 994e5d16fc
3 changed files with 141 additions and 16 deletions
+57 -10
View File
@@ -179,11 +179,25 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs)
}
// SEC: For user and auditor roles, restrict results to mails the user is
// involved in (From, To, or CC). Email comes from the JWT session — no DB
// lookup needed. If email is missing, block all results (fail-safe).
// auditor role: restrict results to mails with no tenant assignment.
var auditorAllowedIDs map[string]struct{}
if sess.Role == userstore.RoleAuditor {
ids, idErr := s.store.GetAllIDsWithoutTenant(r.Context())
if idErr != nil {
writeError(w, http.StatusInternalServerError, "failed to load mail list")
return
}
auditorAllowedIDs = make(map[string]struct{}, len(ids))
for _, id := range ids {
auditorAllowedIDs[id] = struct{}{}
}
}
// SEC: For user role, restrict results to mails the user is involved in
// (From, To, or CC). Email comes from the JWT session — no DB lookup needed.
// If email is missing, block all results (fail-safe).
var userEmailFilter string
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
if sess.Role == userstore.RoleUser {
userEmailFilter = strings.ToLower(sess.Email)
if userEmailFilter == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"total": 0, "hits": []interface{}{}})
@@ -215,6 +229,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
// If mail can't be parsed, deny access to user role.
continue
}
// Auditor isolation: skip mails that belong to a tenant.
if auditorAllowedIDs != nil {
if _, ok := auditorAllowedIDs[h.ID]; !ok {
continue
}
}
}
if labelMap != nil {
eh.LabelIDs = labelMap[h.ID]
@@ -259,8 +279,17 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
}
}
// user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
// auditor: only mails with no tenant assignment.
if sess.Role == userstore.RoleAuditor {
ok, err := s.store.IsWithoutTenant(r.Context(), id)
if err != nil || !ok {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// user: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser {
if sess.Email == "" || !mailBelongsToUser(pm, sess.Email) {
writeError(w, http.StatusForbidden, "access denied")
return
@@ -353,8 +382,17 @@ func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
}
}
// user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
// auditor: only mails with no tenant assignment.
if sess.Role == userstore.RoleAuditor {
ok, err := s.store.IsWithoutTenant(r.Context(), id)
if err != nil || !ok {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// user: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser {
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
writeError(w, http.StatusForbidden, "access denied")
@@ -405,8 +443,17 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
}
}
// SEC-28/29: User and Auditor: only own mails. Parse failure must NOT grant access.
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
// auditor: only mails with no tenant assignment.
if sess.Role == userstore.RoleAuditor {
ok, err := s.store.IsWithoutTenant(r.Context(), id)
if err != nil || !ok {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// SEC-28/29: User only: own mails. Parse failure must NOT grant access.
if sess.Role == userstore.RoleUser {
pm, err := mailparser.Parse(raw)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to parse mail")