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:
sysops
2026-03-31 01:09:19 +02:00
parent e0f6a818eb
commit 64433aa847
5 changed files with 47 additions and 48 deletions
+10 -1
View File
@@ -41,6 +41,15 @@
| PROJ-26 | IMAP-Server-Schnittstelle (Read-Only Archivzugriff) | In Progress | [PROJ-26](PROJ-26-imap-server-schnittstelle.md) | 2026-03-18 | | PROJ-26 | IMAP-Server-Schnittstelle (Read-Only Archivzugriff) | In Progress | [PROJ-26](PROJ-26-imap-server-schnittstelle.md) | 2026-03-18 |
| PROJ-27 | Container-Ready (Dockerfile + Env-Vars) | In Review | [PROJ-27](PROJ-27-container-ready.md) | 2026-03-28 |
| PROJ-28 | Self-Service Onboarding (Sign-up, E-Mail-Verifikation, Passwort-Reset) | Planned | [PROJ-28](PROJ-28-self-service-onboarding.md) | 2026-03-28 |
| PROJ-29 | Tenant-Quotas & Usage-Limits | Planned | [PROJ-29](PROJ-29-tenant-quotas.md) | 2026-03-28 |
| PROJ-30 | Volltext-Index: Xapian → Manticore Search Migration | Planned | [PROJ-30](PROJ-30-bleve-migration.md) | 2026-03-28 |
| PROJ-31 | Billing & Subscriptions (Stripe) | Planned | [PROJ-31](PROJ-31-billing-subscriptions.md) | 2026-03-28 |
| PROJ-32 | Message-ID-basierte Duplikatserkennung | Planned | [PROJ-32](PROJ-32-message-id-dedup.md) | 2026-03-31 |
| PROJ-33 | IMAP-Modus: Gemeinsames Archiv vs. Persönlicher Posteingang | Planned | [PROJ-33](PROJ-33-imap-modus-shared-personal.md) | 2026-03-31 |
<!-- Add features above this line --> <!-- Add features above this line -->
## Next Available ID: PROJ-27 ## Next Available ID: PROJ-34
+7 -27
View File
@@ -356,8 +356,8 @@ func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
} }
} }
// User role: only own mailbox // user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser { if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
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")
@@ -393,8 +393,7 @@ func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) { func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
IDs []string `json:"ids"` IDs []string `json:"ids"`
Attachments bool `json:"attachments"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body") 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 var userEmail string
if sess.Role == userstore.RoleUser { if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
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")
@@ -477,8 +476,8 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
continue continue
} }
// Access check for user role // user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser && !mailBelongsToUser(pm, userEmail) { if (sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor) && !mailBelongsToUser(pm, userEmail) {
continue continue
} }
@@ -509,25 +508,6 @@ func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) {
date: dateStr, date: dateStr,
}) })
exported++ 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 // Write manifest.csv
+6 -5
View File
@@ -231,8 +231,8 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
} }
} }
// user role: only own mailbox // user and auditor: only own mails; domain_auditor: all tenant mails (no filter)
if sess.Role == userstore.RoleUser { if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
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")
@@ -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) 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")
@@ -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. // SEC-28/29: User and Auditor: only own mails. Parse failure must NOT grant access.
if sess.Role == userstore.RoleUser { if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor {
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")
+18 -10
View File
@@ -34,14 +34,17 @@ func isValidMailID(id string) bool {
} }
// roleLevel returns the privilege level for a role string. // 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 { func roleLevel(role string) int {
levels := map[string]int{ levels := map[string]int{
userstore.RoleUser: 1, userstore.RoleUser: 1,
userstore.RoleAuditor: 2, userstore.RoleAuditor: 2,
userstore.RoleDomainAdmin: 3, userstore.RoleDomainAuditor: 3,
userstore.RoleAdmin: 4, userstore.RoleDomainAdmin: 4,
userstore.RoleSuperAdmin: 5, userstore.RoleAdmin: 4, // legacy alias for domain_admin
userstore.RoleSuperAdmin: 5,
} }
return levels[role] return levels[role]
} }
@@ -261,10 +264,9 @@ func (s *Server) requireRole(role string, next http.HandlerFunc) http.HandlerFun
// ── Mail access middleware ──────────────────────────────────────────────── // ── Mail access middleware ────────────────────────────────────────────────
// requireMailAccess checks that the caller may read mail content. // requireMailAccess checks that the caller may read mail content.
// superadmin and domain_admin have read access (tenant-scoped via handleGetMail). // SEC-29: Strict separation of duties — admins manage, auditors review.
// Auditor and user have access to their own mails. // Mail access is granted ONLY to: user, auditor, domain_auditor.
// The old "admin" role (now domain_admin) previously had no mail access — that // superadmin and domain_admin are explicitly denied (manage system/tenant, not content).
// restriction is removed; domain_admin now needs to be able to read archived mails.
func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc { func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context()) sess := sessionFromCtx(r.Context())
@@ -272,6 +274,12 @@ func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
return 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) next(w, r)
} }
} }
+6 -5
View File
@@ -12,11 +12,12 @@ import (
) )
const ( const (
RoleUser = "user" RoleUser = "user"
RoleAdmin = "admin" RoleAdmin = "admin" // legacy, maps to domain_admin
RoleAuditor = "auditor" RoleAuditor = "auditor"
RoleDomainAdmin = "domain_admin" RoleDomainAdmin = "domain_admin"
RoleSuperAdmin = "superadmin" RoleDomainAuditor = "domain_auditor"
RoleSuperAdmin = "superadmin"
bcryptCost = 12 bcryptCost = 12
) )