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 — `principal × resource × kind` — so cheers does not grow a
9//! per-kind table for every resource type yah adds. The two unconditional
10//! invariants are encoded both here (parse-time, via
11//! [`OwnershipValidationError`]) and at the SQL CHECK level in the backing
12//! schema:
13//!
14//! - **`granted_by` is always a service principal** (`svc:<id>`). Humans never
15//! appear in `granted_by`; cheers writes the writing service principal's
16//! own `sub` into this column on every POST /ownership.
17//! - **`on_behalf_of` (when set) is always a user principal** (`user:<id>`).
18//! Service principals never appear here; for self-grants the field is
19//! `None`.
20//!
21//! Writes/deletes do not hard-delete — a soft `revoked_at` timestamp marks a
22//! row inactive. The cascade revoke for "user U went away" sweeps every row
23//! with `on_behalf_of = U` in one update, matching the staleness budget yah
24//! accepts on access tokens (the short access-TTL is the bound on
25//! propagation).
26//!
27//! The trait says nothing about authorisation — composition rule (4) (the
28//! `ownership:write` scope being grantable to services only) is enforced at
29//! the grant API by [`cheers_core::validate_grant`], and at the HTTP layer
30//! by checking the bearer token's `scope` list. The store impl just enforces
31//! the row invariants.
32
33use async_trait::async_trait;
34use cheers_core::{PrincipalId, PrincipalKind, StoreError};
35use serde::{Deserialize, Serialize};
36
37/// Why a [`NewOwnership`] failed to validate before reaching the store.
38///
39/// The SQL CHECK constraints are the same invariants enforced at the DB
40/// level — this enum is the Rust-side guard so a misconfigured insert never
41/// makes a round-trip to the database to be rejected.
42#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43pub enum OwnershipValidationError {
44 /// `granted_by` must be a service principal — humans never appear in
45 /// this column.
46 #[error("granted_by must be a service principal; got {0}")]
47 GrantedByNotService(PrincipalKind),
48 /// `on_behalf_of`, when set, must be a user principal — services never
49 /// appear here.
50 #[error("on_behalf_of must be a user principal when set; got {0}")]
51 OnBehalfOfNotUser(PrincipalKind),
52}
53
54/// Input for [`OwnershipStore::insert`]. Constructed via
55/// [`NewOwnership::new`] which enforces the `granted_by` / `on_behalf_of`
56/// invariants up front.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[non_exhaustive]
59pub struct NewOwnership {
60 pub principal_id: PrincipalId,
61 pub resource_kind: String,
62 pub resource_id: String,
63 pub relationship: String,
64 pub granted_by: PrincipalId,
65 pub on_behalf_of: Option<PrincipalId>,
66}
67
68impl NewOwnership {
69 /// Construct + validate. The kind invariants on `granted_by` /
70 /// `on_behalf_of` are checked here so an impl never has to.
71 pub fn new(
72 principal_id: PrincipalId,
73 resource_kind: impl Into<String>,
74 resource_id: impl Into<String>,
75 relationship: impl Into<String>,
76 granted_by: PrincipalId,
77 on_behalf_of: Option<PrincipalId>,
78 ) -> Result<Self, OwnershipValidationError> {
79 if granted_by.kind != PrincipalKind::Service {
80 return Err(OwnershipValidationError::GrantedByNotService(
81 granted_by.kind,
82 ));
83 }
84 if let Some(ref obo) = on_behalf_of {
85 if obo.kind != PrincipalKind::User {
86 return Err(OwnershipValidationError::OnBehalfOfNotUser(obo.kind));
87 }
88 }
89 Ok(Self {
90 principal_id,
91 resource_kind: resource_kind.into(),
92 resource_id: resource_id.into(),
93 relationship: relationship.into(),
94 granted_by,
95 on_behalf_of,
96 })
97 }
98}
99
100/// One row in the ownership table.
101///
102/// `id` is an opaque 128-bit identifier (UUIDv4 in the cheers-sqlx impls,
103/// matching the existing `mint_user_id` shape — the doc spec calls for "ULID"
104/// but the concrete crypto-random 128-bit shape is what's load-bearing, not
105/// the encoding).
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[non_exhaustive]
108pub struct OwnershipRow {
109 pub id: String,
110 pub principal_id: PrincipalId,
111 pub resource_kind: String,
112 pub resource_id: String,
113 pub relationship: String,
114 pub granted_by: PrincipalId,
115 pub on_behalf_of: Option<PrincipalId>,
116 pub granted_at: i64,
117 /// `None` while the row is live; the unix-second timestamp the soft delete
118 /// landed on once revoked. Rows with `Some(_)` are excluded from
119 /// [`list_for_principal`](OwnershipStore::list_for_principal).
120 pub revoked_at: Option<i64>,
121}
122
123impl OwnershipRow {
124 /// Build a row from its constituent fields. Use from `OwnershipStore`
125 /// impls that need to assemble a row from a SQL query result.
126 #[allow(clippy::too_many_arguments)]
127 pub fn new(
128 id: String,
129 principal_id: PrincipalId,
130 resource_kind: String,
131 resource_id: String,
132 relationship: String,
133 granted_by: PrincipalId,
134 on_behalf_of: Option<PrincipalId>,
135 granted_at: i64,
136 revoked_at: Option<i64>,
137 ) -> Self {
138 Self {
139 id,
140 principal_id,
141 resource_kind,
142 resource_id,
143 relationship,
144 granted_by,
145 on_behalf_of,
146 granted_at,
147 revoked_at,
148 }
149 }
150
151 /// `true` iff the row has been soft-revoked.
152 pub fn is_revoked(&self) -> bool {
153 self.revoked_at.is_some()
154 }
155}
156
157/// Persistence for the ownership table — the embedded-ownership source of
158/// truth cheers reads at mint time.
159///
160/// All time arguments are unix seconds, signed — same as
161/// [`Claims::issued_at`](cheers_core::Claims) and the refresh-token rows.
162#[async_trait]
163pub trait OwnershipStore: Send + Sync {
164 /// Insert a fresh row. The impl mints `id` and sets `granted_at = now`;
165 /// the returned [`OwnershipRow`] carries both. `revoked_at` is `None`.
166 async fn insert(
167 &self,
168 ownership: &NewOwnership,
169 now: i64,
170 ) -> Result<OwnershipRow, StoreError>;
171
172 /// Look up a row by id. `None` if no such row exists (live or revoked).
173 async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError>;
174
175 /// Soft-delete by id — sets `revoked_at = now` if the row is live; no-op
176 /// when already revoked. Returns [`StoreError::NotFound`] when the id is
177 /// unknown.
178 async fn revoke_by_id(&self, id: &str, now: i64) -> Result<(), StoreError>;
179
180 /// Cascading revoke — sweep `revoked_at = now` across every live row whose
181 /// `on_behalf_of` matches the supplied user principal. Returns the count
182 /// of rows newly revoked.
183 ///
184 /// Soundness rests on the column invariant: `on_behalf_of` is always a
185 /// user principal (`user:<id>`) or `None`. A non-user principal here
186 /// matches nothing and returns `0` — the impl does NOT need to check
187 /// `user.kind` itself; the SQL `WHERE on_behalf_of = $1` is precise.
188 async fn revoke_by_on_behalf_of(
189 &self,
190 user: &PrincipalId,
191 now: i64,
192 ) -> Result<u64, StoreError>;
193
194 /// Live (non-revoked) rows held by `principal`, in unspecified order.
195 async fn list_for_principal(
196 &self,
197 principal: &PrincipalId,
198 ) -> Result<Vec<OwnershipRow>, StoreError>;
199
200 /// Live (non-revoked) rows over one resource — every principal currently
201 /// holding a relationship on `(resource_kind, resource_id)`, in
202 /// unspecified order.
203 ///
204 /// Exists for eviction-parity sweeps (R593-F9, W268 Q6): when a device
205 /// (`resource_kind = "node"`) is re-enrolled under a *different* owner —
206 /// the device changed hands — the enrollment writer must find and revoke
207 /// the previous owner's still-live row, which `list_for_principal` cannot
208 /// see (it is keyed on the *old* principal, which the new ceremony does
209 /// not know).
210 async fn list_for_resource(
211 &self,
212 resource_kind: &str,
213 resource_id: &str,
214 ) -> Result<Vec<OwnershipRow>, StoreError>;
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 fn user(id: &str) -> PrincipalId {
222 PrincipalId::user(id)
223 }
224 fn svc(id: &str) -> PrincipalId {
225 PrincipalId::service(id)
226 }
227 fn camp(id: &str) -> PrincipalId {
228 PrincipalId::camp(id)
229 }
230
231 #[test]
232 fn new_ownership_accepts_service_granted_by_and_user_on_behalf_of() {
233 let n = NewOwnership::new(
234 camp("c-1"),
235 "service",
236 "svc-abc",
237 "owns",
238 svc("yubaba"),
239 Some(user("alice")),
240 )
241 .unwrap();
242 assert_eq!(n.granted_by, svc("yubaba"));
243 assert_eq!(n.on_behalf_of, Some(user("alice")));
244
245 // Self-grant: on_behalf_of=None is valid.
246 NewOwnership::new(camp("c-2"), "service", "s-1", "owns", svc("yubaba"), None).unwrap();
247 }
248
249 #[test]
250 fn new_ownership_rejects_user_granted_by() {
251 let err = NewOwnership::new(
252 camp("c-1"),
253 "service",
254 "svc-abc",
255 "owns",
256 user("alice"),
257 Some(user("alice")),
258 )
259 .unwrap_err();
260 assert_eq!(
261 err,
262 OwnershipValidationError::GrantedByNotService(PrincipalKind::User)
263 );
264
265 // Camp principal also rejected — only Service is OK.
266 let err = NewOwnership::new(
267 camp("c-1"),
268 "service",
269 "svc-abc",
270 "owns",
271 camp("c-1"),
272 None,
273 )
274 .unwrap_err();
275 assert_eq!(
276 err,
277 OwnershipValidationError::GrantedByNotService(PrincipalKind::Camp)
278 );
279 }
280
281 #[test]
282 fn new_ownership_rejects_non_user_on_behalf_of() {
283 let err = NewOwnership::new(
284 camp("c-1"),
285 "service",
286 "svc-abc",
287 "owns",
288 svc("yubaba"),
289 Some(svc("yubaba")),
290 )
291 .unwrap_err();
292 assert_eq!(
293 err,
294 OwnershipValidationError::OnBehalfOfNotUser(PrincipalKind::Service)
295 );
296
297 let err = NewOwnership::new(
298 camp("c-1"),
299 "service",
300 "svc-abc",
301 "owns",
302 svc("yubaba"),
303 Some(camp("c-9")),
304 )
305 .unwrap_err();
306 assert_eq!(
307 err,
308 OwnershipValidationError::OnBehalfOfNotUser(PrincipalKind::Camp)
309 );
310 }
311
312 #[test]
313 fn ownership_row_is_revoked_tracks_revoked_at() {
314 let mut row = OwnershipRow::new(
315 "id-1".into(),
316 camp("c-1"),
317 "service".into(),
318 "svc-a".into(),
319 "owns".into(),
320 svc("yubaba"),
321 Some(user("alice")),
322 100,
323 None,
324 );
325 assert!(!row.is_revoked());
326 row.revoked_at = Some(150);
327 assert!(row.is_revoked());
328 }
329}