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:
+121
-45
@@ -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 {
|
||||
|
||||
@@ -108,8 +108,11 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.uploadJobs.Store(jobID, job)
|
||||
|
||||
// Propagate tenant from session context
|
||||
tenantID := tenantFromCtx(r.Context())
|
||||
|
||||
// Run import in background
|
||||
go s.runUploadJob(job, allMessages)
|
||||
go s.runUploadJob(job, allMessages, tenantID)
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
|
||||
}
|
||||
@@ -126,11 +129,11 @@ func (s *Server) handleUploadProgress(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, job.snapshot())
|
||||
}
|
||||
|
||||
func (s *Server) runUploadJob(job *UploadJob, messages [][]byte) {
|
||||
func (s *Server) runUploadJob(job *UploadJob, messages [][]byte, tenantID *int64) {
|
||||
ctx := context.Background()
|
||||
|
||||
for _, raw := range messages {
|
||||
result := s.importRawMessage(ctx, raw)
|
||||
result := s.importRawMessage(ctx, raw, tenantID)
|
||||
job.mu.Lock()
|
||||
switch result {
|
||||
case "imported":
|
||||
@@ -150,14 +153,14 @@ func (s *Server) runUploadJob(job *UploadJob, messages [][]byte) {
|
||||
|
||||
// importRawMessage stores and indexes a single raw message.
|
||||
// Returns "imported", "skipped", or "error".
|
||||
func (s *Server) importRawMessage(ctx context.Context, raw []byte) string {
|
||||
func (s *Server) importRawMessage(ctx context.Context, raw []byte, tenantID *int64) string {
|
||||
pm, err := mailparser.Parse(raw)
|
||||
if err != nil {
|
||||
s.logger.Warn("upload: parse failed", "err", err)
|
||||
return "error"
|
||||
}
|
||||
|
||||
id, err := s.store.Save(raw, pm.Date)
|
||||
id, err := s.store.Save(ctx, raw, pm.Date, tenantID)
|
||||
if err != nil {
|
||||
s.logger.Warn("upload: save failed", "err", err)
|
||||
return "error"
|
||||
|
||||
Reference in New Issue
Block a user