fix(SEC-29): Rollen-Trennung Admins/Auditoren, domain_auditor Rolle
- superadmin + domain_admin haben keinen Mail-Zugriff mehr (requireMailAccess) - Neue Rolle domain_auditor: alle Tenant-Mails, kein Admin-Zugriff - auditor + user: nur eigene Mails - ZIP-Export: kein separates Attachment-Entpacken mehr, nur EML - roleLevel() um domain_auditor (Level 3) erweitert Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+7
-27
@@ -356,8 +356,8 @@ func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// User role: only own mailbox
|
||||
if sess.Role == userstore.RoleUser {
|
||||
// user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
|
||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
||||
u, err := s.users.GetByUsername(sess.Username)
|
||||
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
@@ -393,8 +393,7 @@ func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
Attachments bool `json:"attachments"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
@@ -438,9 +437,9 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// For RoleUser, look up user email once for access checks
|
||||
// User and Auditor: look up email once for per-mail access checks
|
||||
var userEmail string
|
||||
if sess.Role == userstore.RoleUser {
|
||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
||||
u, err := s.users.GetByUsername(sess.Username)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "user lookup failed")
|
||||
@@ -477,8 +476,8 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Access check for user role
|
||||
if sess.Role == userstore.RoleUser && !mailBelongsToUser(pm, userEmail) {
|
||||
// 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) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -509,25 +508,6 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
|
||||
date: dateStr,
|
||||
})
|
||||
exported++
|
||||
|
||||
// Attachments
|
||||
if req.Attachments {
|
||||
shortID := id
|
||||
if len(shortID) > 8 {
|
||||
shortID = shortID[:8]
|
||||
}
|
||||
for _, a := range pm.Attachments {
|
||||
if a.Filename == "" || len(a.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
attPath := fmt.Sprintf("attachments/%s/%s", shortID, a.Filename)
|
||||
af, err := zw.Create(attPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
af.Write(a.Data) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write manifest.csv
|
||||
|
||||
@@ -231,8 +231,8 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// user role: only own mailbox
|
||||
if sess.Role == userstore.RoleUser {
|
||||
// user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
|
||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
||||
u, err := s.users.GetByUsername(sess.Username)
|
||||
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
@@ -326,7 +326,8 @@ func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if sess.Role == userstore.RoleUser {
|
||||
// user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
|
||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
||||
u, err := s.users.GetByUsername(sess.Username)
|
||||
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
@@ -377,8 +378,8 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// SEC-28: Access check for user role — parse failure must NOT grant access.
|
||||
if sess.Role == userstore.RoleUser {
|
||||
// SEC-28/29: User and Auditor: only own mails. Parse failure must NOT grant access.
|
||||
if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
|
||||
pm, err := mailparser.Parse(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to parse mail")
|
||||
|
||||
+18
-10
@@ -34,14 +34,17 @@ func isValidMailID(id string) bool {
|
||||
}
|
||||
|
||||
// roleLevel returns the privilege level for a role string.
|
||||
// Hierarchy: superadmin=5 > admin=4 > domain_admin=3 > auditor=2 > user=1
|
||||
// Hierarchy: superadmin=5 > domain_admin=4 > domain_auditor=3 > auditor=2 > user=1
|
||||
// Separation of duties: admins (superadmin, domain_admin) have NO mail access.
|
||||
// Mail access: domain_auditor (all tenant mails), auditor (own mails), user (own mails).
|
||||
func roleLevel(role string) int {
|
||||
levels := map[string]int{
|
||||
userstore.RoleUser: 1,
|
||||
userstore.RoleAuditor: 2,
|
||||
userstore.RoleDomainAdmin: 3,
|
||||
userstore.RoleAdmin: 4,
|
||||
userstore.RoleSuperAdmin: 5,
|
||||
userstore.RoleUser: 1,
|
||||
userstore.RoleAuditor: 2,
|
||||
userstore.RoleDomainAuditor: 3,
|
||||
userstore.RoleDomainAdmin: 4,
|
||||
userstore.RoleAdmin: 4, // legacy alias for domain_admin
|
||||
userstore.RoleSuperAdmin: 5,
|
||||
}
|
||||
return levels[role]
|
||||
}
|
||||
@@ -261,10 +264,9 @@ func (s *Server) requireRole(role string, next http.HandlerFunc) http.HandlerFun
|
||||
// ── Mail access middleware ────────────────────────────────────────────────
|
||||
|
||||
// requireMailAccess checks that the caller may read mail content.
|
||||
// superadmin and domain_admin have read access (tenant-scoped via handleGetMail).
|
||||
// Auditor and user have access to their own mails.
|
||||
// The old "admin" role (now domain_admin) previously had no mail access — that
|
||||
// restriction is removed; domain_admin now needs to be able to read archived mails.
|
||||
// SEC-29: Strict separation of duties — admins manage, auditors review.
|
||||
// Mail access is granted ONLY to: user, auditor, domain_auditor.
|
||||
// superadmin and domain_admin are explicitly denied (manage system/tenant, not content).
|
||||
func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
@@ -272,6 +274,12 @@ func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
// SEC-29: Admins must not access mail content.
|
||||
switch sess.Role {
|
||||
case userstore.RoleSuperAdmin, userstore.RoleDomainAdmin:
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user