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::new("acme",  "Acme Corp", "acme_primary"),
29//!         TenantConfig::new("globex", "Globex",    "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.
64#[non_exhaustive]
65pub enum TenantStrategy {
66    /// e.g. `X-Tenant-Id: acme`
67    Header(std::borrow::Cow<'static, str>),
68    /// e.g. `acme.api.example.com` with `base_domain = "api.example.com"` → `acme`
69    Subdomain {
70        base_domain: std::borrow::Cow<'static, str>,
71    },
72    /// Header takes precedence; subdomain is the fallback.
73    HeaderThenSubdomain {
74        header: std::borrow::Cow<'static, str>,
75        base_domain: std::borrow::Cow<'static, str>,
76    },
77}
78
79impl TenantStrategy {
80    /// Header strategy from a static or runtime-config header name.
81    pub fn header(name: impl Into<std::borrow::Cow<'static, str>>) -> Self {
82        Self::Header(name.into())
83    }
84    pub fn subdomain(base_domain: impl Into<std::borrow::Cow<'static, str>>) -> Self {
85        Self::Subdomain {
86            base_domain: base_domain.into(),
87        }
88    }
89}
90
91/// Per-tenant static configuration.
92#[non_exhaustive]
93pub struct TenantConfig {
94    pub id: TenantId,
95    pub display_name: String,
96    /// Logical datasource name — consumed by `data::DataSourceRegistry` to
97    /// select this tenant's connection pool. Loose coupling by name keeps the
98    /// tenant layer independent of any database crate.
99    pub datasource: String,
100}
101
102impl TenantConfig {
103    pub fn new(
104        id: impl AsRef<str>,
105        display_name: impl Into<String>,
106        datasource: impl Into<String>,
107    ) -> Self {
108        Self {
109            id: TenantId::new(id),
110            display_name: display_name.into(),
111            datasource: datasource.into(),
112        }
113    }
114}
115
116/// Tenant registry with **dynamic provisioning**.
117///
118/// Hot path: `resolve()` is one `ArcSwap` pointer load + an immutable map
119/// read — exactly the zero-lock profile of the old frozen map. Control
120/// plane: [`Self::upsert`]/[`Self::suspend`]/[`Self::resume`] copy-on-write a new snapshot and
121/// swap it atomically, so onboarding a customer (or cutting off a delinquent
122/// one) takes effect on the next request — **no redeploy, no restart**.
123///
124/// Not `#[Injectable]` — provide via `ctx.provide(TenantRegistry::new(...))`.
125pub struct TenantRegistry {
126    strategy: TenantStrategy,
127    snapshot: ArcSwap<TenantSnapshot>,
128    fallback: Option<TenantId>,
129}
130
131struct TenantSnapshot {
132    known: HashMap<TenantId, Arc<TenantConfig>>,
133    suspended: HashSet<TenantId>,
134}
135
136impl TenantRegistry {
137    pub fn new(
138        strategy: TenantStrategy,
139        tenants: Vec<TenantConfig>,
140        fallback: Option<TenantId>,
141    ) -> Self {
142        let known = tenants
143            .into_iter()
144            .map(|t| (t.id.clone(), Arc::new(t)))
145            .collect();
146        Self {
147            strategy,
148            snapshot: ArcSwap::from_pointee(TenantSnapshot {
149                known,
150                suspended: HashSet::new(),
151            }),
152            fallback,
153        }
154    }
155
156    // ── Control plane (copy-on-write; never touches the hot path) ──────────
157
158    /// Add or replace a tenant — visible to the very next request.
159    pub fn upsert(&self, cfg: TenantConfig) {
160        self.snapshot.rcu(|cur| {
161            let mut known = cur.known.clone();
162            known.insert(
163                cfg.id.clone(),
164                Arc::new(TenantConfig {
165                    id: cfg.id.clone(),
166                    display_name: cfg.display_name.clone(),
167                    datasource: cfg.datasource.clone(),
168                }),
169            );
170            TenantSnapshot {
171                known,
172                suspended: cur.suspended.clone(),
173            }
174        });
175        tracing::info!("tenant upserted (live, no restart)");
176    }
177
178    /// Suspend a tenant: `resolve()` returns `None` → guards reject with 401.
179    /// Deliberately does NOT fall through to the fallback tenant — a
180    /// suspended customer must not silently ride the shared pool.
181    pub fn suspend(&self, id: &TenantId) {
182        self.snapshot.rcu(|cur| {
183            let mut suspended = cur.suspended.clone();
184            suspended.insert(id.clone());
185            TenantSnapshot {
186                known: cur.known.clone(),
187                suspended,
188            }
189        });
190    }
191
192    pub fn resume(&self, id: &TenantId) {
193        self.snapshot.rcu(|cur| {
194            let mut suspended = cur.suspended.clone();
195            suspended.remove(id);
196            TenantSnapshot {
197                known: cur.known.clone(),
198                suspended,
199            }
200        });
201    }
202
203    fn raw_id(&self, headers: &HeaderMap) -> Option<SmolStr> {
204        fn from_header(headers: &HeaderMap, name: &str) -> Option<SmolStr> {
205            headers
206                .get(name)
207                .and_then(|v| v.to_str().ok())
208                .map(str::trim)
209                .filter(|s| !s.is_empty())
210                .map(SmolStr::new)
211        }
212        fn from_subdomain(headers: &HeaderMap, base: &str) -> Option<SmolStr> {
213            let host = headers.get("host")?.to_str().ok()?;
214            // Strip an optional port before comparing against the base domain.
215            let host = host.rsplit_once(':').map_or(host, |(h, _)| h);
216            host.strip_suffix(base)?
217                .strip_suffix('.')
218                .filter(|s| !s.is_empty())
219                .map(SmolStr::new)
220        }
221
222        match &self.strategy {
223            TenantStrategy::Header(h) => from_header(headers, h),
224            TenantStrategy::Subdomain { base_domain } => from_subdomain(headers, base_domain),
225            TenantStrategy::HeaderThenSubdomain {
226                header,
227                base_domain,
228            } => from_header(headers, header).or_else(|| from_subdomain(headers, base_domain)),
229        }
230    }
231
232    /// Resolve the request's tenant. Called once per request by the boundary.
233    ///
234    /// One atomic snapshot load + immutable map reads — zero locks.
235    /// Unknown IDs resolve to the fallback (a typo'd header cannot select an
236    /// unconfigured tenant); **suspended** IDs resolve to `None` outright.
237    pub fn resolve(&self, headers: &HeaderMap) -> Option<Arc<TenantConfig>> {
238        let snap = self.snapshot.load();
239
240        if let Some(id) = self.raw_id(headers).map(TenantId) {
241            if snap.suspended.contains(&id) {
242                return None; // hard cut-off, no fallback
243            }
244            if let Some(cfg) = snap.known.get(&id) {
245                return Some(cfg.clone()); // Arc clone — no data copy
246            }
247        }
248        self.fallback
249            .as_ref()
250            .filter(|id| !snap.suspended.contains(id))
251            .and_then(|id| snap.known.get(id).cloned())
252    }
253
254    pub fn get(&self, id: &TenantId) -> Option<Arc<TenantConfig>> {
255        self.snapshot.load().known.get(id).cloned()
256    }
257
258    /// Resolve a tenant by its raw id — the non-HTTP twin of
259    /// [`resolve`](Self::resolve), used by the consumer mesh where the id
260    /// rides a message envelope instead of a header. Same semantics:
261    /// **suspended → `None` outright** (a suspended tenant's queued events
262    /// must stop processing, exactly like its HTTP traffic). Unknown ids get
263    /// no fallback: an envelope names its tenant explicitly, and falling
264    /// back would process data under the wrong tenant.
265    pub fn resolve_by_id(&self, id: &str) -> Option<Arc<TenantConfig>> {
266        let snap = self.snapshot.load();
267        let id = TenantId::new(id);
268        if snap.suspended.contains(&id) {
269            return None;
270        }
271        snap.known.get(&id).cloned()
272    }
273}
274
275// ─── TenantGuard ──────────────────────────────────────────────────────────────
276
277/// Requires a resolved tenant AND cross-checks it against the JWT.
278///
279/// - No tenant on the context → `401 Unauthorized` (strategy failed to
280///   resolve and no fallback is configured).
281/// - JWT carries a `tenant` claim that differs from the resolved tenant →
282///   `403 Forbidden` (forged header / cross-tenant token reuse).
283/// - JWT without a `tenant` claim passes — single-tenant tokens stay valid.
284pub struct TenantGuard;
285
286/// Ready-to-use singleton.
287pub static TENANT: TenantGuard = TenantGuard;
288
289impl Guard for TenantGuard {
290    fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
291        let tenant = ctx.tenant().ok_or(Error::Unauthorized)?;
292
293        if let Some(claim) = ctx
294            .claims()
295            .and_then(|c| c.get("tenant"))
296            .and_then(|v| v.as_str())
297        {
298            if claim != tenant.id.as_str() {
299                return Err(Error::Forbidden);
300            }
301        }
302        Ok(())
303    }
304}