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}