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:
+10
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user