diff --git a/features/INDEX.md b/features/INDEX.md index 3f2ca50..d201b7e 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -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-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 | + -## Next Available ID: PROJ-27 +## Next Available ID: PROJ-34 diff --git a/internal/api/export.go b/internal/api/export.go index 47d9d9f..f07c712 100644 --- a/internal/api/export.go +++ b/internal/api/export.go @@ -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 diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index fd640c3..9eeea8e 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -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") diff --git a/internal/api/server.go b/internal/api/server.go index d8218c3..bee6fca 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) } } diff --git a/internal/userstore/userstore.go b/internal/userstore/userstore.go index a5b4ceb..e26faf2 100644 --- a/internal/userstore/userstore.go +++ b/internal/userstore/userstore.go @@ -12,11 +12,12 @@ import ( ) const ( - RoleUser = "user" - RoleAdmin = "admin" - RoleAuditor = "auditor" - RoleDomainAdmin = "domain_admin" - RoleSuperAdmin = "superadmin" + RoleUser = "user" + RoleAdmin = "admin" // legacy, maps to domain_admin + RoleAuditor = "auditor" + RoleDomainAdmin = "domain_admin" + RoleDomainAuditor = "domain_auditor" + RoleSuperAdmin = "superadmin" bcryptCost = 12 )