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}