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))
|
||||
|
||||
@@ -74,7 +74,9 @@ func (m *Manager) SetTenantLDAP(tenantLdapStore *ldapcfg.TenantStore, tenantLook
|
||||
// short-lived pending token that can only be used with ValidateTOTPLogin.
|
||||
func (m *Manager) Login(username, password string) (token string, user *userstore.User, totpRequired bool, err error) {
|
||||
// 1. Try local authentication first.
|
||||
user, err = m.store.VerifyPassword(username, password)
|
||||
// PROJ-46: VerifyLogin lets tenant users authenticate by email and keeps
|
||||
// username-login working only for non-tenant users (tenant_id IS NULL).
|
||||
user, err = m.store.VerifyLogin(context.Background(), username, password)
|
||||
if err == nil {
|
||||
if user.TOTPEnabled {
|
||||
t, e := m.issuePendingTOTPToken(user)
|
||||
|
||||
@@ -243,6 +243,22 @@ func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids
|
||||
// accountID identifies the IMAP account for PROJ-52 source tracking.
|
||||
func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, accountID int64, log *slog.Logger) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Parse early: needed both for PROJ-43 routing-rule resolution (before Save,
|
||||
// so the mail is stored under the correct tenant) and for indexing below.
|
||||
pm, parseErr := mailparser.Parse(raw)
|
||||
|
||||
// PROJ-43: pattern routing rules may override the account's default tenant.
|
||||
// They are more specific than the per-account TenantID, so a match wins.
|
||||
if parseErr == nil {
|
||||
recipients := append(append([]string{}, pm.To...), pm.CC...)
|
||||
if tid, err := imp.mailStore.ResolveTenantByRoutingRules(ctx, pm.From, recipients); err != nil {
|
||||
log.Warn("routing rule lookup failed", "err", err)
|
||||
} else if tid != nil {
|
||||
tenantID = tid
|
||||
}
|
||||
}
|
||||
|
||||
// Save to file storage (deduplicates by SHA256 automatically)
|
||||
id, err := imp.mailStore.Save(ctx, raw, time.Now(), tenantID)
|
||||
if err != nil {
|
||||
@@ -255,10 +271,9 @@ func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, accountID int64,
|
||||
log.Warn("failed to tag source", "id", id, "err", err)
|
||||
}
|
||||
|
||||
// Parse for indexing
|
||||
pm, err := mailparser.Parse(raw)
|
||||
if err != nil {
|
||||
log.Warn("failed to parse mail for indexing", "id", id, "err", err)
|
||||
// Parse for indexing (reuse the early parse from routing-rule resolution).
|
||||
if parseErr != nil {
|
||||
log.Warn("failed to parse mail for indexing", "id", id, "err", parseErr)
|
||||
// Store succeeded, just skip indexing for unparseable mails
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -101,6 +101,13 @@ func (d *Daemon) resolveTenantFromRcpts(rcpts []string) *int64 {
|
||||
// This handles BCC-journaling where RCPT TO is the archive's own address and
|
||||
// the real sender/recipient domain is only visible in the RFC 2822 headers.
|
||||
func (d *Daemon) resolveTenant(rcpts []string, raw []byte) *int64 {
|
||||
// 0. PROJ-43: explicit pattern routing rules take precedence over the plain
|
||||
// tenant_domains mapping, because they are more specific (wildcard
|
||||
// domains, exact sender/recipient addresses, priority ordering).
|
||||
if tid := d.resolveTenantByRules(rcpts, raw); tid != nil {
|
||||
return tid
|
||||
}
|
||||
|
||||
if d.domainToTenant == nil {
|
||||
return d.defaultTenantID
|
||||
}
|
||||
@@ -144,6 +151,38 @@ func (d *Daemon) resolveTenant(rcpts []string, raw []byte) *int64 {
|
||||
return d.defaultTenantID
|
||||
}
|
||||
|
||||
// resolveTenantByRules gathers the From address and recipient list (envelope
|
||||
// RCPT TO plus header To/Cc) and evaluates the PROJ-43 tenant_routing_rules.
|
||||
// Returns nil when the store is unavailable or no rule matches.
|
||||
func (d *Daemon) resolveTenantByRules(rcpts []string, raw []byte) *int64 {
|
||||
if d.store == nil {
|
||||
return nil
|
||||
}
|
||||
var from string
|
||||
recipients := make([]string, 0, len(rcpts)+4)
|
||||
for _, r := range rcpts {
|
||||
recipients = append(recipients, strings.Trim(r, "<>"))
|
||||
}
|
||||
if msg, err := mail.ReadMessage(bytes.NewReader(raw)); err == nil {
|
||||
if addrs, err := mail.ParseAddressList(msg.Header.Get("From")); err == nil && len(addrs) > 0 {
|
||||
from = addrs[0].Address
|
||||
}
|
||||
for _, hdr := range []string{"To", "Cc"} {
|
||||
if addrs, err := mail.ParseAddressList(msg.Header.Get(hdr)); err == nil {
|
||||
for _, a := range addrs {
|
||||
recipients = append(recipients, a.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tid, err := d.store.ResolveTenantByRoutingRules(context.Background(), from, recipients)
|
||||
if err != nil {
|
||||
d.logger.Warn("SMTP: routing rule lookup failed", "err", err)
|
||||
return nil
|
||||
}
|
||||
return tid
|
||||
}
|
||||
|
||||
// SetIndexCallback sets the function called after each successfully stored mail.
|
||||
func (d *Daemon) SetIndexCallback(cb IndexCallback) {
|
||||
d.indexCallback = cb
|
||||
|
||||
@@ -107,6 +107,8 @@ func New(cfg Config) (*Store, error) {
|
||||
_, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS storage_id BIGINT REFERENCES storage_objects(id)`)
|
||||
// PROJ-51: archiving_rules table + retain_until_source column
|
||||
s.initRetentionRulesSchema(ctx)
|
||||
// PROJ-43: tenant_routing_rules table (pattern-based tenant assignment)
|
||||
s.initTenantRoutingRulesSchema(ctx)
|
||||
// PROJ-50: DSGVO Löschersuchen
|
||||
s.initDSGVOSchema(ctx)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PROJ-43: Tenant routing rules — flexible pattern rules that assign an
|
||||
// incoming mail to a tenant. This complements the simple 1:1 tenant_domains
|
||||
// mapping (PROJ-21 Phase 5): routing rules are more explicit and therefore
|
||||
// evaluated *before* the plain domain fallback. Higher priority wins on ties.
|
||||
//
|
||||
// NOTE: this is intentionally a separate table from PROJ-51's `archiving_rules`
|
||||
// (retention categories) — same "rules engine" style, different purpose.
|
||||
|
||||
// Routing rule match types.
|
||||
const (
|
||||
RouteMatchFromDomain = "from_domain" // domain of the From address
|
||||
RouteMatchToDomain = "to_domain" // domain of any To/Cc/envelope recipient
|
||||
RouteMatchFromAddr = "from_addr" // full From address (exact)
|
||||
RouteMatchToAddr = "to_addr" // full To/Cc/envelope recipient (exact)
|
||||
)
|
||||
|
||||
// TenantRoutingRule assigns matching mails to TenantID.
|
||||
type TenantRoutingRule struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"` // target tenant (always set — a routing rule must resolve to a tenant)
|
||||
MatchType string `json:"match_type"`
|
||||
Pattern string `json:"pattern"`
|
||||
Priority int `json:"priority"` // higher = evaluated first
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *Store) initTenantRoutingRulesSchema(ctx context.Context) {
|
||||
if s.db == nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS tenant_routing_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
match_type TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
_, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_tenant_routing_rules_priority ON tenant_routing_rules (priority DESC)`)
|
||||
_, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_tenant_routing_rules_tenant ON tenant_routing_rules (tenant_id)`)
|
||||
}
|
||||
|
||||
func validRouteMatchType(t string) bool {
|
||||
switch t {
|
||||
case RouteMatchFromDomain, RouteMatchToDomain, RouteMatchFromAddr, RouteMatchToAddr:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ListTenantRoutingRules returns rules ordered by priority (desc). If tenantID
|
||||
// is non-nil, only rules for that tenant are returned (tenant-admin scope).
|
||||
// A nil tenantID returns all rules (superadmin scope).
|
||||
func (s *Store) ListTenantRoutingRules(ctx context.Context, tenantID *int64) ([]TenantRoutingRule, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("storage: no db")
|
||||
}
|
||||
query := `SELECT id, tenant_id, match_type, pattern, priority, created_at FROM tenant_routing_rules`
|
||||
var args []interface{}
|
||||
if tenantID != nil {
|
||||
query += ` WHERE tenant_id = $1`
|
||||
args = append(args, *tenantID)
|
||||
}
|
||||
query += ` ORDER BY priority DESC, id ASC`
|
||||
|
||||
rows, err := s.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list routing rules: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []TenantRoutingRule
|
||||
for rows.Next() {
|
||||
var r TenantRoutingRule
|
||||
if err := rows.Scan(&r.ID, &r.TenantID, &r.MatchType, &r.Pattern, &r.Priority, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan routing rule: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetTenantRoutingRule loads a single rule (used for IDOR scope checks and
|
||||
// dry-run by rule ID).
|
||||
func (s *Store) GetTenantRoutingRule(ctx context.Context, id int64) (*TenantRoutingRule, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("storage: no db")
|
||||
}
|
||||
var r TenantRoutingRule
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, match_type, pattern, priority, created_at FROM tenant_routing_rules WHERE id=$1`, id,
|
||||
).Scan(&r.ID, &r.TenantID, &r.MatchType, &r.Pattern, &r.Priority, &r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: get routing rule: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func normalizeRoutePattern(matchType, pattern string) (string, error) {
|
||||
if !validRouteMatchType(matchType) {
|
||||
return "", fmt.Errorf("storage: invalid match_type %q", matchType)
|
||||
}
|
||||
p := strings.ToLower(strings.TrimSpace(pattern))
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("storage: pattern must not be empty")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CreateTenantRoutingRule inserts a new rule and returns its generated ID.
|
||||
func (s *Store) CreateTenantRoutingRule(ctx context.Context, r TenantRoutingRule) (int64, error) {
|
||||
if s.db == nil {
|
||||
return 0, fmt.Errorf("storage: no db")
|
||||
}
|
||||
pat, err := normalizeRoutePattern(r.MatchType, r.Pattern)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if r.TenantID <= 0 {
|
||||
return 0, fmt.Errorf("storage: tenant_id must be set")
|
||||
}
|
||||
var id int64
|
||||
err = s.db.QueryRow(ctx, `
|
||||
INSERT INTO tenant_routing_rules (tenant_id, match_type, pattern, priority)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
r.TenantID, r.MatchType, pat, r.Priority,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("storage: create routing rule: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateTenantRoutingRule replaces the editable fields of an existing rule.
|
||||
func (s *Store) UpdateTenantRoutingRule(ctx context.Context, r TenantRoutingRule) error {
|
||||
if s.db == nil {
|
||||
return fmt.Errorf("storage: no db")
|
||||
}
|
||||
pat, err := normalizeRoutePattern(r.MatchType, r.Pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.TenantID <= 0 {
|
||||
return fmt.Errorf("storage: tenant_id must be set")
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE tenant_routing_rules
|
||||
SET tenant_id=$1, match_type=$2, pattern=$3, priority=$4
|
||||
WHERE id=$5`,
|
||||
r.TenantID, r.MatchType, pat, r.Priority, r.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: update routing rule: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: routing rule %d not found", r.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTenantRoutingRule removes a rule. Already-archived mails keep their
|
||||
// assigned tenant (no retroactive re-routing).
|
||||
func (s *Store) DeleteTenantRoutingRule(ctx context.Context, id int64) error {
|
||||
if s.db == nil {
|
||||
return fmt.Errorf("storage: no db")
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM tenant_routing_rules WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: delete routing rule: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: routing rule %d not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Matching ────────────────────────────────────────────────────────────────
|
||||
|
||||
func routeDomainOf(addr string) string {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
// strip a possible "Name <addr>" wrapper
|
||||
if i := strings.LastIndex(addr, "<"); i >= 0 {
|
||||
if j := strings.LastIndex(addr, ">"); j > i {
|
||||
addr = addr[i+1 : j]
|
||||
}
|
||||
}
|
||||
if i := strings.LastIndex(addr, "@"); i >= 0 {
|
||||
return addr[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func routeBareAddr(addr string) string {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
if i := strings.LastIndex(addr, "<"); i >= 0 {
|
||||
if j := strings.LastIndex(addr, ">"); j > i {
|
||||
addr = addr[i+1 : j]
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
// domainMatches supports exact match and a leading "*." wildcard that also
|
||||
// matches sub-domains (e.g. "*.kunde.de" matches "mail.kunde.de" and "kunde.de").
|
||||
func domainMatches(pattern, domain string) bool {
|
||||
if pattern == "" || domain == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(pattern, "*.") {
|
||||
base := pattern[2:]
|
||||
return domain == base || strings.HasSuffix(domain, "."+base)
|
||||
}
|
||||
return domain == pattern
|
||||
}
|
||||
|
||||
// routingRuleMatches reports whether a rule matches the given (already
|
||||
// lowercased-on-store) from address and recipient list.
|
||||
func routingRuleMatches(r TenantRoutingRule, from string, recipients []string) bool {
|
||||
pat := strings.ToLower(strings.TrimSpace(r.Pattern))
|
||||
if pat == "" {
|
||||
return false
|
||||
}
|
||||
switch r.MatchType {
|
||||
case RouteMatchFromAddr:
|
||||
return routeBareAddr(from) == pat
|
||||
case RouteMatchFromDomain:
|
||||
return domainMatches(pat, routeDomainOf(from))
|
||||
case RouteMatchToAddr:
|
||||
for _, t := range recipients {
|
||||
if routeBareAddr(t) == pat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case RouteMatchToDomain:
|
||||
for _, t := range recipients {
|
||||
if domainMatches(pat, routeDomainOf(t)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveTenantByRoutingRules evaluates all routing rules against a mail's From
|
||||
// address and recipient list. The highest-priority matching rule wins (lowest
|
||||
// id on a tie). Returns nil when no rule matches. This is the PROJ-43 explicit
|
||||
// stage that runs *before* the plain tenant_domains fallback.
|
||||
func (s *Store) ResolveTenantByRoutingRules(ctx context.Context, from string, recipients []string) (*int64, error) {
|
||||
if s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rules, err := s.ListTenantRoutingRules(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rules {
|
||||
if routingRuleMatches(rules[i], from, recipients) {
|
||||
tid := rules[i].TenantID
|
||||
return &tid, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ── Dry-run ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// RoutingDryRunMatch is a single sample row for the dry-run preview.
|
||||
type RoutingDryRunMatch struct {
|
||||
ID string `json:"id"`
|
||||
MailFrom string `json:"mail_from"`
|
||||
MailTo string `json:"mail_to"`
|
||||
Subject string `json:"subject"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
// RoutingDryRunResult reports how many already-archived mails would be matched
|
||||
// by a (match_type, pattern) pair, plus a bounded sample.
|
||||
type RoutingDryRunResult struct {
|
||||
MatchType string `json:"match_type"`
|
||||
Pattern string `json:"pattern"`
|
||||
MatchCount int64 `json:"match_count"`
|
||||
SampleLimit int `json:"sample_limit"`
|
||||
Sample []RoutingDryRunMatch `json:"sample"`
|
||||
}
|
||||
|
||||
// dryRunCondition builds a SQL condition + argument approximating the rule
|
||||
// against the emails table (mail_from / mail_to are display strings, so we use
|
||||
// ILIKE containment — this is a preview estimate, not the exact live matcher).
|
||||
func dryRunCondition(matchType, pattern string) (cond string, arg string, err error) {
|
||||
p := strings.ToLower(strings.TrimSpace(pattern))
|
||||
if p == "" {
|
||||
return "", "", fmt.Errorf("storage: pattern must not be empty")
|
||||
}
|
||||
// "*." wildcard: drop the leading star so ILIKE '%.base' / '%@base' catches
|
||||
// both the base domain and its sub-domains.
|
||||
wild := strings.HasPrefix(p, "*.")
|
||||
base := p
|
||||
if wild {
|
||||
base = p[2:]
|
||||
}
|
||||
switch matchType {
|
||||
case RouteMatchFromAddr:
|
||||
return "LOWER(mail_from) LIKE $1", "%<" + p + ">%", nil
|
||||
case RouteMatchToAddr:
|
||||
return "LOWER(mail_to) LIKE $1", "%<" + p + ">%", nil
|
||||
case RouteMatchFromDomain:
|
||||
return "LOWER(mail_from) LIKE $1", "%@%" + base + "%", nil
|
||||
case RouteMatchToDomain:
|
||||
return "LOWER(mail_to) LIKE $1", "%@%" + base + "%", nil
|
||||
}
|
||||
return "", "", fmt.Errorf("storage: invalid match_type %q", matchType)
|
||||
}
|
||||
|
||||
// DryRunRoutingRule counts and samples archived mails that a rule would match.
|
||||
// sampleLimit bounds the sample (and is applied to the query) to avoid full
|
||||
// table scans / timeouts on large archives.
|
||||
// tenantScope, when non-nil, restricts the dry-run to mails already assigned to
|
||||
// that tenant (enforced for tenant/domain admins so they cannot preview other
|
||||
// tenants' mails). A nil tenantScope (superadmin) counts across all tenants.
|
||||
func (s *Store) DryRunRoutingRule(ctx context.Context, matchType, pattern string, sampleLimit int, tenantScope *int64) (*RoutingDryRunResult, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("storage: no db")
|
||||
}
|
||||
if !validRouteMatchType(matchType) {
|
||||
return nil, fmt.Errorf("storage: invalid match_type %q", matchType)
|
||||
}
|
||||
if sampleLimit <= 0 || sampleLimit > 100 {
|
||||
sampleLimit = 20
|
||||
}
|
||||
cond, arg, err := dryRunCondition(matchType, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := []interface{}{arg}
|
||||
if tenantScope != nil {
|
||||
cond += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
|
||||
args = append(args, *tenantScope)
|
||||
}
|
||||
|
||||
res := &RoutingDryRunResult{
|
||||
MatchType: matchType,
|
||||
Pattern: strings.ToLower(strings.TrimSpace(pattern)),
|
||||
SampleLimit: sampleLimit,
|
||||
Sample: []RoutingDryRunMatch{},
|
||||
}
|
||||
|
||||
if err := s.db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM emails WHERE `+cond, args...,
|
||||
).Scan(&res.MatchCount); err != nil {
|
||||
return nil, fmt.Errorf("storage: dry-run count: %w", err)
|
||||
}
|
||||
|
||||
sampleArgs := append(append([]interface{}{}, args...), sampleLimit)
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, COALESCE(mail_from,''), COALESCE(mail_to,''), COALESCE(subject,''), received_at
|
||||
FROM emails WHERE `+cond+fmt.Sprintf(` ORDER BY received_at DESC LIMIT $%d`, len(sampleArgs)), sampleArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: dry-run sample: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var m RoutingDryRunMatch
|
||||
if err := rows.Scan(&m.ID, &m.MailFrom, &m.MailTo, &m.Subject, &m.ReceivedAt); err != nil {
|
||||
return nil, fmt.Errorf("storage: dry-run scan: %w", err)
|
||||
}
|
||||
res.Sample = append(res.Sample, m)
|
||||
}
|
||||
return res, rows.Err()
|
||||
}
|
||||
@@ -20,6 +20,13 @@ const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
|
||||
bcryptCost = 12
|
||||
|
||||
// dummyBcryptHash is used by VerifyLogin to burn bcrypt time when no user
|
||||
// matched, closing the timing side-channel that would otherwise let an
|
||||
// attacker distinguish "unknown identifier" from "wrong password" (PROJ-46
|
||||
// security review). Precomputed hash of a fixed placeholder string — the
|
||||
// plaintext is never used or compared meaningfully, only the cost matters.
|
||||
dummyBcryptHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEeO4TW/OZ/6PdTdSU0/eV1JCJXo.0DGvTa"
|
||||
)
|
||||
|
||||
// User represents a user account in the system.
|
||||
@@ -284,6 +291,59 @@ func (s *Store) VerifyPassword(username, password string) (*User, error) {
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// VerifyLogin checks credentials for the web-login path (PROJ-46) and returns
|
||||
// the user on success. Lookup semantics:
|
||||
// 1. Match by email (`email = $1`) — valid for ALL users (tenant users AND
|
||||
// non-tenant users like superadmin/system).
|
||||
// 2. If no email match, fall back to username (`username = $1`) — but ONLY
|
||||
// accept the match when tenant_id IS NULL (superadmin/system users).
|
||||
// Tenant users (tenant_id IS NOT NULL) can therefore no longer log in via
|
||||
// their username; they must use their email address.
|
||||
//
|
||||
// Note: VerifyPassword (username-only) is intentionally left untouched — it is
|
||||
// used by the IMAP server login path (PROJ-26).
|
||||
func (s *Store) VerifyLogin(ctx context.Context, identifier, password string) (*User, error) {
|
||||
// 1. Email lookup — matches any user.
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size, password_hash
|
||||
FROM users WHERE email = $1`,
|
||||
identifier,
|
||||
)
|
||||
u, hash, err := scanUserWithHash(row)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("userstore: verify login (email): %w", err)
|
||||
}
|
||||
// 2. Username lookup — only accepted for non-tenant users (tenant_id IS NULL).
|
||||
row = s.pool.QueryRow(ctx,
|
||||
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by, list_page_size, password_hash
|
||||
FROM users WHERE username = $1 AND tenant_id IS NULL`,
|
||||
identifier,
|
||||
)
|
||||
u, hash, err = scanUserWithHash(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// PROJ-46 security review: run bcrypt against a dummy hash even when no
|
||||
// user was found, so "unknown identifier" and "wrong password" take
|
||||
// comparable time. Without this, the missing bcrypt call (~150-300ms
|
||||
// cheaper) lets an attacker enumerate valid identifiers by timing the
|
||||
// login endpoint.
|
||||
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
|
||||
return nil, errors.New("userstore: user not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("userstore: verify login (username): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !u.Active {
|
||||
return nil, errors.New("userstore: account disabled")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return nil, errors.New("userstore: wrong password")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Update applies a partial update to a user record.
|
||||
func (s *Store) Update(id int64, req UpdateUserRequest) (*User, error) {
|
||||
ctx := context.Background()
|
||||
@@ -517,6 +577,19 @@ func scanUser(row pgx.Row) (*User, error) {
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// scanUserWithHash scans a full user row that includes the password_hash column
|
||||
// (used by the login verification paths). The pgx.ErrNoRows sentinel is passed
|
||||
// through unwrapped so callers can distinguish "not found" from other errors.
|
||||
func scanUserWithHash(row pgx.Row) (*User, string, error) {
|
||||
var u User
|
||||
var hash string
|
||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize, &hash)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &u, hash, nil
|
||||
}
|
||||
|
||||
func scanUserRow(rows pgx.Rows) (*User, error) {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &u.TOTPEnabled, &u.TOTPResetAt, &u.TOTPResetBy, &u.ListPageSize); err != nil {
|
||||
|
||||
@@ -116,6 +116,74 @@ func TestVerifyPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyLogin covers the full PROJ-46 login-identifier matrix.
|
||||
func TestVerifyLogin(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tenantID := int64(1)
|
||||
|
||||
// Tenant user: username "patrick" != email
|
||||
if _, err := s.Create(userstore.CreateUserRequest{
|
||||
Username: "patrick", Email: "patrick@perlbach24.de",
|
||||
Password: "pw-tenant", Role: userstore.RoleUser, TenantID: &tenantID,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Non-tenant user (superadmin/system), tenant_id IS NULL
|
||||
if _, err := s.Create(userstore.CreateUserRequest{
|
||||
Username: "superadmin", Email: "superadmin@localhost",
|
||||
Password: "pw-super", Role: userstore.RoleSuperAdmin, TenantID: nil,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 1. Tenant user via email → success
|
||||
u, err := s.VerifyLogin(ctx, "patrick@perlbach24.de", "pw-tenant")
|
||||
if err != nil {
|
||||
t.Fatalf("tenant user via email should succeed: %v", err)
|
||||
}
|
||||
if u.Username != "patrick" {
|
||||
t.Errorf("Username = %q, want patrick", u.Username)
|
||||
}
|
||||
|
||||
// 2. Tenant user via username (≠ email) → invalid_credentials
|
||||
if _, err := s.VerifyLogin(ctx, "patrick", "pw-tenant"); err == nil {
|
||||
t.Error("tenant user via username should be rejected")
|
||||
}
|
||||
|
||||
// 3. Non-tenant user via username → success
|
||||
u, err = s.VerifyLogin(ctx, "superadmin", "pw-super")
|
||||
if err != nil {
|
||||
t.Fatalf("non-tenant user via username should succeed: %v", err)
|
||||
}
|
||||
if u.Username != "superadmin" {
|
||||
t.Errorf("Username = %q, want superadmin", u.Username)
|
||||
}
|
||||
|
||||
// 4. Non-tenant user via email → success
|
||||
u, err = s.VerifyLogin(ctx, "superadmin@localhost", "pw-super")
|
||||
if err != nil {
|
||||
t.Fatalf("non-tenant user via email should succeed: %v", err)
|
||||
}
|
||||
if u.Username != "superadmin" {
|
||||
t.Errorf("Username = %q, want superadmin", u.Username)
|
||||
}
|
||||
|
||||
// 5. Unknown identifier → invalid_credentials
|
||||
if _, err := s.VerifyLogin(ctx, "ghost@nowhere.tld", "x"); err == nil {
|
||||
t.Error("unknown identifier should be rejected")
|
||||
}
|
||||
if _, err := s.VerifyLogin(ctx, "ghost", "x"); err == nil {
|
||||
t.Error("unknown username should be rejected")
|
||||
}
|
||||
|
||||
// Wrong password for valid identifier → rejected
|
||||
if _, err := s.VerifyLogin(ctx, "patrick@perlbach24.de", "wrong"); err == nil {
|
||||
t.Error("wrong password should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
u, _ := s.Create(userstore.CreateUserRequest{
|
||||
|
||||
Reference in New Issue
Block a user