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>
384 lines
13 KiB
Go
384 lines
13 KiB
Go
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()
|
|
}
|