Skip to main content

cheers_server/
ownership.rs

1//! Embedded-ownership persistence — the [`OwnershipStore`] trait.
2//!
3//! The table this trait backs is the *source of truth* cheers reads at mint
4//! time to fill the [`owns`](cheers_core::Owns) claim on an MCP token. See
5//! `.yah/docs/working/mcp-auth-and-ownership.md` §Ownership table for the
6//! schema and the W159 trust-layer reasoning behind it.
7//!
8//! Generic shape — `subject × resource × kind` — so cheers does not grow a
9//! per-kind table for every resource type yah adds. The subject is a
10//! [`Subject`]: one principal (`principal_id`), or a subject set
11//! (`subject_kind / subject_id / subject_relation`, "every holder of
12//! `subject_relation` on that resource", noisetable W235 §2.1). Exactly one
13//! form is populated — a SQL CHECK and [`Subject`]'s own constructors both
14//! hold that line. The set-aware checks walk sets through
15//! [`OwnershipTuples`] (R732-F2). The two unconditional invariants are encoded
16//! both here (parse-time, via [`OwnershipValidationError`]) and at the SQL
17//! CHECK level in the backing schema:
18//!
19//! - **`granted_by` is the writer's verified `sub`, of any kind** (D4,
20//!   operator 2026-10-06). The right to grant is a relationship checked at
21//!   the write door, not a property of the caller's principal kind.
22//! - **`on_behalf_of` (when set) is always a user principal** (`user:<id>`).
23//!   It is attribution only: it never drives a row's lifecycle.
24//!
25//! Writes/deletes do not hard-delete — a soft `revoked_at` timestamp marks a
26//! row inactive. **Lifecycle follows the holder:** the cascade for "principal
27//! P went away" sweeps every row with `principal_id = P`
28//! ([`OwnershipStore::revoke_by_principal`]). Deleting a granter or an
29//! `on_behalf_of` user never revokes rows they granted. (No current writer
30//! needs a row to die with anyone but its holder, so there is no `bound_to`
31//! column; add one explicitly if that changes.)
32//!
33//! **Ownership version (R732-F4).** The store carries one store-wide counter,
34//! [`OwnershipStore::current_version`]. Every write that changes a row —
35//! [`insert`](OwnershipStore::insert), a [`revoke_by_id`](OwnershipStore::revoke_by_id)
36//! that revokes, a [`revoke_by_principal`](OwnershipStore::revoke_by_principal)
37//! that sweeps anything — advances it to [`next_ownership_version`]
38//! (`max(v + 1, now)`) **in the same transaction**, so two reads of an equal
39//! version saw equal rows. It is the epoch of a membership snapshot
40//! (`crate::snapshot`). Store-wide, not per resource, because subject sets make
41//! one resource's flattened membership depend on other resources' rows. The
42//! clock floor is the revocation epoch's and the binding sequence's: a store
43//! restored from an older backup still advances above what edges hold.
44//!
45//! The trait says nothing about authorisation — composition rule (4) (the
46//! `ownership:write` scope being grantable to services only) is enforced at
47//! the grant API by [`cheers_core::validate_grant`], and at the HTTP layer
48//! by checking the bearer token's `scope` list. The store impl just enforces
49//! the row invariants.
50//!
51//! @yah:ticket(R731-F7, "Ownership rows: granted_by of any kind, on_behalf_of attribution only, revoke by holder (D4 storage)")
52//! @yah:status(review)
53//! @yah:assignee(agent:bundle-anthropic-ashguard)
54//! @yah:at(2026-10-06T22:53:07Z)
55//! @yah:phase(P3)
56//! @yah:parent(R731)
57//! @yah:next("Doc D4; operator decided 2026-10-06. Drop CHECK (granted_by LIKE svc:%) and OwnershipValidationError::GrantedByNotService.")
58//! @yah:next("Revocation follows the holder (principal_id), never the granter: revoke_by_on_behalf_of today would revoke every grant an operator made when their account is deleted. Audit its callers; a row that must die with someone other than its holder gets an explicit nullable bound_to column.")
59//! @yah:next("Migrations in all three schemas: cheers-sqlx migrations/sqlite and migrations/pg, cheers-turso migrations/sqlite. SQLite cannot drop a CHECK, so it is a table rebuild.")
60//! @yah:verify("cheers-test-support store_scenarios cover user-granted rows and the holder cascade on every backend.")
61//! @yah:tier(Warrior)
62//! @yah:handoff("Dropped OwnershipValidationError::GrantedByNotService and the granted_by kind check in NewOwnership::new (cheers-server/src/ownership.rs); module + trait docs rewritten for D4 (any-kind granter, on_behalf_of attribution only, lifecycle follows holder).")
63//! @yah:handoff("OwnershipStore::revoke_by_on_behalf_of REPLACED by revoke_by_principal(principal, now): UPDATE ... WHERE principal_id = ? AND revoked_at IS NULL. Impls: cheers-sqlx ownership_store.rs (pg + sqlite), cheers-turso ownership_store.rs; mocks in cheers-axum/src/camps.rs, cheers-server/src/mcp_authority.rs, cheers-axum/tests/common/mod.rs.")
64//! @yah:handoff("bound_to NOT added. Audit: revoke_by_on_behalf_of had zero production callers (only trait impls, mocks and one scenario). The only in-tree ownership writer, enrollment (cheers-axum/src/enrollment.rs, fed by cheers/src/lan_pair/enroll.rs), writes rows HELD by the user, so the holder cascade covers them. camps.rs writes no ownership rows; camp principals die with their user via CampAuthority::revoke_user_cascade on the camp store. Doc comments in enrollment.rs and lan_pair/enroll.rs updated to name the holder cascade.")
65//! @yah:handoff("Migration 0008_ownership_any_granter.sql in cheers-sqlx/migrations/sqlite, cheers-turso/migrations/sqlite (byte-identical, registered as version 8 in cheers-turso/src/migrate.rs) and cheers-sqlx/migrations/pg. SQLite: table rebuild without the granted_by CHECK (on_behalf_of CHECK kept, ix_ownership_principal recreated). PG: DROP CONSTRAINT ownership_granted_by_check (Postgres auto-name for 0002's unnamed column CHECK; inferred, not run against a live pg). Both drop ix_ownership_on_behalf_of, which only served the retired cascade.")
66//! @yah:handoff("New scenario cheers-test-support store_scenarios::ownership_store_revoke_follows_holder (user-granted row inserts+lists; deleting the operator sweeps only their held row, rows they granted survive; holder cascade sweeps only that holder), wired in cheers-sqlx tests/sqlite.rs + tests/pg.rs and cheers-turso tests/turso.rs. Lifecycle scenario now cascades by holder; GrantedByNotService assertion removed.")
67//! @yah:handoff("Extra: cheers-axum/tests/ownership_basic.rs post_with_user_sub_is_rejected_by_defense_in_depth asserted the deleted rule; renamed to post_with_user_sub_records_user_as_granted_by (201, granted_by user:). cheers-sqlx tests/libsql.rs now expects a user grantor to be ACCEPTED. cheers-turso/src/error.rs test string swapped to the surviving CHECK.")
68//! @yah:handoff("Left for R731-F8 (router, out of my fence): cheers-axum/src/ownership.rs module doc lines 8-15 still say granted_by is a service; no code change was needed there.")
69//! @yah:verify("Baseline before edits: cargo test -p cheers-server -p cheers-sqlx -p cheers-turso -p cheers-test-support -p cheers-axum = 390 passed, 0 failed, plus a cheers-axum doctest compile error (unresolved cheers_core::KeyRole from R731-B1's in-flight jwk work), so that run exited 1.")
70//! @yah:verify("After, with --no-fail-fast: 407 passed, 0 failed, 3 ignored (doctests), exit 0. ownership_store_revoke_follows_holder ran OK on sqlx-sqlite and turso. The doctest error had cleared by then (R731-B1's fix landed in between).")
71//! @yah:verify("NOT run: pg.rs (feature pg-integration) and libsql.rs (container-gated); the pg migration's constraint name is unverified against a live Postgres.")
72//! @yah:verify("Leader re-verify 2026-10-06: cargo test --workspace (oss/cheers) 640 pass / 0 fail / 3 ignored. cargo test -p cheers-sqlx --features pg-integration,libsql-integration --test pg --test libsql: pg 17/17, libsql 7/7, so the Postgres 0008 DROP CONSTRAINT ownership_granted_by_check applies cleanly and the guessed constraint name is confirmed.")
73//! @yah:gotcha("The container-gated suites DO run on this machine. Docker is OrbStack, and its socket is not /var/run/docker.sock, so testcontainers fails with SocketNotFoundError unless you prefix DOCKER_HOST=unix:///Users/leif/.orbstack/run/docker.sock TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/Users/leif/.orbstack/run/docker.sock. \"Container-gated, didn't run\" is not a valid skip here.")
74//!
75//! @yah:ticket(R732-T9, "OwnershipStore::list_for_kind(kind): enumerate every grant row of one resource_kind (needed by noisetable R803-T1 ledger migration)")
76//! @yah:status(review)
77//! @yah:at(2026-10-07T07:15:43Z)
78//! @yah:assignee(agent:bundle-anthropic-ashguard)
79//! @yah:parent(R732)
80//! @yah:handoff("Landed `async fn list_for_kind(&self, resource_kind: &str) -> Result<Vec<OwnershipRow>, StoreError>` on OwnershipStore (cheers-server/src/ownership.rs:339) with the Arc delegate, turso + sqlx pg/sqlite backends (WHERE resource_kind = ? AND revoked_at IS NULL), and mocks in grants.rs, mcp_authority.rs, camps.rs, axum tests/common. Shared scenario store_scenarios.rs::ownership_store_list_for_kind covers live rows of the kind, other kinds excluded, revoked excluded, empty kind -> empty; wired into turso.rs, sqlite.rs, pg.rs.")
81//! @yah:verify("cargo test --workspace (oss/cheers, default features): 793 pass / 0 fail; list_for_kind passes on turso and sqlite.")
82//! @yah:gotcha("noisetable requires cheers-server = \"0.8.32\"; this working copy is 0.8.43-pre.1, which a ^0.8.32 requirement does not match (prerelease), so a plain [patch.crates-io] to oss/cheers would be unused unless the burst also bumps the requirement. Not verified end-to-end.")
83
84use async_trait::async_trait;
85use cheers_core::{
86    AdmissionPolicy, Lease, PrincipalId, PrincipalKind, RevocationKey, StoreError, Subject, SubjectFormError, TupleSource,
87};
88use serde::{Deserialize, Serialize};
89
90/// Why a [`NewOwnership`] failed to validate before reaching the store.
91///
92/// The SQL CHECK constraints are the same invariants enforced at the DB
93/// level — this enum is the Rust-side guard so a misconfigured insert never
94/// makes a round-trip to the database to be rejected.
95#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
96pub enum OwnershipValidationError {
97    /// `on_behalf_of`, when set, must be a user principal — services never
98    /// appear here.
99    #[error("on_behalf_of must be a user principal when set; got {0}")]
100    OnBehalfOfNotUser(PrincipalKind),
101    /// A subject given as loose fields (a request body) with both forms or
102    /// neither.
103    #[error(transparent)]
104    SubjectForm(#[from] SubjectFormError),
105}
106
107/// Input for [`OwnershipStore::insert`]. Constructed via
108/// [`NewOwnership::new`] which enforces the `on_behalf_of` invariant up
109/// front.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[non_exhaustive]
112pub struct NewOwnership {
113    /// Who holds the tuple. Serialized flat: `principal_id`, or the three
114    /// `subject_*` keys.
115    #[serde(flatten)]
116    pub subject: Subject,
117    pub resource_kind: String,
118    pub resource_id: String,
119    pub relationship: String,
120    pub granted_by: PrincipalId,
121    pub on_behalf_of: Option<PrincipalId>,
122    /// A guest's posted lease (R734-F4); `None` for a standing tuple.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub lease: Option<TupleLease>,
125}
126
127/// The lease a tuple keeps from the `Admit` that wrote it (R734-F4,
128/// `knock.md` "Posted lease"). Past `lease.exp` the tuple confers nothing:
129/// [`OwnershipTuples`] drops it, so the server stops counting it as held and
130/// the snapshot mint stops listing it. `iat` is the `Admit`'s, kept so the
131/// lease re-validates on read.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133pub struct TupleLease {
134    pub iat: i64,
135    #[serde(flatten)]
136    pub lease: Lease,
137}
138
139impl TupleLease {
140    /// From stored columns; a lease that breaks its invariant is a
141    /// data-integrity error.
142    pub fn from_columns(iat: Option<i64>, refresh_after: Option<i64>, exp: Option<i64>) -> Result<Option<Self>, StoreError> {
143        match (iat, refresh_after) {
144            (Some(iat), Some(refresh_after)) => Lease::new(iat, refresh_after, exp)
145                .map(|lease| Some(Self { iat, lease }))
146                .map_err(|e| StoreError::Backend(format!("invalid ownership lease: {e}"))),
147            (None, None) if exp.is_none() => Ok(None),
148            _ => Err(StoreError::Backend("partial ownership lease columns".into())),
149        }
150    }
151
152    /// `true` once the lease has lapsed at `now`.
153    pub fn lapsed_at(&self, now: i64) -> bool {
154        self.lease.exp().is_some_and(|exp| exp <= now)
155    }
156}
157
158impl NewOwnership {
159    /// Construct + validate. The kind invariant on `on_behalf_of` is checked
160    /// here so an impl never has to; `granted_by` may be any kind.
161    pub fn new(
162        subject: impl Into<Subject>,
163        resource_kind: impl Into<String>,
164        resource_id: impl Into<String>,
165        relationship: impl Into<String>,
166        granted_by: PrincipalId,
167        on_behalf_of: Option<PrincipalId>,
168    ) -> Result<Self, OwnershipValidationError> {
169        if let Some(ref obo) = on_behalf_of {
170            if obo.kind != PrincipalKind::User {
171                return Err(OwnershipValidationError::OnBehalfOfNotUser(obo.kind));
172            }
173        }
174        Ok(Self {
175            subject: subject.into(),
176            resource_kind: resource_kind.into(),
177            resource_id: resource_id.into(),
178            relationship: relationship.into(),
179            granted_by,
180            on_behalf_of,
181            lease: None,
182        })
183    }
184
185    /// Attach a guest's lease.
186    pub fn with_lease(mut self, lease: Option<TupleLease>) -> Self {
187        self.lease = lease;
188        self
189    }
190}
191
192/// One row in the ownership table.
193///
194/// `id` is an opaque 128-bit identifier (UUIDv4 in the cheers-sqlx impls,
195/// matching the existing `mint_user_id` shape — the doc spec calls for "ULID"
196/// but the concrete crypto-random 128-bit shape is what's load-bearing, not
197/// the encoding).
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[non_exhaustive]
200pub struct OwnershipRow {
201    pub id: String,
202    /// Who holds the tuple. Serialized flat, as in [`NewOwnership`]: a
203    /// principal row keeps its `principal_id` key on the wire.
204    #[serde(flatten)]
205    pub subject: Subject,
206    pub resource_kind: String,
207    pub resource_id: String,
208    pub relationship: String,
209    pub granted_by: PrincipalId,
210    pub on_behalf_of: Option<PrincipalId>,
211    pub granted_at: i64,
212    /// `None` while the row is live; the unix-second timestamp the soft delete
213    /// landed on once revoked. Rows with `Some(_)` are excluded from
214    /// [`list_for_principal`](OwnershipStore::list_for_principal).
215    pub revoked_at: Option<i64>,
216    /// The tuple's lease, if it was admitted for a while (R734-F4).
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub lease: Option<TupleLease>,
219}
220
221impl OwnershipRow {
222    /// Build a row from its constituent fields. Use from `OwnershipStore`
223    /// impls that need to assemble a row from a SQL query result.
224    #[allow(clippy::too_many_arguments)]
225    pub fn new(
226        id: String,
227        subject: Subject,
228        resource_kind: String,
229        resource_id: String,
230        relationship: String,
231        granted_by: PrincipalId,
232        on_behalf_of: Option<PrincipalId>,
233        granted_at: i64,
234        revoked_at: Option<i64>,
235    ) -> Self {
236        Self {
237            id,
238            subject,
239            resource_kind,
240            resource_id,
241            relationship,
242            granted_by,
243            on_behalf_of,
244            granted_at,
245            revoked_at,
246            lease: None,
247        }
248    }
249
250    /// Attach the stored lease.
251    pub fn with_lease(mut self, lease: Option<TupleLease>) -> Self {
252        self.lease = lease;
253        self
254    }
255
256    /// `true` while the row confers its relation at `now`: not revoked and
257    /// not past its lease.
258    pub fn is_live_at(&self, now: i64) -> bool {
259        !self.is_revoked() && !self.lease.is_some_and(|l| l.lapsed_at(now))
260    }
261
262    /// `true` iff the row has been soft-revoked.
263    pub fn is_revoked(&self) -> bool {
264        self.revoked_at.is_some()
265    }
266}
267
268impl cheers_core::RelationTuple for OwnershipRow {
269    fn subject(&self) -> &Subject {
270        &self.subject
271    }
272    fn resource_kind(&self) -> &str {
273        &self.resource_kind
274    }
275    fn resource_id(&self) -> &str {
276        &self.resource_id
277    }
278    fn relation(&self) -> &str {
279        &self.relationship
280    }
281    fn is_live(&self) -> bool {
282        !self.is_revoked()
283    }
284}
285
286/// One bootstrap tuple for [`seed_ownership`]: the first grant-holder of a
287/// resource, which the grant door (D4) cannot create because nobody yet
288/// holds a relation on it.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct SeedTuple {
291    pub principal_id: PrincipalId,
292    pub resource_kind: String,
293    pub resource_id: String,
294    pub relationship: String,
295}
296
297/// D4 bootstrap: write each seed tuple unless an identical live row already
298/// exists, attributed to `granted_by` (the deployment's own `svc:` identity).
299/// Idempotent — a deployment calls it at every startup. Returns how many rows
300/// were inserted.
301///
302/// This is the ONE path into the table that skips the `may_grant` check, so
303/// it must never be reachable from an HTTP route; it takes the store, not a
304/// request.
305pub async fn seed_ownership<O: OwnershipStore + ?Sized>(
306    store: &O,
307    seeds: &[SeedTuple],
308    granted_by: &PrincipalId,
309    now: i64,
310) -> Result<usize, StoreError> {
311    let mut inserted = 0;
312    for s in seeds {
313        let live = store.list_for_principal(&s.principal_id).await?;
314        if live.iter().any(|r| {
315            !r.is_revoked()
316                && r.resource_kind == s.resource_kind
317                && r.resource_id == s.resource_id
318                && r.relationship == s.relationship
319        }) {
320            continue;
321        }
322        let new = NewOwnership {
323            subject: s.principal_id.clone().into(),
324            resource_kind: s.resource_kind.clone(),
325            resource_id: s.resource_id.clone(),
326            relationship: s.relationship.clone(),
327            granted_by: granted_by.clone(),
328            on_behalf_of: None,
329            lease: None,
330        };
331        store.insert(&new, now).await?;
332        inserted += 1;
333    }
334    Ok(inserted)
335}
336
337/// The ownership version a write advances to: `max(prev + 1, now)`.
338/// Strictly monotonic whatever the clock does; the floor keeps a store
339/// restored from an older backup above the versions edges already hold. The
340/// same rule as `next_epoch` and [`next_binding_seq`](crate::next_binding_seq).
341pub fn next_ownership_version(prev: u64, now: i64) -> u64 {
342    prev.saturating_add(1).max(u64::try_from(now).unwrap_or(0))
343}
344
345/// What [`OwnershipStore::insert`] wrote: the row, and the ownership version
346/// the write advanced the store to.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct Inserted {
349    pub row: OwnershipRow,
350    pub version: u64,
351}
352
353/// Persistence for the ownership table — the embedded-ownership source of
354/// truth cheers reads at mint time.
355///
356/// All time arguments are unix seconds, signed — same as
357/// [`Claims::issued_at`](cheers_core::Claims) and the refresh-token rows.
358/// Every write that changes a row advances the store-wide ownership version
359/// in its own transaction (module docs); `now` is its clock floor.
360#[async_trait]
361pub trait OwnershipStore: Send + Sync {
362    /// Insert a fresh row and advance the ownership version, atomically. The
363    /// impl mints `id` and sets `granted_at = now`; the returned row carries
364    /// both, `revoked_at` is `None`, and [`Inserted::version`] is the version
365    /// this insert produced.
366    async fn insert(&self, ownership: &NewOwnership, now: i64) -> Result<Inserted, StoreError>;
367
368    /// Look up a row by id. `None` if no such row exists (live or revoked).
369    async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError>;
370
371    /// Soft-delete by id — sets `revoked_at = now` if the row is live and
372    /// advances the ownership version in the same transaction. Returns the
373    /// version after the call: the one this revoke produced, or, when the row
374    /// was already revoked (a no-op that changes nothing and advances
375    /// nothing), the current one. Returns [`StoreError::NotFound`] when the id
376    /// is unknown.
377    async fn revoke_by_id(&self, id: &str, now: i64) -> Result<u64, StoreError>;
378
379    /// Cascading revoke — sweep `revoked_at = now` across every live row
380    /// *held* by `principal` (`principal_id = principal`), advancing the
381    /// ownership version in the same transaction when it sweeps any. Returns
382    /// the version after the call: the one the sweep produced, or, when
383    /// nothing was live, the current one. Rows the principal merely granted
384    /// (`granted_by`) or was attributed on (`on_behalf_of`) are untouched, by
385    /// design (D4). A deletion cascade goes through
386    /// [`revoke_principal_ownership`](crate::revoke_principal_ownership), which
387    /// also tells offline edges.
388    async fn revoke_by_principal(
389        &self,
390        principal: &PrincipalId,
391        now: i64,
392    ) -> Result<u64, StoreError>;
393
394    /// Every row held by `principal` directly — subject
395    /// [`Subject::Principal`] — **live or revoked**, in unspecified order.
396    /// What a principal held after its rows are gone, for
397    /// [`revoke_principal_ownership`](crate::revoke_principal_ownership).
398    /// Unbounded and unindexed for revoked rows: a cascade read, not a request
399    /// path.
400    async fn list_history_for_principal(
401        &self,
402        principal: &PrincipalId,
403    ) -> Result<Vec<OwnershipRow>, StoreError>;
404
405    /// The store-wide ownership version: `0` for a fresh store, then whatever
406    /// the last row-changing write advanced it to. Equal versions read equal
407    /// rows (module docs).
408    async fn current_version(&self) -> Result<u64, StoreError>;
409
410    /// Live (non-revoked) rows held by `principal` directly — subject
411    /// [`Subject::Principal`]. Rows reaching it through a subject set are not
412    /// included. Unspecified order.
413    async fn list_for_principal(
414        &self,
415        principal: &PrincipalId,
416    ) -> Result<Vec<OwnershipRow>, StoreError>;
417
418    /// Live (non-revoked) rows over one resource — every principal currently
419    /// holding a relationship on `(resource_kind, resource_id)`, in
420    /// unspecified order.
421    ///
422    /// Exists for eviction-parity sweeps (R593-F9, W268 Q6): when a device
423    /// (`resource_kind = "node"`) is re-enrolled under a *different* owner —
424    /// the device changed hands — the enrollment writer must find and revoke
425    /// the previous owner's still-live row, which `list_for_principal` cannot
426    /// see (it is keyed on the *old* principal, which the new ceremony does
427    /// not know).
428    async fn list_for_resource(
429        &self,
430        resource_kind: &str,
431        resource_id: &str,
432    ) -> Result<Vec<OwnershipRow>, StoreError>;
433
434    /// Live (non-revoked) rows whose subject is a set on `(kind, id)` — the
435    /// tuples conferring their relation on holders of some relation on that
436    /// resource (`<resource>#<rel> ← kind/id#<subject_relation>`), whatever
437    /// `subject_relation` is. Unspecified order. Backed by a partial index on
438    /// `(subject_kind, subject_id)`.
439    async fn list_for_subject_set(
440        &self,
441        kind: &str,
442        id: &str,
443    ) -> Result<Vec<OwnershipRow>, StoreError>;
444
445    /// Live (non-revoked) rows over every resource of `resource_kind`,
446    /// whatever the subject form, in unspecified order. Unbounded, like its
447    /// siblings: meant for one-shot ledger migrations (noisetable R803-T1
448    /// re-keys every `publish-scope` row to `namespace`), not request paths.
449    async fn list_for_kind(&self, resource_kind: &str) -> Result<Vec<OwnershipRow>, StoreError>;
450
451    /// The membership-revocation key of `(resource_kind, resource_id)`,
452    /// created on first use (R732-T10; `cheers_core::revocation`, "Membership
453    /// privacy"). **Immutable**: once a key exists every call returns it, so
454    /// an edge holding an old snapshot keeps matching new entries — a changed
455    /// key would fail revocation open, silently. Get-or-create is insert-or-
456    /// ignore of a fresh [`new_revocation_key`] followed by a reread, so two
457    /// concurrent creators converge on the one row that won. Kept beside the
458    /// ownership rows so a restore cannot separate the two. Secret: it goes
459    /// out only inside signed snapshots, never on an unauthenticated route.
460    async fn revocation_key(&self, resource_kind: &str, resource_id: &str) -> Result<RevocationKey, StoreError>;
461
462    /// The admission policy row of `(resource_kind, resource_id)` (R734-F3,
463    /// migration 0016). `None` when the resource has none, which reads as
464    /// closed for offline admits.
465    async fn admission_policy(
466        &self,
467        resource_kind: &str,
468        resource_id: &str,
469    ) -> Result<Option<AdmissionPolicy>, StoreError>;
470
471    /// Set (`Some`) or delete (`None`) the admission policy row, advancing the
472    /// ownership version in the same transaction, so the next snapshot carries
473    /// the change at a higher epoch. Always advances, even when the value is
474    /// unchanged. Returns the version it produced.
475    async fn set_admission_policy(
476        &self,
477        resource_kind: &str,
478        resource_id: &str,
479        policy: Option<&AdmissionPolicy>,
480        now: i64,
481    ) -> Result<u64, StoreError>;
482}
483
484/// An [`AdmissionPolicy`] as its stored column: the JSON wire form.
485pub fn encode_admission_policy(policy: &AdmissionPolicy) -> String {
486    serde_json::to_string(policy).expect("AdmissionPolicy serializes")
487}
488
489/// Read a stored policy column back. A malformed row is a data-integrity
490/// error, never a silent `None` (that would read as closed, or worse, mask a
491/// tightening).
492pub fn decode_admission_policy(kind: &str, id: &str, column: &str) -> Result<AdmissionPolicy, StoreError> {
493    serde_json::from_str(column)
494        .map_err(|e| StoreError::Backend(format!("invalid admission policy for {kind}/{id}: {e}")))
495}
496
497/// A fresh random [`RevocationKey`] for a store's get-or-create.
498pub fn new_revocation_key() -> RevocationKey {
499    let mut bytes = [0u8; 32];
500    getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
501    RevocationKey::from_bytes(bytes)
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    fn user(id: &str) -> PrincipalId {
509        PrincipalId::user(id)
510    }
511    fn svc(id: &str) -> PrincipalId {
512        PrincipalId::service(id)
513    }
514    fn camp(id: &str) -> PrincipalId {
515        PrincipalId::camp(id)
516    }
517
518    #[test]
519    fn new_ownership_accepts_service_granted_by_and_user_on_behalf_of() {
520        let n = NewOwnership::new(
521            camp("c-1"),
522            "service",
523            "svc-abc",
524            "owns",
525            svc("yubaba"),
526            Some(user("alice")),
527        )
528        .unwrap();
529        assert_eq!(n.granted_by, svc("yubaba"));
530        assert_eq!(n.on_behalf_of, Some(user("alice")));
531
532        // Self-grant: on_behalf_of=None is valid.
533        NewOwnership::new(camp("c-2"), "service", "s-1", "owns", svc("yubaba"), None).unwrap();
534    }
535
536    #[test]
537    fn new_ownership_accepts_granted_by_of_any_kind() {
538        for granter in [user("alice"), camp("c-1"), svc("yubaba")] {
539            let n = NewOwnership::new(
540                camp("c-1"),
541                "service",
542                "svc-abc",
543                "owns",
544                granter.clone(),
545                Some(user("alice")),
546            )
547            .unwrap();
548            assert_eq!(n.granted_by, granter);
549        }
550    }
551
552    #[test]
553    fn new_ownership_rejects_non_user_on_behalf_of() {
554        let err = NewOwnership::new(
555            camp("c-1"),
556            "service",
557            "svc-abc",
558            "owns",
559            svc("yubaba"),
560            Some(svc("yubaba")),
561        )
562        .unwrap_err();
563        assert_eq!(
564            err,
565            OwnershipValidationError::OnBehalfOfNotUser(PrincipalKind::Service)
566        );
567
568        let err = NewOwnership::new(
569            camp("c-1"),
570            "service",
571            "svc-abc",
572            "owns",
573            svc("yubaba"),
574            Some(camp("c-9")),
575        )
576        .unwrap_err();
577        assert_eq!(
578            err,
579            OwnershipValidationError::OnBehalfOfNotUser(PrincipalKind::Camp)
580        );
581    }
582
583    #[test]
584    fn ownership_row_is_revoked_tracks_revoked_at() {
585        let mut row = OwnershipRow::new(
586            "id-1".into(),
587            camp("c-1").into(),
588            "service".into(),
589            "svc-a".into(),
590            "owns".into(),
591            svc("yubaba"),
592            Some(user("alice")),
593            100,
594            None,
595        );
596        assert!(!row.is_revoked());
597        row.revoked_at = Some(150);
598        assert!(row.is_revoked());
599    }
600
601    #[test]
602    fn ownership_row_wire_keeps_principal_id_and_flattens_sets() {
603        let principal = OwnershipRow::new(
604            "id-1".into(),
605            user("alice").into(),
606            "doc".into(),
607            "d1".into(),
608            "reader".into(),
609            svc("cheers"),
610            None,
611            100,
612            None,
613        );
614        let v = serde_json::to_value(&principal).unwrap();
615        assert_eq!(v["principal_id"], "user:alice");
616        assert!(v.get("subject_kind").is_none());
617        assert_eq!(serde_json::from_value::<OwnershipRow>(v).unwrap(), principal);
618
619        let set = NewOwnership::new(
620            Subject::set("namespace", "n1", "member"),
621            "doc",
622            "d1",
623            "reader",
624            user("alice"),
625            None,
626        )
627        .unwrap();
628        let v = serde_json::to_value(&set).unwrap();
629        assert!(v.get("principal_id").is_none());
630        assert_eq!(v["subject_kind"], "namespace");
631        assert_eq!(v["subject_id"], "n1");
632        assert_eq!(v["subject_relation"], "member");
633        assert_eq!(serde_json::from_value::<NewOwnership>(v).unwrap(), set);
634    }
635}
636
637/// Shared handle: lets [`SchemaGrantStore`](crate::SchemaGrantStore) and the
638/// mint authority read one ownership table.
639#[async_trait]
640impl<T: OwnershipStore + ?Sized> OwnershipStore for std::sync::Arc<T> {
641    async fn insert(&self, o: &NewOwnership, now: i64) -> Result<Inserted, StoreError> {
642        (**self).insert(o, now).await
643    }
644    async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError> {
645        (**self).get(id).await
646    }
647    async fn revoke_by_id(&self, id: &str, now: i64) -> Result<u64, StoreError> {
648        (**self).revoke_by_id(id, now).await
649    }
650    async fn revoke_by_principal(&self, p: &PrincipalId, now: i64) -> Result<u64, StoreError> {
651        (**self).revoke_by_principal(p, now).await
652    }
653    async fn list_history_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
654        (**self).list_history_for_principal(p).await
655    }
656    async fn current_version(&self) -> Result<u64, StoreError> {
657        (**self).current_version().await
658    }
659    async fn list_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
660        (**self).list_for_principal(p).await
661    }
662    async fn list_for_resource(
663        &self,
664        kind: &str,
665        id: &str,
666    ) -> Result<Vec<OwnershipRow>, StoreError> {
667        (**self).list_for_resource(kind, id).await
668    }
669    async fn list_for_subject_set(
670        &self,
671        kind: &str,
672        id: &str,
673    ) -> Result<Vec<OwnershipRow>, StoreError> {
674        (**self).list_for_subject_set(kind, id).await
675    }
676    async fn list_for_kind(&self, kind: &str) -> Result<Vec<OwnershipRow>, StoreError> {
677        (**self).list_for_kind(kind).await
678    }
679    async fn revocation_key(&self, kind: &str, id: &str) -> Result<RevocationKey, StoreError> {
680        (**self).revocation_key(kind, id).await
681    }
682    async fn admission_policy(&self, kind: &str, id: &str) -> Result<Option<AdmissionPolicy>, StoreError> {
683        (**self).admission_policy(kind, id).await
684    }
685    async fn set_admission_policy(
686        &self,
687        kind: &str,
688        id: &str,
689        policy: Option<&AdmissionPolicy>,
690        now: i64,
691    ) -> Result<u64, StoreError> {
692        (**self).set_admission_policy(kind, id, policy, now).await
693    }
694}
695
696/// In-process ownership table — tests and single-process deployments. Clones
697/// share one table. Each write takes one lock, so the row change and the
698/// version advance are atomic as the trait requires.
699#[derive(Debug, Clone, Default)]
700pub struct MemoryOwnershipStore(std::sync::Arc<std::sync::Mutex<MemoryOwnership>>);
701
702#[derive(Debug, Default)]
703struct MemoryOwnership {
704    rows: Vec<OwnershipRow>,
705    version: u64,
706    revocation_keys: std::collections::HashMap<(String, String), RevocationKey>,
707    policies: std::collections::HashMap<(String, String), AdmissionPolicy>,
708}
709
710impl MemoryOwnershipStore {
711    pub fn new() -> Self {
712        Self::default()
713    }
714
715    fn lock(&self) -> std::sync::MutexGuard<'_, MemoryOwnership> {
716        self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
717    }
718
719    fn live(&self, keep: impl Fn(&OwnershipRow) -> bool) -> Vec<OwnershipRow> {
720        self.lock().rows.iter().filter(|r| !r.is_revoked() && keep(r)).cloned().collect()
721    }
722}
723
724#[async_trait]
725impl OwnershipStore for MemoryOwnershipStore {
726    async fn insert(&self, o: &NewOwnership, now: i64) -> Result<Inserted, StoreError> {
727        let mut t = self.lock();
728        let row = OwnershipRow::new(
729            format!("own-{}", t.rows.len() + 1),
730            o.subject.clone(),
731            o.resource_kind.clone(),
732            o.resource_id.clone(),
733            o.relationship.clone(),
734            o.granted_by.clone(),
735            o.on_behalf_of.clone(),
736            now,
737            None,
738        )
739        .with_lease(o.lease);
740        t.rows.push(row.clone());
741        t.version = next_ownership_version(t.version, now);
742        Ok(Inserted { row, version: t.version })
743    }
744    async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError> {
745        Ok(self.lock().rows.iter().find(|r| r.id == id).cloned())
746    }
747    async fn revoke_by_id(&self, id: &str, now: i64) -> Result<u64, StoreError> {
748        let mut t = self.lock();
749        let row = t.rows.iter_mut().find(|r| r.id == id).ok_or(StoreError::NotFound)?;
750        if row.revoked_at.is_none() {
751            row.revoked_at = Some(now);
752            t.version = next_ownership_version(t.version, now);
753        }
754        Ok(t.version)
755    }
756    async fn revoke_by_principal(&self, p: &PrincipalId, now: i64) -> Result<u64, StoreError> {
757        let mut t = self.lock();
758        let mut swept = false;
759        for row in t.rows.iter_mut().filter(|r| r.revoked_at.is_none() && r.subject.principal() == Some(p)) {
760            row.revoked_at = Some(now);
761            swept = true;
762        }
763        if swept {
764            t.version = next_ownership_version(t.version, now);
765        }
766        Ok(t.version)
767    }
768    async fn list_history_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
769        Ok(self.lock().rows.iter().filter(|r| r.subject.principal() == Some(p)).cloned().collect())
770    }
771    async fn current_version(&self) -> Result<u64, StoreError> {
772        Ok(self.lock().version)
773    }
774    async fn list_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
775        Ok(self.live(|r| r.subject.principal() == Some(p)))
776    }
777    async fn list_for_resource(&self, kind: &str, id: &str) -> Result<Vec<OwnershipRow>, StoreError> {
778        Ok(self.live(|r| r.resource_kind == kind && r.resource_id == id))
779    }
780    async fn list_for_subject_set(&self, kind: &str, id: &str) -> Result<Vec<OwnershipRow>, StoreError> {
781        Ok(self.live(|r| r.subject.as_set().is_some_and(|(k, i, _)| k == kind && i == id)))
782    }
783    async fn list_for_kind(&self, kind: &str) -> Result<Vec<OwnershipRow>, StoreError> {
784        Ok(self.live(|r| r.resource_kind == kind))
785    }
786    async fn revocation_key(&self, kind: &str, id: &str) -> Result<RevocationKey, StoreError> {
787        let fresh = new_revocation_key();
788        let mut t = self.lock();
789        Ok(t.revocation_keys.entry((kind.to_owned(), id.to_owned())).or_insert(fresh).clone())
790    }
791    async fn admission_policy(&self, kind: &str, id: &str) -> Result<Option<AdmissionPolicy>, StoreError> {
792        Ok(self.lock().policies.get(&(kind.to_owned(), id.to_owned())).cloned())
793    }
794    async fn set_admission_policy(
795        &self,
796        kind: &str,
797        id: &str,
798        policy: Option<&AdmissionPolicy>,
799        now: i64,
800    ) -> Result<u64, StoreError> {
801        let mut t = self.lock();
802        let key = (kind.to_owned(), id.to_owned());
803        match policy {
804            Some(p) => t.policies.insert(key, p.clone()),
805            None => t.policies.remove(&key),
806        };
807        t.version = next_ownership_version(t.version, now);
808        Ok(t.version)
809    }
810}
811
812/// An [`OwnershipStore`] read as the schema's [`TupleSource`] at instant
813/// `now`, for the set-aware checks: `schema.may_grant(&OwnershipTuples::at(&*store, now), …)`,
814/// [`holds`](cheers_core::SchemaRegistry::holds),
815/// [`held_by`](cheers_core::SchemaRegistry::held_by),
816/// [`members`](cheers_core::SchemaRegistry::members). Rows whose lease has
817/// lapsed at `now` are dropped (R734-F4). A newtype because the orphan rule
818/// forbids a blanket impl of cheers-core's trait for every store.
819pub struct OwnershipTuples<'a, O: ?Sized> {
820    store: &'a O,
821    now: i64,
822}
823
824impl<'a, O: ?Sized> OwnershipTuples<'a, O> {
825    pub fn at(store: &'a O, now: i64) -> Self {
826        Self { store, now }
827    }
828
829    fn live(&self, rows: Vec<OwnershipRow>) -> Vec<OwnershipRow> {
830        rows.into_iter().filter(|r| r.is_live_at(self.now)).collect()
831    }
832}
833
834#[async_trait]
835impl<'a, O: OwnershipStore + ?Sized> TupleSource for OwnershipTuples<'a, O> {
836    type Tuple = OwnershipRow;
837    async fn list_for_principal(&self, p: &PrincipalId) -> Result<Vec<OwnershipRow>, StoreError> {
838        Ok(self.live(self.store.list_for_principal(p).await?))
839    }
840    async fn list_for_resource(
841        &self,
842        kind: &str,
843        id: &str,
844    ) -> Result<Vec<OwnershipRow>, StoreError> {
845        Ok(self.live(self.store.list_for_resource(kind, id).await?))
846    }
847    async fn list_for_subject_set(
848        &self,
849        kind: &str,
850        id: &str,
851    ) -> Result<Vec<OwnershipRow>, StoreError> {
852        Ok(self.live(self.store.list_for_subject_set(kind, id).await?))
853    }
854}