cratefield_core/tenant.rs
1//! The tenant a request belongs to, and the statuses the control
2//! database's registry carries (RECONCILIATION.md §6, ADR 0008,
3//! TENANT-ROUTING.md).
4//!
5//! [`TenantId`] and [`Tenant`] are nameable everywhere and constructible
6//! only here. That is the whole isolation argument: a module cannot ask
7//! the registry for a neighbour's pool by writing a different string,
8//! because it cannot write a [`TenantId`] at all. Same shape as
9//! `HarnessOnly` in the secrets crate (#39), and for the same reason —
10//! the type is the boundary, not a convention about how to call a
11//! function.
12
13/// A tenant database's reconciliation status.
14///
15/// Serializes as the registry's own text (`"active"`), not the variant
16/// name, so a structured log field and a registry row cannot drift apart.
17/// `round_trips_through_the_registry_text` and
18/// `serializes_as_the_registry_text` together pin that.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum TenantStatus {
22 /// Reconciled and serving.
23 Active,
24 /// Registered, reconciliation not yet succeeded.
25 Provisioning,
26 /// The last reconciliation failed: requests answer
27 /// `503 tenant-degraded` until a boot succeeds.
28 Degraded,
29}
30
31impl TenantStatus {
32 /// The registry's text form, lowercase and stable.
33 #[must_use]
34 pub fn as_str(self) -> &'static str {
35 match self {
36 Self::Active => "active",
37 Self::Provisioning => "provisioning",
38 Self::Degraded => "degraded",
39 }
40 }
41
42 /// Parses the registry's text form; anything else (a future version
43 /// wrote a status this binary does not know) reads as
44 /// [`TenantStatus::Degraded`] — refuse, do not guess.
45 #[must_use]
46 pub fn parse(status: &str) -> Self {
47 match status {
48 "active" => Self::Active,
49 "provisioning" => Self::Provisioning,
50 _ => Self::Degraded,
51 }
52 }
53}
54
55impl std::fmt::Display for TenantStatus {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.write_str(self.as_str())
58 }
59}
60
61/// A tenant's key in the control database's registry.
62///
63/// Not `String`, and not constructible outside core:
64///
65/// ```compile_fail
66/// use cratefield_core::TenantId;
67/// // The field is private: module code cannot name a tenant.
68/// let _id = TenantId("someone-elses-tenant".to_owned());
69/// ```
70///
71/// Naming the type is fine, which keeps signatures writable:
72///
73/// ```
74/// use cratefield_core::TenantId;
75/// fn takes_id(_id: &TenantId) {}
76/// ```
77#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
78pub struct TenantId(String);
79
80impl TenantId {
81 /// Minted from a registry row. `pub(crate)` on purpose — see the
82 /// module docs.
83 pub(crate) fn new(id: impl Into<String>) -> Self {
84 Self(id.into())
85 }
86
87 #[must_use]
88 pub fn as_str(&self) -> &str {
89 &self.0
90 }
91}
92
93impl std::fmt::Display for TenantId {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.write_str(&self.0)
96 }
97}
98
99/// The tenant a request belongs to. Immutable, and built only by core's
100/// resolution layer from a registry row — never from a header, a path
101/// segment, or module code.
102///
103/// `Clone` because a request carries it into deferred work. Deliberately
104/// **not** `Deserialize`: the threat is an identity read back in from an
105/// attacker-supplied body, not one written out, so `Serialize` is fine
106/// and wanted — a tenant id is a structured log field.
107///
108/// ```compile_fail
109/// use cratefield_core::{Tenant, TenantStatus};
110/// // No public constructor, so a handler cannot conjure a neighbour.
111/// let _t = Tenant::new("other", TenantStatus::Active);
112/// ```
113#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
114pub struct Tenant {
115 id: TenantId,
116 status: TenantStatus,
117}
118
119impl Tenant {
120 pub(crate) fn new(id: impl Into<String>, status: TenantStatus) -> Self {
121 Self {
122 id: TenantId::new(id),
123 status,
124 }
125 }
126
127 #[must_use]
128 pub fn id(&self) -> &TenantId {
129 &self.id
130 }
131
132 #[must_use]
133 pub fn status(&self) -> TenantStatus {
134 self.status
135 }
136}
137
138/// What a deployment's registry says about a host.
139///
140/// The design sketch had three arms — `Found(Tenant)`, `Unknown`,
141/// `Degraded(TenantId)` — but a `Tenant` already carries its status, so
142/// the third was the same fact twice and two places to keep in step. It
143/// also could not work: a `ResolveTenant` impl lives in a runtime crate,
144/// and if the arm carried a `Tenant` the constructor would have to be
145/// `pub`, which is exactly what §3 wants to avoid. So the trait reports
146/// what the registry said and **core mints the [`Tenant`]**.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum Resolution {
149 /// A registry row for this host.
150 Found {
151 /// The row's primary key.
152 id: String,
153 /// The row's status, parsed fail-closed by
154 /// [`TenantStatus::parse`].
155 status: TenantStatus,
156 },
157 /// No row for this host: `404 unknown-tenant`.
158 Unknown,
159}
160
161impl Resolution {
162 /// The tenant a request may proceed with, or the problem that refuses
163 /// it.
164 ///
165 /// Only [`TenantStatus::Active`] serves. `Provisioning` is refused
166 /// with the same `503 tenant-degraded` as `Degraded`: it means
167 /// registered-but-not-yet-reconciled, so its schema is not known to
168 /// match the code, which is the condition the refusal exists for. One
169 /// slug rather than two because the caller's situation is identical —
170 /// come back later — and the operator learns which from the registry,
171 /// not from the response.
172 pub(crate) fn admit(self) -> Result<Tenant, &'static crate::problems::ProblemDef> {
173 match self {
174 Self::Found { id, status } if status == TenantStatus::Active => {
175 Ok(Tenant::new(id, status))
176 }
177 Self::Found { .. } => Err(&crate::problems::SLUGS.tenant_degraded),
178 Self::Unknown => Err(&crate::problems::SLUGS.unknown_tenant),
179 }
180 }
181}
182
183/// How a deployment turns a request's host into a tenant.
184///
185/// A trait so a deployment that must resolve by something else can, and
186/// so tests resolve without DNS. Host is the harness's answer — it is
187/// established before any tenant state is consulted, is already validated
188/// (#129), and is what the registry is keyed on anyway. A caller-supplied
189/// header is refused as policy: a deployment that got
190/// `TRUSTED_PROXY_HEADERS` wrong would let any caller name any tenant,
191/// and the blast radius of that mistake is every tenant's data
192/// (TENANT-ROUTING.md §3).
193pub trait ResolveTenant: Send + Sync {
194 /// The registry's answer for `host`. Implementations must not block
195 /// on a per-request round trip to the control database; see §13's
196 /// open question on cache lifetime.
197 fn resolve(&self, host: &str) -> Resolution;
198}
199
200/// How a deployment turns a resolved tenant into a database handle.
201///
202/// Separate from [`ResolveTenant`] because the two answers come from
203/// different places and fail differently: resolution reads a registry row
204/// (cheap, cacheable, and a miss means `404`), while this opens or reuses
205/// a connection pool (lazy, evictable, and a failure means the tenant is
206/// unreachable rather than unknown).
207///
208/// Implemented by the runtime, never by a module. The registry that backs
209/// it is the only thing in the process that knows a DSN
210/// (TENANT-ROUTING.md §4): it maps a connect failure to
211/// [`TenantDbError::Unreachable`] **before** the URL can reach a log line
212/// or a response body. `Display for DbError` already scrubs, so that is
213/// not the only defence — but `Debug` derives raw, and a DSN formatted
214/// before it ever becomes a `DbError` is scrubbed by nothing. Not
215/// producing the string is the first line; the sink is the second.
216#[async_trait::async_trait]
217pub trait TenantDatabases: Send + Sync {
218 /// The handle for `tenant`, opening its pool on first use.
219 ///
220 /// # Errors
221 ///
222 /// [`TenantDbError::Unreachable`] when the tenant's database cannot be
223 /// reached. The DSN is never part of the error.
224 async fn database(
225 &self,
226 tenant: &Tenant,
227 ) -> Result<std::sync::Arc<dyn crate::ports::Database>, TenantDbError>;
228}
229
230/// Why a tenant's database could not be handed over. Deliberately carries
231/// the tenant id and nothing else — no DSN, no driver message.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum TenantDbError {
234 /// The pool could not be opened or the server did not answer.
235 Unreachable {
236 /// Which tenant. Safe to log.
237 tenant: String,
238 },
239}
240
241impl std::fmt::Display for TenantDbError {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 match self {
244 Self::Unreachable { tenant } => {
245 write!(f, "tenant `{tenant}`'s database is unreachable")
246 }
247 }
248 }
249}
250
251impl std::error::Error for TenantDbError {}
252
253/// A deployment's tenant plane: a host becomes a tenant, and a tenant
254/// becomes a database handle.
255///
256/// One slot on [`Ports`](crate::Ports) rather than two, because the
257/// resolution layer needs both halves of the same request and a
258/// deployment that supplied only one would be misconfigured in a way no
259/// type could catch. The blanket impl means a runtime writes the two
260/// traits and gets this for free.
261pub trait TenantRouting: ResolveTenant + TenantDatabases {}
262
263impl<T: ResolveTenant + TenantDatabases> TenantRouting for T {}
264
265/// The registry id a deployment without a registry resolves to.
266///
267/// Not a magic string scattered across three runtimes: one constant, so
268/// a log line from a Cloudflare Worker and one from a `cargo test` run
269/// say the same word.
270pub const IMPLICIT_TENANT: &str = "default";
271
272/// The resolver a deployment with **no control database** uses: every
273/// host is the one venture, always `active`.
274///
275/// This is not the Cloudflare path, it is the *no registry* path — which
276/// is also the browser runtime, and native in every development run and
277/// every test (TENANT-ROUTING.md §6). Scoping it to Cloudflare would mean
278/// `cargo test` cannot resolve a tenant and every module suite 500s.
279///
280/// A module is therefore written once, against the stricter shape, and
281/// the path most ventures actually run in production is not the one
282/// without the isolation.
283#[derive(Debug, Clone, Copy, Default)]
284pub struct ImplicitTenant;
285
286impl ResolveTenant for ImplicitTenant {
287 fn resolve(&self, _host: &str) -> Resolution {
288 Resolution::Found {
289 id: IMPLICIT_TENANT.to_owned(),
290 status: TenantStatus::Active,
291 }
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::{Resolution, TenantStatus};
298
299 fn found(status: TenantStatus) -> Resolution {
300 Resolution::Found {
301 id: "acme".to_owned(),
302 status,
303 }
304 }
305
306 #[test]
307 fn the_implicit_tenant_answers_for_every_host() {
308 use super::{IMPLICIT_TENANT, ImplicitTenant, ResolveTenant};
309 // A deployment with no registry has one tenant, and which host
310 // asked is not a question it can answer differently - including
311 // for a host it has never seen, which is what a test client and a
312 // loopback probe both look like.
313 for host in ["acme.example", "localhost:8787", ""] {
314 let tenant = ImplicitTenant
315 .resolve(host)
316 .admit()
317 .expect("the implicit tenant always serves");
318 assert_eq!(tenant.id().as_str(), IMPLICIT_TENANT);
319 assert_eq!(tenant.status(), TenantStatus::Active);
320 }
321 }
322
323 #[test]
324 fn only_an_active_tenant_is_admitted() {
325 let tenant = found(TenantStatus::Active)
326 .admit()
327 .expect("an active tenant serves");
328 assert_eq!(tenant.id().as_str(), "acme");
329 assert_eq!(tenant.status(), TenantStatus::Active);
330 }
331
332 #[test]
333 fn provisioning_is_refused_like_degraded_and_not_like_unknown() {
334 // Registered but not yet reconciled: the schema is not known to
335 // match the code, which is the condition the refusal exists for.
336 // The caller's situation is identical to degraded - come back
337 // later - so it is one slug, and the operator learns which from
338 // the registry rather than from the response.
339 for status in [TenantStatus::Provisioning, TenantStatus::Degraded] {
340 let problem = found(status).admit().expect_err("must not serve");
341 assert_eq!(problem.slug, "tenant-degraded", "for {status}");
342 assert_eq!(problem.status.as_u16(), 503);
343 }
344 }
345
346 #[test]
347 fn an_unknown_host_is_a_404_not_a_503() {
348 // Distinguishable on purpose: 503 says "this tenant exists and is
349 // unwell", 404 says "no such tenant". Collapsing them would tell
350 // an operator the wrong thing during an incident.
351 let problem = Resolution::Unknown.admit().expect_err("must not serve");
352 assert_eq!(problem.slug, "unknown-tenant");
353 assert_eq!(problem.status.as_u16(), 404);
354 }
355
356 #[test]
357 fn a_status_this_binary_does_not_know_is_refused_not_guessed() {
358 // `parse` is fail-closed, so a future version writing a status
359 // this binary has never heard of reads as degraded and is
360 // refused. The alternative - defaulting to active - would serve a
361 // tenant on the strength of not understanding it.
362 let unknown = TenantStatus::parse("quiesced");
363 assert_eq!(unknown, TenantStatus::Degraded);
364 assert!(found(unknown).admit().is_err());
365 }
366
367 #[test]
368 fn serializes_as_the_registry_text_not_the_variant_name() {
369 for status in [
370 TenantStatus::Active,
371 TenantStatus::Provisioning,
372 TenantStatus::Degraded,
373 ] {
374 let json = serde_json::to_string(&status).expect("a unit variant serializes");
375 assert_eq!(
376 json,
377 format!("\"{}\"", status.as_str()),
378 "the wire form is the registry's word, so a log field and a row agree"
379 );
380 }
381 }
382
383 #[test]
384 fn round_trips_through_the_registry_text() {
385 for status in [
386 TenantStatus::Active,
387 TenantStatus::Provisioning,
388 TenantStatus::Degraded,
389 ] {
390 assert_eq!(TenantStatus::parse(status.as_str()), status);
391 }
392 }
393
394 #[test]
395 fn an_unknown_status_reads_as_degraded() {
396 assert_eq!(TenantStatus::parse("busy"), TenantStatus::Degraded);
397 assert_eq!(TenantStatus::parse(""), TenantStatus::Degraded);
398 }
399}