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}