Skip to main content

arcly_http/web/
tenant.rs

1//! Multi-tenant separation layer.
2//!
3//! Tenant identity is resolved **once per request** inside
4//! `web::boundary::assemble_context` — the same single pipeline that handles
5//! credentials and tracing — so every entry point (macro routes, plugin
6//! routes) sees the same tenant with zero per-handler boilerplate.
7//!
8//! ## Hot-path guarantees
9//!
10//! `TenantRegistry` is a *frozen* map built once at boot and provided into the
11//! DI container: resolution is a header read + immutable `HashMap` lookup —
12//! no locks, no allocation beyond the interned `SmolStr` key.
13//!
14//! ## Defense in depth
15//!
16//! A client-supplied header is never trusted on its own. When the request also
17//! carries a JWT with a `tenant` claim, [`TenantGuard`] enforces that both
18//! agree — a token minted for tenant A can never act on tenant B by forging
19//! `X-Tenant-Id`.
20//!
21//! ## Usage
22//!
23//! ```ignore
24//! // boot (plugin on_init):
25//! ctx.provide(TenantRegistry::new(
26//!     TenantStrategy::Header("x-tenant-id"),
27//!     vec![
28//!         TenantConfig { id: TenantId::new("acme"),  display_name: "Acme Corp".into(), datasource: "acme_primary" },
29//!         TenantConfig { id: TenantId::new("globex"), display_name: "Globex".into(),   datasource: "globex_primary" },
30//!     ],
31//!     Some(TenantId::new("public")),       // fallback tenant (None = strict)
32//! ));
33//!
34//! // handler:
35//! static TENANT: TenantGuard = TenantGuard;
36//! TENANT.check(&ctx)?;
37//! let t = ctx.tenant().expect("guarded");
38//! ```
39
40use std::collections::{HashMap, HashSet};
41use std::sync::Arc;
42
43use arc_swap::ArcSwap;
44use axum::http::HeaderMap;
45use smol_str::SmolStr;
46
47use crate::auth::guards::Guard;
48use crate::web::{Error, RequestContext};
49
50/// Interned tenant identifier (no heap allocation for IDs ≤ 23 bytes).
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct TenantId(pub SmolStr);
53
54impl TenantId {
55    pub fn new(id: impl AsRef<str>) -> Self {
56        Self(SmolStr::new(id.as_ref()))
57    }
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61}
62
63/// How the tenant identifier is carried on the wire.
64pub enum TenantStrategy {
65    /// e.g. `X-Tenant-Id: acme`
66    Header(&'static str),
67    /// e.g. `acme.api.example.com` with `base_domain = "api.example.com"` → `acme`
68    Subdomain { base_domain: &'static str },
69    /// Header takes precedence; subdomain is the fallback.
70    HeaderThenSubdomain {
71        header: &'static str,
72        base_domain: &'static str,
73    },
74}
75
76/// Per-tenant static configuration.
77pub struct TenantConfig {
78    pub id: TenantId,
79    pub display_name: String,
80    /// Logical datasource name — consumed by `data::DataSourceRegistry` to
81    /// select this tenant's connection pool. Loose coupling by name keeps the
82    /// tenant layer independent of any database crate.
83    pub datasource: String,
84}
85
86/// Tenant registry with **dynamic provisioning**.
87///
88/// Hot path: `resolve()` is one `ArcSwap` pointer load + an immutable map
89/// read — exactly the zero-lock profile of the old frozen map. Control
90/// plane: [`Self::upsert`]/[`Self::suspend`]/[`Self::resume`] copy-on-write a new snapshot and
91/// swap it atomically, so onboarding a customer (or cutting off a delinquent
92/// one) takes effect on the next request — **no redeploy, no restart**.
93///
94/// Not `#[Injectable]` — provide via `ctx.provide(TenantRegistry::new(...))`.
95pub struct TenantRegistry {
96    strategy: TenantStrategy,
97    snapshot: ArcSwap<TenantSnapshot>,
98    fallback: Option<TenantId>,
99}
100
101struct TenantSnapshot {
102    known: HashMap<TenantId, Arc<TenantConfig>>,
103    suspended: HashSet<TenantId>,
104}
105
106impl TenantRegistry {
107    pub fn new(
108        strategy: TenantStrategy,
109        tenants: Vec<TenantConfig>,
110        fallback: Option<TenantId>,
111    ) -> Self {
112        let known = tenants
113            .into_iter()
114            .map(|t| (t.id.clone(), Arc::new(t)))
115            .collect();
116        Self {
117            strategy,
118            snapshot: ArcSwap::from_pointee(TenantSnapshot {
119                known,
120                suspended: HashSet::new(),
121            }),
122            fallback,
123        }
124    }
125
126    // ── Control plane (copy-on-write; never touches the hot path) ──────────
127
128    /// Add or replace a tenant — visible to the very next request.
129    pub fn upsert(&self, cfg: TenantConfig) {
130        self.snapshot.rcu(|cur| {
131            let mut known = cur.known.clone();
132            known.insert(
133                cfg.id.clone(),
134                Arc::new(TenantConfig {
135                    id: cfg.id.clone(),
136                    display_name: cfg.display_name.clone(),
137                    datasource: cfg.datasource.clone(),
138                }),
139            );
140            TenantSnapshot {
141                known,
142                suspended: cur.suspended.clone(),
143            }
144        });
145        tracing::info!("tenant upserted (live, no restart)");
146    }
147
148    /// Suspend a tenant: `resolve()` returns `None` → guards reject with 401.
149    /// Deliberately does NOT fall through to the fallback tenant — a
150    /// suspended customer must not silently ride the shared pool.
151    pub fn suspend(&self, id: &TenantId) {
152        self.snapshot.rcu(|cur| {
153            let mut suspended = cur.suspended.clone();
154            suspended.insert(id.clone());
155            TenantSnapshot {
156                known: cur.known.clone(),
157                suspended,
158            }
159        });
160    }
161
162    pub fn resume(&self, id: &TenantId) {
163        self.snapshot.rcu(|cur| {
164            let mut suspended = cur.suspended.clone();
165            suspended.remove(id);
166            TenantSnapshot {
167                known: cur.known.clone(),
168                suspended,
169            }
170        });
171    }
172
173    fn raw_id(&self, headers: &HeaderMap) -> Option<SmolStr> {
174        fn from_header(headers: &HeaderMap, name: &str) -> Option<SmolStr> {
175            headers
176                .get(name)
177                .and_then(|v| v.to_str().ok())
178                .map(str::trim)
179                .filter(|s| !s.is_empty())
180                .map(SmolStr::new)
181        }
182        fn from_subdomain(headers: &HeaderMap, base: &str) -> Option<SmolStr> {
183            let host = headers.get("host")?.to_str().ok()?;
184            // Strip an optional port before comparing against the base domain.
185            let host = host.rsplit_once(':').map_or(host, |(h, _)| h);
186            host.strip_suffix(base)?
187                .strip_suffix('.')
188                .filter(|s| !s.is_empty())
189                .map(SmolStr::new)
190        }
191
192        match &self.strategy {
193            TenantStrategy::Header(h) => from_header(headers, h),
194            TenantStrategy::Subdomain { base_domain } => from_subdomain(headers, base_domain),
195            TenantStrategy::HeaderThenSubdomain {
196                header,
197                base_domain,
198            } => from_header(headers, header).or_else(|| from_subdomain(headers, base_domain)),
199        }
200    }
201
202    /// Resolve the request's tenant. Called once per request by the boundary.
203    ///
204    /// One atomic snapshot load + immutable map reads — zero locks.
205    /// Unknown IDs resolve to the fallback (a typo'd header cannot select an
206    /// unconfigured tenant); **suspended** IDs resolve to `None` outright.
207    pub fn resolve(&self, headers: &HeaderMap) -> Option<Arc<TenantConfig>> {
208        let snap = self.snapshot.load();
209
210        if let Some(id) = self.raw_id(headers).map(TenantId) {
211            if snap.suspended.contains(&id) {
212                return None; // hard cut-off, no fallback
213            }
214            if let Some(cfg) = snap.known.get(&id) {
215                return Some(cfg.clone()); // Arc clone — no data copy
216            }
217        }
218        self.fallback
219            .as_ref()
220            .filter(|id| !snap.suspended.contains(id))
221            .and_then(|id| snap.known.get(id).cloned())
222    }
223
224    pub fn get(&self, id: &TenantId) -> Option<Arc<TenantConfig>> {
225        self.snapshot.load().known.get(id).cloned()
226    }
227}
228
229// ─── TenantGuard ──────────────────────────────────────────────────────────────
230
231/// Requires a resolved tenant AND cross-checks it against the JWT.
232///
233/// - No tenant on the context → `401 Unauthorized` (strategy failed to
234///   resolve and no fallback is configured).
235/// - JWT carries a `tenant` claim that differs from the resolved tenant →
236///   `403 Forbidden` (forged header / cross-tenant token reuse).
237/// - JWT without a `tenant` claim passes — single-tenant tokens stay valid.
238pub struct TenantGuard;
239
240/// Ready-to-use singleton.
241pub static TENANT: TenantGuard = TenantGuard;
242
243impl Guard for TenantGuard {
244    fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
245        let tenant = ctx.tenant().ok_or(Error::Unauthorized)?;
246
247        if let Some(claim) = ctx
248            .claims()
249            .and_then(|c| c.get("tenant"))
250            .and_then(|v| v.as_str())
251        {
252            if claim != tenant.id.as_str() {
253                return Err(Error::Forbidden);
254            }
255        }
256        Ok(())
257    }
258}