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:
+35
-6
@@ -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)
|
// auditor: only mails with no tenant assignment.
|
||||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
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)
|
u, err := s.users.GetByUsername(sess.Username)
|
||||||
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
||||||
writeError(w, http.StatusForbidden, "access denied")
|
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
|
var userEmail string
|
||||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
if sess.Role == userstore.RoleUser {
|
||||||
u, err := s.users.GetByUsername(sess.Username)
|
u, err := s.users.GetByUsername(sess.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "user lookup failed")
|
writeError(w, http.StatusInternalServerError, "user lookup failed")
|
||||||
@@ -476,8 +499,14 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
|
// auditor: only mails with no tenant assignment.
|
||||||
if (sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor) && !mailBelongsToUser(pm, userEmail) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,11 +179,25 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs)
|
labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SEC: For user and auditor roles, restrict results to mails the user is
|
// auditor role: restrict results to mails with no tenant assignment.
|
||||||
// involved in (From, To, or CC). Email comes from the JWT session — no DB
|
var auditorAllowedIDs map[string]struct{}
|
||||||
// lookup needed. If email is missing, block all results (fail-safe).
|
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
|
var userEmailFilter string
|
||||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
if sess.Role == userstore.RoleUser {
|
||||||
userEmailFilter = strings.ToLower(sess.Email)
|
userEmailFilter = strings.ToLower(sess.Email)
|
||||||
if userEmailFilter == "" {
|
if userEmailFilter == "" {
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"total": 0, "hits": []interface{}{}})
|
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.
|
// If mail can't be parsed, deny access to user role.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Auditor isolation: skip mails that belong to a tenant.
|
||||||
|
if auditorAllowedIDs != nil {
|
||||||
|
if _, ok := auditorAllowedIDs[h.ID]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if labelMap != nil {
|
if labelMap != nil {
|
||||||
eh.LabelIDs = labelMap[h.ID]
|
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)
|
// auditor: only mails with no tenant assignment.
|
||||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
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) {
|
if sess.Email == "" || !mailBelongsToUser(pm, sess.Email) {
|
||||||
writeError(w, http.StatusForbidden, "access denied")
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
return
|
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)
|
// auditor: only mails with no tenant assignment.
|
||||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
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)
|
u, err := s.users.GetByUsername(sess.Username)
|
||||||
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
||||||
writeError(w, http.StatusForbidden, "access denied")
|
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.
|
// auditor: only mails with no tenant assignment.
|
||||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
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)
|
pm, err := mailparser.Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to parse mail")
|
writeError(w, http.StatusInternalServerError, "failed to parse mail")
|
||||||
|
|||||||
@@ -970,6 +970,55 @@ func (s *Store) StatsByTenant(ctx context.Context, tenantID *int64) (map[string]
|
|||||||
}, nil
|
}, 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.
|
// GetAllIDs returns all email IDs from the DB, or walks the store if no DB.
|
||||||
func (s *Store) GetAllIDs(ctx context.Context) ([]string, error) {
|
func (s *Store) GetAllIDs(ctx context.Context) ([]string, error) {
|
||||||
if s.db != nil {
|
if s.db != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user