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.