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 — `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}