feat(PROJ-21): Phase 2+3+5+8 Multi-Tenancy + PROJ-2 EML/MBOX Upload

Phase 2a: userstore domain_admin/superadmin Rollen, User.TenantID,
          ListByTenant, UpsertLDAPUser mit tenantID
Phase 2b: storage.Save() mit tenantID *int64, email_refs Tabelle,
          GetTenantForMail, GetAllIDsByTenant, StatsByTenant
Phase 2c: JWT-Claims tenant_id/tenant_slug, Session.TenantID,
          Login Domain-Erkennung via E-Mail-Domain
Phase 3:  tenantMiddleware, Handler-Filterung (Users, Mail, Stats)
Phase 5:  SMTP Domain-Routing via DomainToTenantFunc Callback,
          config smtp.tenant_routing + default_tenant_id
Phase 8:  archivmail migrate-tenants Subkommando
PROJ-2:   Upload-Seite /admin/upload mit DropZone + Progress-Polling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 21:03:40 +01:00
parent 5250ffcd52
commit 479c27e5a8
16 changed files with 966 additions and 158 deletions
+121 -45
View File
@@ -33,7 +33,10 @@ import (
type contextKey string
const sessionKey contextKey = "session"
const (
sessionKey contextKey = "session"
tenantKey contextKey = "tenant_id"
)
// Server is the archivmail HTTP API server.
type Server struct {
@@ -98,58 +101,68 @@ func New(
return s
}
// auth wraps a handler with authentication + tenant context propagation.
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
return s.authMiddleware(s.tenantMiddleware(h))
}
// authAdmin wraps a handler requiring at least admin role.
func (s *Server) authAdmin(h http.HandlerFunc) http.HandlerFunc {
return s.authMiddleware(s.tenantMiddleware(s.requireRole(userstore.RoleDomainAdmin, h)))
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /api/health", s.handleHealth)
s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
s.mux.HandleFunc("GET /api/auth/me", s.authMiddleware(s.handleMe))
s.mux.HandleFunc("POST /api/auth/logout", s.authMiddleware(s.handleLogout))
s.mux.HandleFunc("GET /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListUsers)))
s.mux.HandleFunc("POST /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleCreateUser)))
s.mux.HandleFunc("PATCH /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpdateUser)))
s.mux.HandleFunc("DELETE /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteUser)))
s.mux.HandleFunc("GET /api/search", s.authMiddleware(s.handleSearch))
s.mux.HandleFunc("GET /api/audit", s.authMiddleware(s.requireRole(userstore.RoleAuditor, s.handleAuditLog)))
s.mux.HandleFunc("GET /api/admin/smtp/status", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSMTPStatus)))
s.mux.HandleFunc("GET /api/admin/storage/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleStorageStats)))
s.mux.HandleFunc("GET /api/mails/{id}", s.authMiddleware(s.requireMailAccess(s.handleGetMail)))
s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.authMiddleware(s.requireMailAccess(s.handleGetAttachment)))
s.mux.HandleFunc("GET /api/mails/{id}/raw", s.authMiddleware(s.requireMailAccess(s.handleGetRaw)))
s.mux.HandleFunc("GET /api/admin/services", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListServices)))
s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleServiceAction)))
s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe))
s.mux.HandleFunc("POST /api/auth/logout", s.auth(s.handleLogout))
s.mux.HandleFunc("GET /api/users", s.authAdmin(s.handleListUsers))
s.mux.HandleFunc("POST /api/users", s.authAdmin(s.handleCreateUser))
s.mux.HandleFunc("PATCH /api/users/{id}", s.authAdmin(s.handleUpdateUser))
s.mux.HandleFunc("DELETE /api/users/{id}", s.authAdmin(s.handleDeleteUser))
s.mux.HandleFunc("GET /api/search", s.auth(s.handleSearch))
s.mux.HandleFunc("GET /api/audit", s.auth(s.requireRole(userstore.RoleAuditor, s.handleAuditLog)))
s.mux.HandleFunc("GET /api/admin/smtp/status", s.authAdmin(s.handleSMTPStatus))
s.mux.HandleFunc("GET /api/admin/storage/stats", s.authAdmin(s.handleStorageStats))
s.mux.HandleFunc("GET /api/mails/{id}", s.auth(s.requireMailAccess(s.handleGetMail)))
s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.auth(s.requireMailAccess(s.handleGetAttachment)))
s.mux.HandleFunc("GET /api/mails/{id}/raw", s.auth(s.requireMailAccess(s.handleGetRaw)))
s.mux.HandleFunc("GET /api/admin/services", s.authAdmin(s.handleListServices))
s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authAdmin(s.handleServiceAction))
s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats)))
s.mux.HandleFunc("GET /api/admin/security/audit", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityAudit)))
s.mux.HandleFunc("POST /api/admin/security/fix", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityFix)))
s.mux.HandleFunc("GET /api/admin/system/stats", s.authAdmin(s.handleSystemStats))
s.mux.HandleFunc("GET /api/admin/security/audit", s.authAdmin(s.handleSecurityAudit))
s.mux.HandleFunc("POST /api/admin/security/fix", s.authAdmin(s.handleSecurityFix))
// Export routes
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF)))
s.mux.HandleFunc("POST /api/export/zip", s.authMiddleware(s.requireMailAccess(s.handleExportZIP)))
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF)))
s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP)))
// Upload routes (admin only)
s.mux.HandleFunc("POST /api/admin/upload", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpload)))
s.mux.HandleFunc("GET /api/admin/upload/{jobID}/progress", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUploadProgress)))
s.mux.HandleFunc("POST /api/admin/upload", s.authAdmin(s.handleUpload))
s.mux.HandleFunc("GET /api/admin/upload/{jobID}/progress", s.authAdmin(s.handleUploadProgress))
// Upload routes (all authenticated users)
s.mux.HandleFunc("POST /api/upload", s.authMiddleware(s.handleUpload))
s.mux.HandleFunc("GET /api/upload/{jobID}/progress", s.authMiddleware(s.handleUploadProgress))
s.mux.HandleFunc("POST /api/upload", s.auth(s.handleUpload))
s.mux.HandleFunc("GET /api/upload/{jobID}/progress", s.auth(s.handleUploadProgress))
// IMAP routes (accessible to all authenticated users)
s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap))
s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap))
s.mux.HandleFunc("DELETE /api/imap/{id}", s.authMiddleware(s.handleDeleteImap))
s.mux.HandleFunc("PATCH /api/imap/{id}", s.authMiddleware(s.handleUpdateImapInterval))
s.mux.HandleFunc("POST /api/imap/test", s.authMiddleware(s.handleTestImap))
s.mux.HandleFunc("POST /api/imap/{id}/import", s.authMiddleware(s.handleStartImport))
s.mux.HandleFunc("GET /api/imap/{id}/progress", s.authMiddleware(s.handleImapProgress))
s.mux.HandleFunc("POST /api/imap/{id}/sync", s.authMiddleware(s.handleSyncNow))
s.mux.HandleFunc("GET /api/imap", s.auth(s.handleListImap))
s.mux.HandleFunc("POST /api/imap", s.auth(s.handleCreateImap))
s.mux.HandleFunc("DELETE /api/imap/{id}", s.auth(s.handleDeleteImap))
s.mux.HandleFunc("PATCH /api/imap/{id}", s.auth(s.handleUpdateImapInterval))
s.mux.HandleFunc("POST /api/imap/test", s.auth(s.handleTestImap))
s.mux.HandleFunc("POST /api/imap/{id}/import", s.auth(s.handleStartImport))
s.mux.HandleFunc("GET /api/imap/{id}/progress", s.auth(s.handleImapProgress))
s.mux.HandleFunc("POST /api/imap/{id}/sync", s.auth(s.handleSyncNow))
// POP3 routes (accessible to all authenticated users)
s.mux.HandleFunc("GET /api/pop3", s.authMiddleware(s.handleListPop3))
s.mux.HandleFunc("POST /api/pop3", s.authMiddleware(s.handleCreatePop3))
s.mux.HandleFunc("DELETE /api/pop3/{id}", s.authMiddleware(s.handleDeletePop3))
s.mux.HandleFunc("POST /api/pop3/test", s.authMiddleware(s.handleTestPop3))
s.mux.HandleFunc("POST /api/pop3/{id}/import", s.authMiddleware(s.handleStartPop3Import))
s.mux.HandleFunc("GET /api/pop3/{id}/progress", s.authMiddleware(s.handlePop3Progress))
s.mux.HandleFunc("GET /api/pop3", s.auth(s.handleListPop3))
s.mux.HandleFunc("POST /api/pop3", s.auth(s.handleCreatePop3))
s.mux.HandleFunc("DELETE /api/pop3/{id}", s.auth(s.handleDeletePop3))
s.mux.HandleFunc("POST /api/pop3/test", s.auth(s.handleTestPop3))
s.mux.HandleFunc("POST /api/pop3/{id}/import", s.auth(s.handleStartPop3Import))
s.mux.HandleFunc("GET /api/pop3/{id}/progress", s.auth(s.handlePop3Progress))
}
// ServeHTTP implements http.Handler.
@@ -286,7 +299,17 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.users.List("")
tenantID := tenantFromCtx(r.Context())
var (
users []*userstore.User
err error
)
if tenantID != nil {
users, err = s.users.ListByTenant(r.Context(), *tenantID)
} else {
users, err = s.users.List("")
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list users")
return
@@ -298,6 +321,7 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
TenantID *int64 `json:"tenant_id,omitempty"`
}
resp := make([]userResp, 0, len(users))
@@ -308,6 +332,7 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
Email: u.Email,
Role: u.Role,
Active: u.Active,
TenantID: u.TenantID,
})
}
writeJSON(w, http.StatusOK, resp)
@@ -572,14 +597,15 @@ func (s *Server) handleSMTPStatus(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleStorageStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.Stats()
tenantID := tenantFromCtx(r.Context())
stats, err := s.store.StatsByTenant(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read storage stats")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"total_mails": stats.TotalMails,
"total_bytes": stats.TotalBytes,
"total_mails": stats["count"],
"total_bytes": stats["total_size"],
})
}
@@ -684,6 +710,26 @@ func sessionFromCtx(ctx context.Context) *auth.Session {
return &auth.Session{}
}
// tenantMiddleware extracts the tenant_id from the session and stores it in
// the request context, making it available to all downstream handlers.
func (s *Server) tenantMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := sessionFromCtx(r.Context())
if session != nil && session.TenantID != nil {
ctx := context.WithValue(r.Context(), tenantKey, session.TenantID)
next(w, r.WithContext(ctx))
return
}
next(w, r)
}
}
// tenantFromCtx extracts the tenant_id from context. Returns nil for global (superadmin) context.
func tenantFromCtx(ctx context.Context) *int64 {
v, _ := ctx.Value(tenantKey).(*int64)
return v
}
func remoteIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return strings.Split(fwd, ",")[0]
@@ -722,8 +768,18 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
return
}
// user role: only own mailbox
sess := sessionFromCtx(r.Context())
// Tenant isolation: domain_admin sees only own tenant's mail
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// user role: only own mailbox
if sess.Role == userstore.RoleUser {
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
@@ -803,6 +859,16 @@ func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
}
sess := sessionFromCtx(r.Context())
// Tenant isolation
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
if sess.Role == userstore.RoleUser {
u, err := s.users.GetByUsername(sess.Username)
if err != nil || !mailBelongsToUser(pm, u.Email) {
@@ -838,8 +904,18 @@ func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
return
}
// Access check for user role
sess := sessionFromCtx(r.Context())
// Tenant isolation
if sess.TenantID != nil {
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
if mailTenant == nil || *mailTenant != *sess.TenantID {
writeError(w, http.StatusForbidden, "access denied")
return
}
}
// Access check for user role
if sess.Role == userstore.RoleUser {
pm, err := mailparser.Parse(raw)
if err == nil {