From 994e5d16fc497a0d1e4688cbdbb645c22dfb3f4c Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 4 Apr 2026 19:34:45 +0200 Subject: [PATCH] feat: auditor sieht Mails ohne Tenant-Zuordnung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/api/export.go | 41 +++++++++++++++++--- internal/api/search_handlers.go | 67 ++++++++++++++++++++++++++++----- internal/storage/storage.go | 49 ++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 16 deletions(-) diff --git a/internal/api/export.go b/internal/api/export.go index f07c712..af18144 100644 --- a/internal/api/export.go +++ b/internal/api/export.go @@ -356,8 +356,17 @@ func (s *Server) handleExportPDF(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") @@ -437,9 +446,23 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) { } } - // User and Auditor: look up email once for per-mail access checks + // Auditor: pre-load the set of no-tenant mail IDs for efficient per-mail checks. + var auditorAllowed map[string]struct{} + if sess.Role == userstore.RoleAuditor { + ids, err := s.store.GetAllIDsWithoutTenant(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "tenant check failed") + return + } + auditorAllowed = make(map[string]struct{}, len(ids)) + for _, aid := range ids { + auditorAllowed[aid] = struct{}{} + } + } + + // User: look up email once for per-mail access checks. var userEmail string - if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor { + if sess.Role == userstore.RoleUser { u, err := s.users.GetByUsername(sess.Username) if err != nil { writeError(w, http.StatusInternalServerError, "user lookup failed") @@ -476,8 +499,14 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) { continue } - // user and auditor: only own mails; domain_auditor: all tenant mails (no filter) - if (sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor) && !mailBelongsToUser(pm, userEmail) { + // auditor: only mails with no tenant assignment. + if auditorAllowed != nil { + if _, ok := auditorAllowed[id]; !ok { + continue + } + } + // user: only own mails; domain_auditor: all tenant mails (no filter) + if sess.Role == userstore.RoleUser && !mailBelongsToUser(pm, userEmail) { continue } diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index c7b23ca..f4611e0 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -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") diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 534414d..3ce4698 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -970,6 +970,55 @@ func (s *Store) StatsByTenant(ctx context.Context, tenantID *int64) (map[string] }, nil } +// IsWithoutTenant reports whether a mail has no tenant assignment — +// neither a direct tenant_id nor any email_refs entry. +// Used by the auditor role to gate direct mail access. +func (s *Store) IsWithoutTenant(ctx context.Context, id string) (bool, error) { + if s.db == nil { + return false, nil + } + var result bool + err := s.db.QueryRow(ctx, ` + SELECT (e.tenant_id IS NULL) + AND NOT EXISTS (SELECT 1 FROM email_refs r WHERE r.email_id = e.id) + FROM emails e WHERE e.id = $1 + `, id).Scan(&result) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("storage: is without tenant: %w", err) + } + return result, nil +} + +// GetAllIDsWithoutTenant returns email IDs that have no tenant assignment. +// Used for the auditor role: access to global (untenanted) mails only. +func (s *Store) GetAllIDsWithoutTenant(ctx context.Context) ([]string, error) { + if s.db == nil { + return nil, nil + } + rows, err := s.db.Query(ctx, ` + SELECT e.id FROM emails e + WHERE e.tenant_id IS NULL + AND NOT EXISTS (SELECT 1 FROM email_refs r WHERE r.email_id = e.id) + ORDER BY e.received_at + `) + if err != nil { + return nil, fmt.Errorf("storage: get ids without tenant: %w", err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + ids = append(ids, id) + } + return ids, rows.Err() +} + // GetAllIDs returns all email IDs from the DB, or walks the store if no DB. func (s *Store) GetAllIDs(ctx context.Context) ([]string, error) { if s.db != nil {