feat(PROJ-46): E-Mail als primärer Login-Identifier für Tenant-User
Tenant-User (tenant_id IS NOT NULL) melden sich künftig per E-Mail an statt per Username — behebt Verwechslungen wie im Support-Fall vom 2026-06-13 (Login schlug trotz Passwort-Reset fehl, weil E-Mail statt Username verwendet wurde). Nicht-Tenant-User (Superadmin/System) können weiterhin Username ODER E-Mail nutzen. Neue Store.VerifyLogin() prüft erst per E-Mail (alle User), fällt dann auf Username zurück (nur tenant_id IS NULL). VerifyPassword() bleibt für den IMAP-Server-Login-Pfad (PROJ-26) unverändert. Bewusster Breaking Change für Tenant-User, Datenqualität vorab geprüft (0 Kollisionen). Security-Nachtrag: bcrypt-Dummy-Compare im "user not found"-Pfad ergänzt, um Timing-basierte Identifier-Enumeration zu verhindern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
804cd62201
commit
767373b206
@@ -0,0 +1,213 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivmail/internal/storage"
|
||||
)
|
||||
|
||||
// PROJ-43: Tenant routing rules CRUD + dry-run.
|
||||
//
|
||||
// Scope model (see PROJ-55/61/62/63 security fixes):
|
||||
// - Superadmin (sess.TenantID == nil): may see/manage rules for ALL tenants.
|
||||
// - Domain admin (sess.TenantID set): may only see/manage rules whose
|
||||
// tenant_id matches their own tenant. Every {id} path additionally verifies
|
||||
// ownership via tenantAccessAllowed() to prevent IDOR.
|
||||
|
||||
type routingRuleBody struct {
|
||||
TenantID *int64 `json:"tenant_id"`
|
||||
MatchType string `json:"match_type"`
|
||||
Pattern string `json:"pattern"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// resolveRuleTenant determines the tenant_id a rule must belong to for the
|
||||
// current session, and reports whether the request is allowed.
|
||||
// - Superadmin: must specify tenant_id in the body (rules always target a
|
||||
// concrete tenant); any tenant allowed.
|
||||
// - Domain admin: tenant_id is forced to their own tenant; a mismatching
|
||||
// explicit body value is rejected.
|
||||
func (s *Server) resolveRuleTenant(sess sessionTenant, bodyTenantID *int64) (int64, bool) {
|
||||
if sess.tenantID == nil {
|
||||
// superadmin
|
||||
if bodyTenantID == nil || *bodyTenantID <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return *bodyTenantID, true
|
||||
}
|
||||
if bodyTenantID != nil && *bodyTenantID != *sess.tenantID {
|
||||
return 0, false
|
||||
}
|
||||
return *sess.tenantID, true
|
||||
}
|
||||
|
||||
// sessionTenant is a tiny helper capturing what the handlers need from a session.
|
||||
type sessionTenant struct {
|
||||
tenantID *int64
|
||||
}
|
||||
|
||||
func sessTenant(r *http.Request) sessionTenant {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
return sessionTenant{tenantID: sess.TenantID}
|
||||
}
|
||||
|
||||
// handleListRoutingRules returns routing rules visible to the caller.
|
||||
// GET /api/admin/routing-rules
|
||||
func (s *Server) handleListRoutingRules(w http.ResponseWriter, r *http.Request) {
|
||||
scope := sessTenant(r).tenantID // nil for superadmin → all rules
|
||||
rules, err := s.store.ListTenantRoutingRules(r.Context(), scope)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if rules == nil {
|
||||
rules = []storage.TenantRoutingRule{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"rules": rules})
|
||||
}
|
||||
|
||||
// handleCreateRoutingRule creates a new routing rule.
|
||||
// POST /api/admin/routing-rules
|
||||
func (s *Server) handleCreateRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body routingRuleBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
tenantID, ok := s.resolveRuleTenant(sessTenant(r), body.TenantID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusForbidden, "tenant_id required and must match your scope")
|
||||
return
|
||||
}
|
||||
id, err := s.store.CreateTenantRoutingRule(r.Context(), storage.TenantRoutingRule{
|
||||
TenantID: tenantID,
|
||||
MatchType: body.MatchType,
|
||||
Pattern: body.Pattern,
|
||||
Priority: body.Priority,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.auditRule(r, "routing_rule_created", fmt.Sprintf("id=%d tenant=%d type=%s pattern=%s prio=%d",
|
||||
id, tenantID, body.MatchType, body.Pattern, body.Priority))
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// handleUpdateRoutingRule updates an existing routing rule.
|
||||
// PUT /api/admin/routing-rules/{id}
|
||||
func (s *Server) handleUpdateRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid rule id")
|
||||
return
|
||||
}
|
||||
// IDOR: load existing rule and verify ownership before mutating.
|
||||
existing, err := s.store.GetTenantRoutingRule(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if !tenantAccessAllowed(sess, &existing.TenantID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
var body routingRuleBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
tenantID, ok := s.resolveRuleTenant(sessTenant(r), body.TenantID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusForbidden, "tenant_id must match your scope")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateTenantRoutingRule(r.Context(), storage.TenantRoutingRule{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
MatchType: body.MatchType,
|
||||
Pattern: body.Pattern,
|
||||
Priority: body.Priority,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.auditRule(r, "routing_rule_updated", fmt.Sprintf("id=%d tenant=%d type=%s pattern=%s prio=%d",
|
||||
id, tenantID, body.MatchType, body.Pattern, body.Priority))
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// handleDeleteRoutingRule deletes a routing rule.
|
||||
// DELETE /api/admin/routing-rules/{id}
|
||||
func (s *Server) handleDeleteRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid rule id")
|
||||
return
|
||||
}
|
||||
existing, err := s.store.GetTenantRoutingRule(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if !tenantAccessAllowed(sess, &existing.TenantID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteTenantRoutingRule(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
s.auditRule(r, "routing_rule_deleted", fmt.Sprintf("id=%d", id))
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
type routingDryRunBody struct {
|
||||
RuleID *int64 `json:"rule_id"` // dry-run an existing rule, OR ...
|
||||
MatchType string `json:"match_type"` // ... an ad-hoc (match_type, pattern)
|
||||
Pattern string `json:"pattern"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// handleDryRunRoutingRule previews which already-archived mails a rule would
|
||||
// match. Bounded by LIMIT to avoid full-scan timeouts on large archives.
|
||||
// POST /api/admin/routing-rules/dry-run
|
||||
func (s *Server) handleDryRunRoutingRule(w http.ResponseWriter, r *http.Request) {
|
||||
var body routingDryRunBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
|
||||
matchType, pattern := body.MatchType, body.Pattern
|
||||
if body.RuleID != nil {
|
||||
rule, err := s.store.GetTenantRoutingRule(r.Context(), *body.RuleID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
if !tenantAccessAllowed(sess, &rule.TenantID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
matchType, pattern = rule.MatchType, rule.Pattern
|
||||
}
|
||||
|
||||
// Domain admins may only preview mails within their own tenant.
|
||||
scope := sess.TenantID
|
||||
res, err := s.store.DryRunRoutingRule(r.Context(), matchType, pattern, body.Limit, scope)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.auditRule(r, "routing_rule_dry_run", fmt.Sprintf("type=%s pattern=%s matches=%d", matchType, pattern, res.MatchCount))
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// NOTE: auditRule is defined in archiving_rules_handlers.go and reused here.
|
||||
@@ -249,6 +249,14 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /api/admin/archiving-rules", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCreateArchivingRule)))
|
||||
s.mux.HandleFunc("PUT /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleUpdateArchivingRule)))
|
||||
s.mux.HandleFunc("DELETE /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteArchivingRule)))
|
||||
// PROJ-43: Tenant routing rules CRUD + dry-run — domain_admin+, tenant-scoped
|
||||
// (superadmin sees all tenants, domain admins only their own).
|
||||
s.mux.HandleFunc("GET /api/admin/routing-rules", s.authAdmin(s.handleListRoutingRules))
|
||||
s.mux.HandleFunc("POST /api/admin/routing-rules", s.authAdmin(s.handleCreateRoutingRule))
|
||||
s.mux.HandleFunc("PUT /api/admin/routing-rules/{id}", s.authAdmin(s.handleUpdateRoutingRule))
|
||||
s.mux.HandleFunc("DELETE /api/admin/routing-rules/{id}", s.authAdmin(s.handleDeleteRoutingRule))
|
||||
s.mux.HandleFunc("POST /api/admin/routing-rules/dry-run", s.authAdmin(s.handleDryRunRoutingRule))
|
||||
|
||||
// PROJ-56c: pro-Mail Löschmarkierung — domain_admin+, tenant-scoped (kein
|
||||
// Mail-Lesezugriff nötig, daher requireRole statt requireMailAccess).
|
||||
s.mux.HandleFunc("GET /api/admin/retention/expired", s.authAdmin(s.handleListExpiredMails))
|
||||
|
||||
Reference in New Issue
Block a user