Skip to main content

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}