boatramp_types/tenancy.rs
1//! In-site tenancy configuration — the declared side of the tenant-isolation model.
2//!
3//! An app **opts into** in-site sub-tenancy per function/site by declaring a [`Tenancy::Scoped`]
4//! block: a tenant column + a host-verified [`TenantSource`] + per-axis [`AccessMode`] grants.
5//! A config with **no** [`Tenancy`] block means *undeclared*; [`Tenancy::Disabled`] means
6//! *deliberately no in-site tenancy* — plain queries, where the project=database boundary is the
7//! entire isolation. The distinction matters only under the `multi-tenant` operator posture,
8//! which **requires** an explicit decision (`disabled` or `scoped`) so running plain is a
9//! reviewed choice, never an accidental omission; `single-tenant`/`dev` treat *undeclared* as
10//! `disabled` silently.
11//!
12//! This module carries only the wasm-clean *declaration*. Resolving the tenant **value** from the
13//! source and building the injected row scope happens above (host-side), where `SqlValue` lives.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
18use serde::{Deserialize, Serialize};
19
20/// How the host resolves an app's in-site "own" tenant for a request. Every source is
21/// **host-verified** and bound once per invocation; a guest never supplies the tenant value.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(tag = "kind", rename_all = "snake_case")]
24#[derive(Default)]
25pub enum TenantSource {
26 /// The verified JWT claim named `claim` (default `tid`), via the same JWKS/issuer machinery
27 /// the GraphQL data connector uses. Authenticated console/portal paths.
28 Token {
29 #[serde(default = "default_tid_claim")]
30 claim: String,
31 },
32 /// Derived from the already-verified request domain via its per-domain context tag
33 /// ([`crate::project::DomainOwner`]'s context). Storefront / public-render paths — no
34 /// app-side `Host`→slug lookup.
35 Domain,
36 /// A host-verifiable signed-context envelope carried on a job/message — the **async-lane "own"**
37 /// (message consumers, cron, webhooks, workflow steps, fan-out workers), the async analog of
38 /// `Token`. The producer's verified own-tenant is host-stamped onto the outgoing
39 /// message/invoke at publish (guest-blind, [`boatramp_core::cose::mint_context`]); a consumer
40 /// declaring this source resolves that tenant by verifying the envelope against the fleet anchor.
41 /// A forged/expired/absent envelope resolves no value ⇒ an "own" op fails closed (never
42 /// unscoped). The guest never names a tenant. Wired since v0.4.3 (producer-stamp at publish +
43 /// consumer resolution in `tenant_resolve.rs`).
44 SignedContext,
45 /// Truly anonymous / non-token auth (funnel reads, HMAC webhooks): there is no "own" tenant,
46 /// so only the `null`/`all` access modes are meaningful (an "own" mode fails closed).
47 #[default]
48 None,
49}
50
51fn default_tid_claim() -> String {
52 "tid".to_string()
53}
54
55/// The default source list when a `Scoped` block omits it: truly anonymous (`[None]`) — an "own"
56/// grant then fails closed until a source is declared.
57fn default_sources() -> Vec<TenantSource> {
58 vec![TenantSource::None]
59}
60
61/// Deserialize the [`Tenancy::Scoped`] `sources` list, accepting BOTH the Stage-2 list form
62/// (`sources: [ (kind: token), (kind: domain) ]` — priority-ordered per-trigger sources) AND — for
63/// backward compatibility with the pre-Stage-2 singular `source:` field (v0.4.0) — a single source
64/// written as one map (`source: (kind: token)`), which becomes a one-element list. Uses
65/// `deserialize_any` (the wire format is self-describing) so a seq → many and a map → the singleton,
66/// **without** an `untagged` enum (which RON — the manifest format — handles poorly). A pre-Stage-2
67/// config therefore keeps resolving exactly as before.
68fn de_sources<'de, D>(deserializer: D) -> Result<Vec<TenantSource>, D::Error>
69where
70 D: Deserializer<'de>,
71{
72 struct SourcesVisitor;
73 impl<'de> Visitor<'de> for SourcesVisitor {
74 type Value = Vec<TenantSource>;
75 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
76 f.write_str("a TenantSource map or a list of TenantSource maps")
77 }
78 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
79 let mut out = Vec::new();
80 while let Some(s) = seq.next_element::<TenantSource>()? {
81 out.push(s);
82 }
83 Ok(out)
84 }
85 fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
86 // A single source written as a struct-map (the legacy `source:` singular).
87 let s = TenantSource::deserialize(de::value::MapAccessDeserializer::new(map))?;
88 Ok(vec![s])
89 }
90 }
91 deserializer.deserialize_any(SourcesVisitor)
92}
93
94/// A **closed** set of the host-verified/host-resolved sources for the *target* axis (R4/D8) — a
95/// SECOND tenant `B` (≠ the caller's own tenant `A`), used only to read `B`'s deliberately-published
96/// PUBLIC subset. Deliberately **distinct** from [`TenantSource`] so `Handle` (a public slug) is
97/// *unrepresentable* on the own/session/private axes at the type level — a guest can never name its
98/// OWN tenant, only a public target, and only within the guardrails (G1–G6). `via` is a
99/// priority-ordered list of these, homogeneous in tier by construction.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102#[non_exhaustive]
103pub enum TargetSource {
104 /// The terminating request domain, host-verified (same-origin, write-capable) — a world-public
105 /// funnel served on the tenant's own host. Tier-2 (published storefront/directory).
106 Domain,
107 /// A public **slug/handle** passed from a third-party origin (embed / aggregator / preview):
108 /// **READ-ONLY (G1)** and admissible **only** on a `world_public` subset (G2). It names public
109 /// data, so it grants nothing an anonymous GET of that data wouldn't. Never on a write field.
110 Handle,
111 /// A host-verified **capability token** carrying the target facts (`tid`, `sub`): the token is
112 /// the *authorization* (tier-3 embed/handoff, NOT world-public), so it is never mixed with
113 /// `Handle` and can back a target write.
114 Capability,
115}
116
117/// The tenancy **class** a root Query/Mutation field (or a plain-wasm route) runs under (R4/D8),
118/// composed from the trusted SDL `@tenant` directive at publish and gated by the operator's
119/// [`TenancySchema::target_eligible_fields`]. Looked up by the host planner and bound **before the
120/// guest runs** — there is no request-time parameter expressing own-vs-target, so a guest can never
121/// select or detect which scope it got (picking the wrong scope is *unrepresentable*). Absent ⇒
122/// `Own` (byte-identical to pre-Stage-5).
123#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
124#[serde(tag = "scope", rename_all = "snake_case")]
125#[non_exhaustive]
126pub enum TenancyClass {
127 /// The caller's OWN resolved tenant/session (today's behavior).
128 #[default]
129 Own,
130 /// A SECOND tenant `B`'s PUBLIC subset. `via` is the prioritized target-source list
131 /// (first-resolves-wins); `public` NAMES the host-held public subset to confine to; `write` is
132 /// the (deny-by-default, empty ⇒ read-only) SET-allowlist of columns a target write may set.
133 Target {
134 via: Vec<TargetSource>,
135 public: String,
136 #[serde(default)]
137 write: Vec<String>,
138 /// **`target_or_null`** (the target-axis analog of [`AccessMode::OwnOrNull`]): when `true`, a
139 /// target READ confines to `(<tenant col> = B OR <tenant col> IS NULL) AND <public subset>` —
140 /// `B`'s public rows PLUS the shared `NULL`-tenant **base/reference** rows (the inheritance
141 /// floor a public funnel needs) — instead of `tenant = B` alone. Read-only: a target WRITE
142 /// still stamps `B` (never the base). The `NULL` disjunct is added ONLY on plain tenant
143 /// (`Column`) tables, never a `TenantOrSession` table (its `NULL` partition is session rows,
144 /// not shared base) nor an `Unscoped` global. Surfaced in the SDL as `scope: target_or_null`.
145 #[serde(default)]
146 null_base: bool,
147 },
148}
149
150impl TenancyClass {
151 /// Whether this class reads/writes another tenant (target axis) vs. the caller's own.
152 pub fn is_target(&self) -> bool {
153 matches!(self, Self::Target { .. })
154 }
155}
156
157/// One host-held visibility term of a [`PublicPredicate`] — `column <op> literal` or a null test.
158/// Never a DSL, never guest-authored or claim-bound: a fixed shape over a literal only.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(tag = "kind", rename_all = "snake_case")]
161pub enum PublicTerm {
162 /// `column <op> <value>` (e.g. `published = true`).
163 Cmp {
164 column: String,
165 op: PublicCmp,
166 value: PublicLiteral,
167 },
168 /// `column IS [NOT] NULL` (e.g. `deleted_at IS NULL`).
169 Null { column: String, negated: bool },
170}
171
172/// The comparison operators a [`PublicTerm`] may use (a closed set — visibility predicates only).
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum PublicCmp {
176 Eq,
177 Ne,
178 Lt,
179 Le,
180 Gt,
181 Ge,
182}
183
184/// A literal a [`PublicTerm`] compares against. Types-local (this crate is `SqlValue`-free —
185/// boatramp-core lowers it to a bound `SqlValue` at injection, so the literal is always a parameter,
186/// never interpolated text).
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum PublicLiteral {
190 Bool(bool),
191 Int(i64),
192 Text(String),
193}
194
195/// A host-held definition of a table's PUBLIC rows (R4/D8): a **closed conjunction** of visibility
196/// terms (`published = true AND deleted_at IS NULL`). A target READ conjoins it (never sees a
197/// non-public row of `B`); a target WRITE filters on it (a non-public row is a fail-closed no-op).
198/// Host-held per table — never a guest-authored DSL — so a guest can never widen its own visibility.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
200pub struct PublicPredicate {
201 /// The conjoined terms (AND). Empty is legal but meaningless (matches every row) — the schema
202 /// loader should reject an empty public predicate on a `world_public` subset.
203 pub terms: Vec<PublicTerm>,
204}
205
206/// A named PUBLIC subset (R4/D8): a table's [`PublicPredicate`] plus the two **separate**,
207/// deny-by-default, operator-held flags that gate the least-trusted target sources.
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
209#[serde(default, deny_unknown_fields)]
210pub struct PublicSubset {
211 /// The visibility predicate confining a target read/write to public rows.
212 pub predicate: PublicPredicate,
213 /// **G2** — is this subset readable by an anonymous `Handle` (public slug) at all? A SEPARATE,
214 /// explicit flag, NOT "has a public subset" (every target field has one, incl. tier-3 capability
215 /// data). `false` (default) ⇒ `handle` is refused on this subset at composition, regardless of a
216 /// field's declared `via` list — so a tier-2 handle can never reach tier-3 data.
217 pub world_public: bool,
218 /// Whether this table's tenants are discoverable by a `handle` lookup (`SELECT tenant WHERE slug
219 /// = ? AND listable = true`). Directory-scraping of listable tenants is confidentiality-neutral
220 /// (that is what listable means) but rate-limited (G5). `false` (default) ⇒ no handle resolves.
221 pub listable: bool,
222}
223
224/// Which **axis** a resolved tenant fact belongs to (`PLAN-tenancy-principal` D1). The host-resolved
225/// principal is a small *set* of facts, each tagged with its axis, so an inherited/carried principal
226/// preserves which axis a value belongs to (e.g. an inherited `TargetTenant` fact keeps its
227/// public-subset confinement, never collapsing into an `own` `Tenant` fact).
228///
229/// **CLOSED enum — the line.** Only these three axes exist, ever: `Tenant` (the caller's own tenant,
230/// Stage 2), `Session` (an anonymous-identity disjunct, Stage 3), `TargetTenant` (one *other*
231/// tenant's public subset, Stage 5). A fourth axis is a design smell — extend a table's scope class
232/// or a fact's lifetime instead. `#[non_exhaustive]` only so the later stages can land their variants
233/// without a breaking change for downstream crates; it is not an invitation to add a fourth.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236#[non_exhaustive]
237pub enum ScopeAxis {
238 /// The caller's own tenant — resolved per trigger from a [`TenantSource`] (Stage 2).
239 Tenant,
240 /// An anonymous returning-visitor identity, a disjunct of `Tenant` (Stage 3).
241 Session,
242 /// One *other* tenant, read-only, confined to a host-declared public subset (Stage 5).
243 TargetTenant,
244}
245
246/// Which tenant-set one axis (read or write) of a function may reach. **Default-deny** on
247/// cross-tenant: only [`AccessMode::All`] crosses tenants, and it needs the operator posture
248/// ceiling to permit it. Distinguishes every case: own, own+null, null-only, all, and no access.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum AccessMode {
252 /// No access at all on this axis (deny).
253 None,
254 /// The shared baseline only (`<column> IS NULL`).
255 Null,
256 /// The resolved tenant only.
257 Own,
258 /// The resolved tenant plus the shared baseline.
259 OwnOrNull,
260 /// Cross-tenant — all rows. Gated by the operator posture ceiling.
261 All,
262}
263
264impl AccessMode {
265 /// Whether this mode crosses tenants (so it needs the operator ceiling to be permitted).
266 pub fn is_cross_tenant(self) -> bool {
267 matches!(self, Self::All)
268 }
269 /// Whether this mode needs a resolved "own" tenant value (so an unresolvable source ⇒ deny).
270 pub fn needs_own_value(self) -> bool {
271 matches!(self, Self::Own | Self::OwnOrNull)
272 }
273 /// Whether the row-set this mode reaches is a **subset** of `ceiling`'s row-set — i.e. this
274 /// mode is a narrowing (or equal), never a widening. Used to enforce that a per-component
275 /// access grant stays within its site ceiling. The modes form a subset lattice, not a total
276 /// order: `Own` and `Null` are disjoint (neither contains the other), so this is a real
277 /// containment test, not a rank comparison.
278 pub fn within(self, ceiling: Self) -> bool {
279 use AccessMode::*;
280 match self {
281 None => true, // {} ⊆ anything
282 Null => matches!(ceiling, Null | OwnOrNull | All),
283 Own => matches!(ceiling, Own | OwnOrNull | All),
284 OwnOrNull => matches!(ceiling, OwnOrNull | All),
285 All => matches!(ceiling, All),
286 }
287 }
288}
289
290fn default_own() -> AccessMode {
291 AccessMode::Own
292}
293
294/// Deserialize an `Option<Tenancy>` config field from **either RON or JSON**, via a `ron::Value`
295/// bridge.
296///
297/// `Tenancy` is an internally-tagged enum (`tag = "mode"`) whose variants carry **enum-valued
298/// fields** (`read`/`write` = [`AccessMode`], `sources` = [`TenantSource`], `via` =
299/// [`TargetSource`]). serde's internally-tagged deserialization buffers the content through
300/// `deserialize_any`, and RON's `deserialize_any` collapses a bare/nested enum value to a *unit*
301/// — so a direct `Tenancy::deserialize` from RON fails (`expected variant identifier, found a unit
302/// value`) in every spelling. serde_json has no such issue.
303///
304/// Routing through [`ron::Value`] — a faithful intermediate that both the RON and the serde_json
305/// deserializers populate correctly — and reconstructing with [`ron::Value::into_rust`] parses one
306/// canonical spelling everywhere:
307/// `(mode: "scoped", column: "tenant_id", sources: [(kind: "token", claim: "tid")], read: "own",
308/// write: "own")` in `project.cfg`/`apply.cfg`, and the byte-identical `{"mode":"scoped",…}` the
309/// control plane stores. (Serialization is unaffected — the derived `Serialize` still emits the
310/// internally-tagged form.)
311pub fn de_opt_tenancy<'de, D>(deserializer: D) -> Result<Option<Tenancy>, D::Error>
312where
313 D: serde::Deserializer<'de>,
314{
315 use serde::Deserialize;
316 match Option::<ron::Value>::deserialize(deserializer)? {
317 Some(value) => value
318 .into_rust::<Tenancy>()
319 .map(Some)
320 .map_err(serde::de::Error::custom),
321 None => Ok(None),
322 }
323}
324
325/// A function/site's in-site tenancy decision. Its **presence** (`Some`) is the explicit
326/// "I decided about tenancy" signal the `multi-tenant` posture requires; absence (`None`) means
327/// *undeclared*.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(tag = "mode", rename_all = "snake_case")]
330pub enum Tenancy {
331 /// Deliberately no in-site tenancy — plain queries (the project=database boundary is the whole
332 /// isolation). The explicit "single-tenant / no tenancy" declaration.
333 Disabled,
334 /// In-site sub-tenancy on `column`, resolving "own" from the first applicable `sources` entry,
335 /// at the per-axis grants.
336 Scoped {
337 /// The tenant column the host scopes on (e.g. `tenant_id`). Validated as a SQL identifier
338 /// when the scope is applied.
339 column: String,
340 /// The host-verified sources the "own" tenant may resolve from, in **priority order** — the
341 /// host picks the first whose current-trigger input is present (a `token` on an authenticated
342 /// HTTP request, the routed `domain` on a storefront, a `signed_context` on an async job), so
343 /// one component can serve multiple trigger kinds (`PLAN-tenancy-principal` R1). Accepts the
344 /// pre-Stage-2 singular `source:` map too (back-compat, [`de_sources`]). Default `[None]`
345 /// (anonymous — an "own" grant fails closed).
346 #[serde(
347 default = "default_sources",
348 alias = "source",
349 deserialize_with = "de_sources"
350 )]
351 sources: Vec<TenantSource>,
352 /// Which tenant-set reads may reach (default [`AccessMode::Own`]).
353 #[serde(default = "default_own")]
354 read: AccessMode,
355 /// Which tenant-set writes may reach (default [`AccessMode::Own`]).
356 #[serde(default = "default_own")]
357 write: AccessMode,
358 },
359 /// **Target** (R4/D8): this route/handler reads (and, with a `write` grant, writes) a SECOND
360 /// tenant `B`'s PUBLIC subset (never the caller's own). The non-federated (plain-wasm) analog of
361 /// a GraphQL `@tenant(scope: target)` field: the host resolves `B` from the first applicable
362 /// [`via`](Self::Target::via) source (5a: the routed domain) and binds a target scope BEFORE the
363 /// guest runs — confining every `orm` access to `tenant = B AND <public subset>` (deny-by-default
364 /// on an undeclared subset), and AST-rewriting every raw-`sql` READ the same way. A distinct
365 /// variant from [`Scoped`](Self::Scoped) so a route can't be both own and target (a misdeclaration
366 /// is unrepresentable). Gated by the operator's [`TenancySchema::target_eligible_fields`].
367 Target {
368 /// The prioritized target-source list (first-resolves-wins). 5a resolves only `domain`.
369 via: Vec<TargetSource>,
370 /// Names the host-held public subset (a table in [`TenancySchema::public_subsets`]) this
371 /// route's raw-`sql`/`orm` accesses confine to; the `orm` path confines every accessed table
372 /// on its own declared subset.
373 public: String,
374 /// **Target WRITE grant (5b), deny-by-default.** The SET-allowlist of columns a target write
375 /// (INSERT / UPDATE, via the typed `orm` only) may set. **Empty ⇒ read-only** (today's
376 /// behavior). Non-empty ⇒ the guest may INSERT/UPDATE rows in `B`'s public subset, setting
377 /// ONLY these columns — the host force-stamps `tenant = B` and the public-visibility columns,
378 /// confines an UPDATE's `WHERE` to `tenant = B AND <public>`, and refuses a DELETE, a raw-SQL
379 /// write, or any attempt to set the tenant/visibility columns (so a target write can never
380 /// change ownership or flip a row's visibility). The tenant/public columns MUST NOT appear in
381 /// this list.
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
383 write: Vec<String>,
384 /// **`target_or_null`** (the target-axis analog of [`AccessMode::OwnOrNull`]): when `true`, a
385 /// target READ confines to `(<tenant col> = B OR <tenant col> IS NULL) AND <public subset>` —
386 /// `B`'s public rows PLUS the shared `NULL`-tenant **base/reference** rows (a public funnel's
387 /// inheritance floor) — instead of `tenant = B` alone. Read-only: a target WRITE still stamps
388 /// `B`. The `NULL` disjunct is added ONLY on plain tenant (`Column`) tables, never a
389 /// `TenantOrSession` (its `NULL` partition is session rows) nor an `Unscoped` global.
390 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
391 null_base: bool,
392 },
393}
394
395impl Tenancy {
396 /// Whether this decision enables in-site row scoping (own [`Scoped`](Self::Scoped) or
397 /// [`Target`](Self::Target)), vs. plain queries ([`Disabled`](Self::Disabled)).
398 pub fn is_scoped(&self) -> bool {
399 matches!(self, Self::Scoped { .. } | Self::Target { .. })
400 }
401
402 /// Whether this decision reads/writes a SECOND tenant (the target axis) rather than the caller's
403 /// own — the plain-wasm analog of a `@tenant(scope: target)` field.
404 pub fn is_target(&self) -> bool {
405 matches!(self, Self::Target { .. })
406 }
407
408 /// Whether this (own) decision names the R1 async-lane [`SignedContext`](TenantSource::SignedContext)
409 /// source — i.e. a **consumer** that resolves the originator's sealed tenant from a drained
410 /// message's `signed_context`. A consumer that declares it must have its bindings rebuilt PER
411 /// MESSAGE with that message's envelope (the site-`consumers` dispatch); a decision that does not
412 /// declare it resolves identically whether or not an envelope is present, so it uses the efficient
413 /// built-once binding. `false` for `Target`/`Disabled`.
414 pub fn declares_signed_context(&self) -> bool {
415 matches!(self, Self::Scoped { sources, .. } if sources.contains(&TenantSource::SignedContext))
416 }
417
418 /// Whether `self` (a **per-component** decision, e.g. a per-handler tenancy) stays **within**
419 /// `ceiling` (the **site-level** decision) — never widening the reachable tenant-set. Enforced
420 /// fail-closed at bind so a per-handler value can narrow within its site ceiling but not widen
421 /// it (`HandlersSiteConfig::tenancy`). Both are deploy-author config; this guards against an
422 /// accidental widening, and it is the sole security-relevant relationship because the operator
423 /// posture separately caps [`AccessMode::All`].
424 ///
425 /// Subset semantics (not a rank): `Disabled` is the **broadest** in-site policy (plain queries,
426 /// no tenant filter → reaches every row in the project db), so removing scoping under a scoped
427 /// ceiling is a widening. The `Target` axis is governed by the operator's separate
428 /// `target_eligible_fields` allowlist, so a target-vs-own axis mismatch across the ceiling is
429 /// refused (fail-closed) rather than silently reinterpreted.
430 pub fn narrows_within(&self, ceiling: &Self) -> bool {
431 use Tenancy::*;
432 match (self, ceiling) {
433 // A site doing no in-site scoping already reaches every row, so any per-handler
434 // decision is a narrowing-or-equal.
435 (_, Disabled) => true,
436 // Removing scoping under a scoped/target ceiling is a WIDENING — refuse.
437 (Disabled, _) => false,
438 // Same-axis in-site scoping: same tenant column, and each grant is a subset.
439 (
440 Scoped {
441 column: c,
442 read: r,
443 write: w,
444 ..
445 },
446 Scoped {
447 column: cc,
448 read: rc,
449 write: wc,
450 ..
451 },
452 ) => c == cc && r.within(*rc) && w.within(*wc),
453 // The target axis is operator-gated (`target_eligible_fields`); a target handler under a
454 // target ceiling is within.
455 (Target { .. }, Target { .. }) => true,
456 // Any own-vs-target axis mismatch across the ceiling is a misdeclaration — refuse.
457 _ => false,
458 }
459 }
460}
461
462/// How the host scopes one table under a project's [`TenancySchema`] (`PLAN-tenancy-principal`,
463/// Decision A / D2 / D3). A table's scope is a fact of the **data model**, declared per project (the
464/// guiding principle — the app configures its own concepts — not baked into a component). New
465/// variants (the R3 session disjunct, the R4 target public-subset) land in later stages; the enum is
466/// `#[non_exhaustive]` so adding them is not a breaking change for downstream crates.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(tag = "kind", rename_all = "snake_case")]
469#[non_exhaustive]
470pub enum TableScope {
471 /// Scope on the schema's [`default_tenant_key`](TenancySchema::default_tenant_key) = the resolved
472 /// tenant (the common case).
473 Tenant,
474 /// The identity table (`tenant`/`org`/`account`), keyed by its own PK: scope on `key` = the
475 /// resolved tenant instead of the default column (R2). `key` MUST be unique — a non-unique key
476 /// would match other tenants' rows — validated at schema load.
477 TenantKeyed { key: String },
478 /// Global reference/enum data (`countries`): reads are unscoped (reachable even by a
479 /// principal-less request — fail-closed is per table-scope, not per invocation); writes are
480 /// deny-by-default (a shared-data write is a cross-tenant blast). Host-declared, never
481 /// guest-inferred (a guest can't mark a sensitive table global).
482 Unscoped,
483 /// An **anonymous-first** table (R3): rows are owned EITHER by a resolved tenant
484 /// (`default_tenant_key = <Tenant fact>`) OR by an anonymous session
485 /// ([`session_key`](TenancySchema::session_key)` = <Session fact>`, on `default_tenant_key IS
486 /// NULL` rows). A read lowers to the disjunction `Or([tenant_key = T, session_key = S])` over
487 /// whichever axis facts the request carries; the disjoint columns confine a cheap anon session
488 /// to `tenant IS NULL` rows structurally (never tenant-owned rows). Requires the schema to set
489 /// `session_key`; a `TenantOrSession` table with no `session_key` is refused (deny-by-default).
490 ///
491 /// The confinement rests on the invariant **a session-owned row has `default_tenant_key IS
492 /// NULL`** — the host write path enforces it (an anon write stamps only `session_key`, leaving
493 /// the tenant key NULL; `promote` is the sole cross-partition move, guarded by `tenant_key IS
494 /// NULL`). An app should add a DB `CHECK (<tenant_key> IS NULL OR <session_key> IS NULL)` as
495 /// belt-and-suspenders: a raw-SQL migration or an `all`-grant write that set BOTH columns on one
496 /// row would let a session reader match a row a tenant also owns.
497 TenantOrSession,
498 /// A **base-inclusive** table: rows with `default_tenant_key IS NULL` are **shared base** data
499 /// (global reference rows — e.g. base EP-vocabulary packs), readable by every tenant, while
500 /// non-NULL rows are the usual per-tenant rows. A READ confines to
501 /// `(default_tenant_key = <resolved own/target> OR default_tenant_key IS NULL)` (AND the public
502 /// subset on the target axis) on BOTH the own and target axes — the shared base is folded in
503 /// regardless of the *field's* scope, so a mixed-table field can fold the base into only the
504 /// tables that declare it (unlike the field-level `own_or_null`/`target_or_null`, which widen
505 /// every table the field reads). A WRITE stamps the resolved tenant exactly like [`Tenant`] — a
506 /// guest can never create or update a `NULL`-tenant base row (base rows are operator-seeded via a
507 /// privileged path). This is the per-**table** analog of the field-level NULL-base modes, and the
508 /// base-partition sibling of [`TenantOrSession`] (whose NULL partition is anonymous-session rows
509 /// instead of shared base). Unlike [`Unscoped`], the per-tenant (non-NULL) rows keep the
510 /// `tenant = <resolved>` boundary — only the NULL rows are shared — so a tenant can never read
511 /// another tenant's owned rows.
512 TenantOrBase,
513}
514
515/// The effective, host-resolved scope for one table (from [`TenancySchema::resolve`]) — the input
516/// the ORM scope-injector needs per table reference. `None` from `resolve` means **refused**
517/// (undeclared — deny-by-default); this enum is only the *declared* outcomes.
518#[derive(Debug, Clone, PartialEq, Eq)]
519pub enum ResolvedScope {
520 /// Scope this table on `column` = the resolved tenant (the injector picks the mode/value).
521 Column(String),
522 /// No tenant predicate — a globally-readable `Unscoped` table.
523 Unscoped,
524 /// The R3 anonymous-first disjunction: a read is `Or([tenant = <Tenant fact>, session =
525 /// <Session fact>])` over whichever axis facts are present; a write stamps the actor's own axis
526 /// (`tenant` if authenticated, else `session`, with the other column left `NULL`).
527 TenantOrSession {
528 /// The tenant column (`default_tenant_key`).
529 tenant: String,
530 /// The anonymous-session column (`session_key`).
531 session: String,
532 },
533 /// A base-inclusive table ([`TableScope::TenantOrBase`]): a READ confines to
534 /// `(tenant = <resolved> OR tenant IS NULL)` (the NULL rows are shared base, folded in on both
535 /// the own and target axes regardless of the field mode); a WRITE stamps `tenant = <resolved>`
536 /// exactly like [`Column`](ResolvedScope::Column) (never a NULL-base row).
537 TenantOrBase {
538 /// The tenant column (`default_tenant_key`); its `NULL` rows are the shared base.
539 tenant: String,
540 },
541}
542
543/// A project's tenant-isolation **schema map** — the host-held facts the scope-injector keys off
544/// (`PLAN-tenancy-principal`, D2). Per-project, not per-component: a table's tenant key is a fact of
545/// the data model. **Absent** (no project schema at all) ⇒ the legacy behavior (every table scopes
546/// on the component's `Tenancy::Scoped.column`, byte-identical to pre-schema). **Present** ⇒ the
547/// `tables` map is authoritative and **exhaustive**: a scoped component touching a table with no
548/// entry is *refused* (deny-by-default, D3 — "no key" and "forgot the key" are indistinguishable, so
549/// the safe collapse is deny; `Unscoped` is the explicit, reviewed "this table is global").
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(default, deny_unknown_fields)]
552pub struct TenancySchema {
553 /// The tenant column for a [`TableScope::Tenant`] table (e.g. `tenant_id`).
554 pub default_tenant_key: String,
555 /// The anonymous-session column for [`TableScope::TenantOrSession`] tables (e.g. `session_id`),
556 /// present iff the project uses the R3 session axis. A `TenantOrSession` table with no
557 /// `session_key` is refused (the disjunct is unrepresentable — deny-by-default).
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub session_key: Option<String>,
560 /// Per-table scope facts. Authoritative + exhaustive when a schema is present (an absent table
561 /// is refused, not defaulted — see the type doc).
562 pub tables: BTreeMap<String, TableScope>,
563 /// **R4 target axis — the operator's host-held allowlist ceiling.** The set of root
564 /// Query/Mutation field names (and plain-wasm route ids) that may carry `@tenant(scope: target)`
565 /// at all. Composition **refuses** a `target` field absent from this set — the app declares
566 /// intent in its SDL, but the operator gates which fields may cross to another tenant. Empty ⇒
567 /// no field may be target (deny-by-default).
568 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
569 pub target_eligible_fields: BTreeSet<String>,
570 /// **R4 target axis — per-table PUBLIC subset definitions.** Keyed by table name: the host-held
571 /// visibility predicate + the deny-by-default `world_public`/`listable` flags a target read/write
572 /// confines to. A `target` field over a table with **no** entry here is refused at composition
573 /// (mandatory — deny-by-default); the ORM join composition refuses a joined table with no entry
574 /// under a target read (the strict analog of the missing-tenant-column fail-close).
575 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
576 pub public_subsets: BTreeMap<String, PublicSubset>,
577 /// **R4 target axis — the operator-curated `handle` registry (5c).** Maps a PUBLIC handle/slug →
578 /// the target tenant's context tag `B` (the same opaque value a routed domain resolves to). A
579 /// [`TargetSource::Handle`] resolves `B` ONLY for a slug listed here (deny-by-default: an unlisted
580 /// slug is indistinguishable from an absent one — no existence oracle, G4), and ONLY when the
581 /// route's `public` subset is [`world_public`](PublicSubset::world_public) (G2/G3). The handle
582 /// source is always READ-ONLY (G1). Empty ⇒ no slug is handle-addressable. This is the opt-in
583 /// allowlist that keeps handle addressing from reaching an arbitrary tenant — only tenants the
584 /// operator deliberately publishes a handle for are reachable.
585 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
586 pub handles: BTreeMap<String, String>,
587}
588
589impl Default for TenancySchema {
590 fn default() -> Self {
591 Self {
592 default_tenant_key: "tenant_id".to_string(),
593 session_key: None,
594 tables: BTreeMap::new(),
595 target_eligible_fields: BTreeSet::new(),
596 public_subsets: BTreeMap::new(),
597 handles: BTreeMap::new(),
598 }
599 }
600}
601
602impl TenancySchema {
603 /// A **deny-all** schema: present (so it is authoritative, not legacy `Uniform`) with **no**
604 /// declared tables, so every table is undeclared and every guest ORM query is refused
605 /// deny-by-default. The host binds this as the fail-closed posture when a project's stored
606 /// schema is present but cannot be read/parsed — never a silent downgrade to `Uniform`.
607 pub fn deny_all() -> Self {
608 Self {
609 default_tenant_key: "tenant_id".to_string(),
610 session_key: None,
611 tables: BTreeMap::new(),
612 // A deny-all posture must also refuse every target field + declare no public subset, so
613 // the target axis fails closed exactly like the own axis when a schema can't be read.
614 target_eligible_fields: BTreeSet::new(),
615 public_subsets: BTreeMap::new(),
616 // No handle resolves under deny-all (an unreadable schema exposes no public handle).
617 handles: BTreeMap::new(),
618 }
619 }
620
621 /// Whether a root field / route `field` is operator-permitted to carry `@tenant(scope: target)`
622 /// (the host-held allowlist ceiling). Composition refuses a `target` field for which this is
623 /// `false`, so the app's SDL intent can never exceed the operator's grant.
624 pub fn target_field_eligible(&self, field: &str) -> bool {
625 self.target_eligible_fields.contains(field)
626 }
627
628 /// Whether the named public `subset` is flagged [`world_public`](PublicSubset::world_public) —
629 /// the deny-by-default host flag that admits the anonymous `handle` source (G2/G3). A subset that
630 /// declares a public predicate but is NOT `world_public` is target-readable via
631 /// domain/capability, but NEVER via a handle.
632 pub fn subset_is_world_public(&self, subset: &str) -> bool {
633 self.public_subsets
634 .get(subset)
635 .is_some_and(|s| s.world_public)
636 }
637
638 /// Resolve a PUBLIC `handle`/slug to its target tenant's context tag `B` (5c), or `None` for an
639 /// unlisted slug (deny-by-default — an unlisted slug is indistinguishable from an absent one, G4).
640 /// Only slugs the operator deliberately published in [`handles`](Self::handles) resolve.
641 pub fn resolve_handle(&self, slug: &str) -> Option<&str> {
642 self.handles.get(slug).map(String::as_str)
643 }
644
645 /// The host-held PUBLIC subset for `table` (its visibility predicate + `world_public`/`listable`
646 /// flags), or `None` when the table declares none — a target read/write over which is refused
647 /// (deny-by-default), including a joined ref with no declared subset.
648 pub fn public_subset(&self, table: &str) -> Option<&PublicSubset> {
649 self.public_subsets.get(table)
650 }
651
652 /// Validate the schema before it is stored — the safety checks the target-read confinement
653 /// assumes. Returns a human-readable reason on the first violation.
654 ///
655 /// **An empty public predicate is refused (R4/D8).** A [`PublicSubset`] with no terms would
656 /// match **every** row (`tenant = B` with no visibility restriction), silently defeating the
657 /// target-read confinement and exposing a tenant's PRIVATE rows — so a subset that declares a
658 /// public surface must actually restrict it. Callers reject a schema that fails this rather than
659 /// store a match-all subset (the write path is the single choke point where this can be caught).
660 pub fn validate(&self) -> Result<(), String> {
661 for (table, subset) in &self.public_subsets {
662 if subset.predicate.terms.is_empty() {
663 return Err(format!(
664 "public subset for table `{table}` has an empty predicate (would match every \
665 row, defeating the target-read confinement) — declare at least one visibility \
666 term (e.g. `published = true`)"
667 ));
668 }
669 }
670 Ok(())
671 }
672
673 /// Resolve how to scope `table`. `None` ⇒ **refused** (undeclared under a present schema —
674 /// deny-by-default; the injector fails the query closed; ALSO returned for a `TenantOrSession`
675 /// table when the schema declares no `session_key`, so the unrepresentable disjunct fails
676 /// closed). `Some(Column)` ⇒ scope on that column; `Some(Unscoped)` ⇒ global read; `Some(
677 /// TenantOrSession)` ⇒ the R3 disjunction.
678 pub fn resolve(&self, table: &str) -> Option<ResolvedScope> {
679 match self.tables.get(table)? {
680 TableScope::Tenant => Some(ResolvedScope::Column(self.default_tenant_key.clone())),
681 TableScope::TenantKeyed { key } => Some(ResolvedScope::Column(key.clone())),
682 TableScope::Unscoped => Some(ResolvedScope::Unscoped),
683 TableScope::TenantOrSession => Some(ResolvedScope::TenantOrSession {
684 tenant: self.default_tenant_key.clone(),
685 session: self.session_key.clone()?,
686 }),
687 TableScope::TenantOrBase => Some(ResolvedScope::TenantOrBase {
688 tenant: self.default_tenant_key.clone(),
689 }),
690 }
691 }
692
693 /// The `table → `[`ResolvedScope`] map the host threads into the ORM scope injector (wrapped as
694 /// `boatramp_core::orm::TableKeys::PerTable` one layer up — that type lives in the crate that owns
695 /// the injector, which depends on this one). The [`TableScope`] match is exhaustive **here**, in
696 /// its defining crate, so adding a variant is a compile error to classify rather than a silent
697 /// miss. A `TenantOrSession` table with no `session_key` is **omitted** — an absent entry is
698 /// refused (deny-by-default), never a silent single-axis scope. An empty schema yields an empty
699 /// map.
700 pub fn table_key_map(&self) -> BTreeMap<String, ResolvedScope> {
701 self.tables
702 .iter()
703 .filter_map(|(table, scope)| {
704 let resolved = match scope {
705 TableScope::Tenant => ResolvedScope::Column(self.default_tenant_key.clone()),
706 TableScope::TenantKeyed { key } => ResolvedScope::Column(key.clone()),
707 TableScope::Unscoped => ResolvedScope::Unscoped,
708 TableScope::TenantOrSession => ResolvedScope::TenantOrSession {
709 tenant: self.default_tenant_key.clone(),
710 session: self.session_key.clone()?, // no session_key ⇒ omit ⇒ deny
711 },
712 TableScope::TenantOrBase => ResolvedScope::TenantOrBase {
713 tenant: self.default_tenant_key.clone(),
714 },
715 };
716 Some((table.clone(), resolved))
717 })
718 .collect()
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
727 fn schema_resolves_per_table_key_and_denies_undeclared() {
728 // A present schema is authoritative + exhaustive: Tenant → default key, TenantKeyed → its
729 // own key (R2 identity table), Unscoped → global, and an ABSENT table is refused (D3).
730 let schema: TenancySchema = serde_json::from_str(
731 r#"{"default_tenant_key":"tenant_id","tables":{
732 "orders":{"kind":"tenant"},
733 "tenant":{"kind":"tenant_keyed","key":"id"},
734 "countries":{"kind":"unscoped"}}}"#,
735 )
736 .unwrap();
737 assert_eq!(
738 schema.resolve("orders"),
739 Some(ResolvedScope::Column("tenant_id".into()))
740 );
741 assert_eq!(
742 schema.resolve("tenant"),
743 Some(ResolvedScope::Column("id".into())) // scoped on its PK, not tenant_id
744 );
745 assert_eq!(schema.resolve("countries"), Some(ResolvedScope::Unscoped));
746 assert_eq!(schema.resolve("secrets_table"), None); // undeclared → deny-by-default
747 }
748
749 #[test]
750 fn tenant_or_base_resolves_to_the_default_tenant_key_and_round_trips_json() {
751 // `{"kind":"tenant_or_base"}` → a base-inclusive table keyed on the default tenant column.
752 let schema: TenancySchema = serde_json::from_str(
753 r#"{"default_tenant_key":"tenant_id","tables":{"pack":{"kind":"tenant_or_base"}}}"#,
754 )
755 .unwrap();
756 assert_eq!(schema.tables.get("pack"), Some(&TableScope::TenantOrBase));
757 assert_eq!(
758 schema.resolve("pack"),
759 Some(ResolvedScope::TenantOrBase {
760 tenant: "tenant_id".into()
761 })
762 );
763 assert_eq!(
764 schema.table_key_map().get("pack"),
765 Some(&ResolvedScope::TenantOrBase {
766 tenant: "tenant_id".into()
767 })
768 );
769 // Serializes back with the snake_case tag.
770 assert!(serde_json::to_string(&TableScope::TenantOrBase)
771 .unwrap()
772 .contains("tenant_or_base"));
773 }
774
775 #[test]
776 fn schema_default_is_tenant_id_no_tables() {
777 let s = TenancySchema::default();
778 assert_eq!(s.default_tenant_key, "tenant_id");
779 assert!(s.tables.is_empty());
780 // With no declared tables, even the default column resolves nothing (present-but-empty
781 // schema refuses everything — a project adopting a schema declares its tables exhaustively).
782 assert_eq!(s.resolve("orders"), None);
783 }
784
785 #[test]
786 fn table_scope_roundtrips_through_json() {
787 for ts in [
788 TableScope::Tenant,
789 TableScope::TenantKeyed { key: "id".into() },
790 TableScope::Unscoped,
791 TableScope::TenantOrSession,
792 ] {
793 let j = serde_json::to_string(&ts).unwrap();
794 assert_eq!(ts, serde_json::from_str::<TableScope>(&j).unwrap());
795 }
796 }
797
798 #[test]
799 fn tenant_or_session_needs_a_session_key_else_denies() {
800 // With a session_key, a TenantOrSession table resolves to the R3 disjunct on both columns.
801 let s = TenancySchema {
802 default_tenant_key: "tenant_id".into(),
803 session_key: Some("session_id".into()),
804 tables: BTreeMap::from([("carts".into(), TableScope::TenantOrSession)]),
805 ..Default::default()
806 };
807 assert_eq!(
808 s.resolve("carts"),
809 Some(ResolvedScope::TenantOrSession {
810 tenant: "tenant_id".into(),
811 session: "session_id".into(),
812 })
813 );
814
815 // WITHOUT a session_key the disjunct is unrepresentable ⇒ fail closed: `resolve` refuses,
816 // and `table_key_map` OMITS it (an absent entry is deny-by-default at the injector), never a
817 // silent single-axis scope.
818 let s = TenancySchema {
819 default_tenant_key: "tenant_id".into(),
820 session_key: None,
821 tables: BTreeMap::from([("carts".into(), TableScope::TenantOrSession)]),
822 ..Default::default()
823 };
824 assert_eq!(s.resolve("carts"), None);
825 assert!(
826 !s.table_key_map().contains_key("carts"),
827 "a TenantOrSession table without a session_key must be omitted (denied), not scoped"
828 );
829 }
830
831 #[test]
832 fn scoped_defaults_are_own_own_none_source() {
833 // Only `column` is required; sources default to [None], both axes to Own.
834 let t: Tenancy = serde_json::from_str(r#"{"mode":"scoped","column":"tenant_id"}"#).unwrap();
835 assert_eq!(
836 t,
837 Tenancy::Scoped {
838 column: "tenant_id".into(),
839 sources: vec![TenantSource::None],
840 read: AccessMode::Own,
841 write: AccessMode::Own,
842 }
843 );
844 assert!(t.is_scoped());
845 }
846
847 #[test]
848 fn sources_accept_both_the_legacy_singular_and_the_stage2_list() {
849 // Back-compat: the pre-Stage-2 singular `source:` map still parses (→ a one-element list),
850 // so a v0.4.0 tenancy config resolves exactly as before.
851 let legacy: Tenancy = serde_json::from_str(
852 r#"{"mode":"scoped","column":"tenant_id","source":{"kind":"domain"}}"#,
853 )
854 .unwrap();
855 let Tenancy::Scoped { sources, .. } = &legacy else {
856 panic!("scoped")
857 };
858 assert_eq!(sources, &vec![TenantSource::Domain]);
859
860 // Stage 2: a priority-ordered list of per-trigger sources.
861 let listed: Tenancy = serde_json::from_str(
862 r#"{"mode":"scoped","column":"tenant_id","sources":[{"kind":"token"},{"kind":"domain"}]}"#,
863 )
864 .unwrap();
865 let Tenancy::Scoped { sources, .. } = &listed else {
866 panic!("scoped")
867 };
868 assert_eq!(
869 sources,
870 &vec![
871 TenantSource::Token {
872 claim: "tid".into()
873 },
874 TenantSource::Domain
875 ]
876 );
877 }
878
879 #[test]
880 fn disabled_is_an_explicit_decision() {
881 let t: Tenancy = serde_json::from_str(r#"{"mode":"disabled"}"#).unwrap();
882 assert_eq!(t, Tenancy::Disabled);
883 assert!(!t.is_scoped());
884 }
885
886 #[test]
887 fn token_source_defaults_the_claim_to_tid() {
888 let t: Tenancy = serde_json::from_str(
889 r#"{"mode":"scoped","column":"tenant_id","source":{"kind":"token"},"read":"own_or_null","write":"own"}"#,
890 )
891 .unwrap();
892 let Tenancy::Scoped { sources, read, .. } = t else {
893 panic!("scoped")
894 };
895 assert_eq!(
896 sources,
897 vec![TenantSource::Token {
898 claim: "tid".into()
899 }]
900 );
901 assert_eq!(read, AccessMode::OwnOrNull);
902 }
903
904 #[test]
905 fn access_mode_cross_tenant_and_own_value_flags() {
906 assert!(AccessMode::All.is_cross_tenant());
907 assert!(!AccessMode::Own.is_cross_tenant());
908 assert!(AccessMode::Own.needs_own_value());
909 assert!(AccessMode::OwnOrNull.needs_own_value());
910 assert!(!AccessMode::Null.needs_own_value());
911 assert!(!AccessMode::All.needs_own_value());
912 assert!(!AccessMode::None.needs_own_value());
913 }
914
915 #[test]
916 fn roundtrips_through_json() {
917 let t = Tenancy::Scoped {
918 column: "org_id".into(),
919 sources: vec![TenantSource::Domain],
920 read: AccessMode::OwnOrNull,
921 write: AccessMode::Own,
922 };
923 let s = serde_json::to_string(&t).unwrap();
924 assert_eq!(t, serde_json::from_str::<Tenancy>(&s).unwrap());
925 }
926
927 #[test]
928 fn tenancy_class_default_is_own_and_target_flag() {
929 assert_eq!(TenancyClass::default(), TenancyClass::Own);
930 assert!(!TenancyClass::Own.is_target());
931 let tgt = TenancyClass::Target {
932 via: vec![TargetSource::Domain, TargetSource::Handle],
933 public: "storefront".into(),
934 write: vec![],
935 null_base: false,
936 };
937 assert!(tgt.is_target());
938 // Round-trips (the class rides on the composed supergraph).
939 assert_eq!(
940 tgt,
941 serde_json::from_str(&serde_json::to_string(&tgt).unwrap()).unwrap()
942 );
943 }
944
945 #[test]
946 fn target_schema_facts_roundtrip_and_gate_deny_by_default() {
947 let mut schema = TenancySchema {
948 default_tenant_key: "tenant_id".into(),
949 tables: BTreeMap::from([("products".into(), TableScope::Tenant)]),
950 ..Default::default()
951 };
952 schema
953 .target_eligible_fields
954 .insert("publicProducts".into());
955 schema.public_subsets.insert(
956 "products".into(),
957 PublicSubset {
958 predicate: PublicPredicate {
959 terms: vec![
960 PublicTerm::Cmp {
961 column: "published".into(),
962 op: PublicCmp::Eq,
963 value: PublicLiteral::Bool(true),
964 },
965 PublicTerm::Null {
966 column: "deleted_at".into(),
967 negated: false,
968 },
969 ],
970 },
971 world_public: true,
972 listable: true,
973 },
974 );
975
976 // The operator allowlist gates which fields may be target (deny-by-default).
977 assert!(schema.target_field_eligible("publicProducts"));
978 assert!(!schema.target_field_eligible("secretOrders"));
979 // A declared public subset resolves; an undeclared table is None (⇒ refused downstream).
980 assert!(schema.public_subset("products").unwrap().world_public);
981 assert!(schema.public_subset("orders").is_none());
982
983 // The whole schema round-trips (it is stored/loaded as the project config).
984 let s = serde_json::to_string(&schema).unwrap();
985 assert_eq!(schema, serde_json::from_str::<TenancySchema>(&s).unwrap());
986 }
987
988 #[test]
989 fn validate_rejects_an_empty_public_predicate() {
990 let mut schema = TenancySchema::default();
991 // A public subset with at least one visibility term is valid.
992 schema.public_subsets.insert(
993 "products".into(),
994 PublicSubset {
995 predicate: PublicPredicate {
996 terms: vec![PublicTerm::Cmp {
997 column: "published".into(),
998 op: PublicCmp::Eq,
999 value: PublicLiteral::Bool(true),
1000 }],
1001 },
1002 world_public: true,
1003 listable: true,
1004 },
1005 );
1006 assert!(schema.validate().is_ok());
1007 // An EMPTY predicate would match every row (`tenant = B` with no visibility restriction),
1008 // defeating the target-read confinement — refused at the write path.
1009 schema.public_subsets.insert(
1010 "orders".into(),
1011 PublicSubset {
1012 predicate: PublicPredicate { terms: vec![] },
1013 world_public: true,
1014 listable: false,
1015 },
1016 );
1017 let err = schema.validate().unwrap_err();
1018 assert!(
1019 err.contains("orders") && err.contains("empty predicate"),
1020 "got: {err}"
1021 );
1022 }
1023
1024 #[test]
1025 fn a_pre_stage5_schema_deserializes_with_empty_target_facts() {
1026 // A schema stored by a pre-Stage-5 binary has no target fields; `#[serde(default)]` must
1027 // fill them empty (no target eligibility, no public subsets) — a clean fail-closed default,
1028 // never a parse error under `deny_unknown_fields`.
1029 let legacy = r#"{"default_tenant_key":"tenant_id","tables":{"notes":{"kind":"tenant"}}}"#;
1030 let schema: TenancySchema = serde_json::from_str(legacy).unwrap();
1031 assert!(schema.target_eligible_fields.is_empty());
1032 assert!(schema.public_subsets.is_empty());
1033 assert!(!schema.target_field_eligible("anything"));
1034 }
1035
1036 #[test]
1037 fn access_mode_subset_lattice() {
1038 use AccessMode::*;
1039 // {} ⊆ anything.
1040 for c in [None, Null, Own, OwnOrNull, All] {
1041 assert!(None.within(c));
1042 }
1043 // Own and Null are disjoint — neither is within the other.
1044 assert!(!Own.within(Null));
1045 assert!(!Null.within(Own));
1046 // Own ⊆ {Own, OwnOrNull, All}; Null ⊆ {Null, OwnOrNull, All}.
1047 assert!(Own.within(Own) && Own.within(OwnOrNull) && Own.within(All));
1048 assert!(Null.within(Null) && Null.within(OwnOrNull) && Null.within(All));
1049 // OwnOrNull ⊆ {OwnOrNull, All} only; not within Own or Null.
1050 assert!(OwnOrNull.within(OwnOrNull) && OwnOrNull.within(All));
1051 assert!(!OwnOrNull.within(Own) && !OwnOrNull.within(Null));
1052 // All ⊆ All only.
1053 assert!(All.within(All));
1054 assert!(!All.within(OwnOrNull) && !All.within(Own));
1055 }
1056
1057 fn scoped(read: AccessMode, write: AccessMode) -> Tenancy {
1058 Tenancy::Scoped {
1059 column: "tenant_id".into(),
1060 sources: vec![TenantSource::Token {
1061 claim: "tid".into(),
1062 }],
1063 read,
1064 write,
1065 }
1066 }
1067
1068 #[test]
1069 fn tenancy_narrows_within_ceiling() {
1070 use AccessMode::*;
1071 // Scoped narrows a Scoped ceiling: same column, read/write are subsets.
1072 assert!(scoped(Own, Own).narrows_within(&scoped(All, All)));
1073 assert!(scoped(All, Own).narrows_within(&scoped(All, All)));
1074 assert!(scoped(All, All).narrows_within(&scoped(All, All)));
1075 // A broader read/write than the ceiling is a widening — refused.
1076 assert!(!scoped(All, Own).narrows_within(&scoped(Own, Own)));
1077 assert!(!scoped(Own, All).narrows_within(&scoped(Own, Own)));
1078 // A different tenant column is not a narrowing (fail-closed).
1079 assert!(!Tenancy::Scoped {
1080 column: "org_id".into(),
1081 sources: vec![TenantSource::None],
1082 read: Own,
1083 write: Own,
1084 }
1085 .narrows_within(&scoped(All, All)));
1086 }
1087
1088 #[test]
1089 fn tenancy_disabled_widening_is_refused() {
1090 use AccessMode::*;
1091 // A `Disabled` site (plain queries, no scoping) is the broadest — anything is within it.
1092 assert!(scoped(Own, Own).narrows_within(&Tenancy::Disabled));
1093 assert!(Tenancy::Disabled.narrows_within(&Tenancy::Disabled));
1094 // But `Disabled` under a scoped ceiling REMOVES scoping = widening = refused.
1095 assert!(!Tenancy::Disabled.narrows_within(&scoped(Own, Own)));
1096 // Own axis vs target axis across the ceiling is a misdeclaration — refused both ways.
1097 let target = Tenancy::Target {
1098 via: vec![TargetSource::Domain],
1099 public: "storefront".into(),
1100 write: vec![],
1101 null_base: false,
1102 };
1103 assert!(!target.narrows_within(&scoped(All, All)));
1104 assert!(!scoped(Own, Own).narrows_within(&target));
1105 // A target handler under a target ceiling is within (operator-gated separately).
1106 assert!(target.narrows_within(&target));
1107 }
1108
1109 #[test]
1110 fn declares_signed_context_detects_the_async_lane_source() {
1111 // A consumer declaring `signed_context` (alone or alongside another source) → true, so its
1112 // bindings are rebuilt per drained message; anything else → false (built-once path).
1113 let ctx = Tenancy::Scoped {
1114 column: "tenant_id".into(),
1115 sources: vec![TenantSource::SignedContext],
1116 read: AccessMode::Own,
1117 write: AccessMode::Own,
1118 };
1119 assert!(ctx.declares_signed_context());
1120 let mixed = Tenancy::Scoped {
1121 column: "tenant_id".into(),
1122 sources: vec![
1123 TenantSource::Token {
1124 claim: "tid".into(),
1125 },
1126 TenantSource::SignedContext,
1127 ],
1128 read: AccessMode::Own,
1129 write: AccessMode::Own,
1130 };
1131 assert!(mixed.declares_signed_context());
1132 let no_ctx = Tenancy::Scoped {
1133 column: "tenant_id".into(),
1134 sources: vec![TenantSource::None],
1135 read: AccessMode::Null,
1136 write: AccessMode::Null,
1137 };
1138 assert!(!no_ctx.declares_signed_context());
1139 assert!(!Tenancy::Disabled.declares_signed_context());
1140 }
1141
1142 #[test]
1143 fn de_opt_tenancy_bridges_ron_and_json() {
1144 use serde::Deserialize;
1145 #[derive(Debug, Deserialize)]
1146 struct W {
1147 #[serde(default, deserialize_with = "de_opt_tenancy")]
1148 tenancy: Option<Tenancy>,
1149 }
1150 let ron_opts = ron::Options::default()
1151 .with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME);
1152
1153 // Scoped, fully-quoted internally-tagged spelling — the canonical apply.cfg/project.cfg form.
1154 let scoped_ron: W = ron_opts
1155 .from_str(
1156 r#"(tenancy: (mode: "scoped", column: "tenant_id",
1157 sources: [(kind: "token", claim: "tid"), (kind: "signed_context")],
1158 read: "all", write: "own"))"#,
1159 )
1160 .expect("scoped RON parses via the bridge");
1161 // Byte-identical JSON the control plane stores, through the SAME bridge.
1162 let scoped_json: W = serde_json::from_str(
1163 r#"{"tenancy":{"mode":"scoped","column":"tenant_id",
1164 "sources":[{"kind":"token","claim":"tid"},{"kind":"signed_context"}],
1165 "read":"all","write":"own"}}"#,
1166 )
1167 .expect("scoped JSON parses via the bridge");
1168 assert_eq!(scoped_ron.tenancy, scoped_json.tenancy);
1169 match scoped_ron.tenancy.unwrap() {
1170 Tenancy::Scoped {
1171 column,
1172 sources,
1173 read,
1174 write,
1175 } => {
1176 assert_eq!(column, "tenant_id");
1177 assert_eq!(
1178 sources,
1179 vec![
1180 TenantSource::Token {
1181 claim: "tid".into()
1182 },
1183 TenantSource::SignedContext
1184 ]
1185 );
1186 assert_eq!(read, AccessMode::All);
1187 assert_eq!(write, AccessMode::Own);
1188 }
1189 other => panic!("expected scoped, got {other:?}"),
1190 }
1191
1192 // Target variant (nested TargetSource enum + write allowlist) also bridges.
1193 let target_ron: W = ron_opts
1194 .from_str(
1195 r#"(tenancy: (mode: "target", via: ["domain"], public: "storefront",
1196 write: ["status"]))"#,
1197 )
1198 .expect("target RON parses");
1199 match target_ron.tenancy.unwrap() {
1200 Tenancy::Target {
1201 via,
1202 public,
1203 write,
1204 null_base,
1205 } => {
1206 assert_eq!(via, vec![TargetSource::Domain]);
1207 assert_eq!(public, "storefront");
1208 assert_eq!(write, vec!["status".to_string()]);
1209 assert!(
1210 !null_base,
1211 "absent null_base defaults to false (plain target)"
1212 );
1213 }
1214 other => panic!("expected target, got {other:?}"),
1215 }
1216 // `target_or_null` (null_base: true) round-trips through the bridge from RON and JSON.
1217 let ton_ron: W = ron_opts
1218 .from_str(
1219 r#"(tenancy: (mode: "target", via: ["domain"], public: "vocab", null_base: true))"#,
1220 )
1221 .expect("target_or_null RON parses");
1222 let ton_json: W = serde_json::from_str(
1223 r#"{"tenancy":{"mode":"target","via":["domain"],"public":"vocab","null_base":true}}"#,
1224 )
1225 .expect("target_or_null JSON parses");
1226 assert_eq!(ton_ron.tenancy, ton_json.tenancy);
1227 assert!(
1228 matches!(
1229 ton_ron.tenancy,
1230 Some(Tenancy::Target {
1231 null_base: true,
1232 ..
1233 })
1234 ),
1235 "null_base survives the bridge"
1236 );
1237
1238 // Disabled + absent.
1239 let disabled: W = ron_opts
1240 .from_str(r#"(tenancy: (mode: "disabled"))"#)
1241 .expect("disabled RON parses");
1242 assert_eq!(disabled.tenancy, Some(Tenancy::Disabled));
1243 let absent: W = ron_opts.from_str(r#"()"#).expect("absent parses");
1244 assert_eq!(absent.tenancy, None);
1245 }
1246}