Files
archivmail/internal/api/rules_handlers.go
T
sysopsandClaude Sonnet 5 767373b206 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>
2026-07-03 23:37:20 +02:00

214 lines
7.1 KiB
Go

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.