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>
214 lines
7.1 KiB
Go
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.
|