Skip to main content

cheers_server/
grants.rs

1//! Grant persistence — what scope-or-bundle entries a principal holds against
2//! a given audience.
3//!
4//! See `.yah/docs/working/mcp-auth-and-ownership.md` §Mint flows: the mint
5//! path looks up grants by `(principal, aud)` and rejects when the list is
6//! empty — composition rule (5), aud-scoping is mandatory.
7//!
8//! The trait returns mixed [`ScopeOrBundle`] entries that ride into
9//! [`expand_scopes`](crate::bundles::expand_scopes) verbatim. cheers' own impl,
10//! [`SchemaGrantStore`], is a derived view of the ownership table (R731 §D2):
11//! everything a principal holds — its live tuples plus those its subject sets
12//! confer ([`SchemaRegistry::held_by`], R732-F2) — expanded through the
13//! [`SchemaRegistry`] closure, filtered to the scopes valid at `aud`.
14//! Hand-coded impls (noisetable's `PublishGrants`) keep the trait until they
15//! migrate.
16//!
17//! @yah:relay(R731, "Product scopes and authorization — scope registry, relationship schema, machine plane, live grants")
18//! @yah:at(2026-10-06T22:41:29Z)
19//! @yah:status(open)
20//! @yah:next("Plan of record: .yah/docs/working/product-scopes-and-authorization.md. Diligence passed 2026-10-06; operator decided D3 = token exchange (no self-signing for now) and D4 = the right to grant is a relationship.")
21//! @yah:next("Wire-shape changes here (Scope type, JWK fields, ownership columns) are coordinated with the yah-side kamaji consumer relays R426/R427/R428 (yah camp) — flag them in every handoff.")
22//! @yah:next("First consumer is noisetable (separate camp): its admin-plane, token-migration and R795-T8 work is filed on the noisetable board and waits on this relay.")
23//! @yah:next("Supersedes the R020 gotcha that ownership:write and granted_by are service-only; D4 removes both.")
24//! @arch:see(.yah/docs/working/product-scopes-and-authorization.md)
25
26use std::collections::BTreeSet;
27use std::sync::Arc;
28
29use async_trait::async_trait;
30use cheers_core::{Lookup, PrincipalId, SchemaRegistry, ScopeRegistry, StoreError};
31
32use crate::bundles::ScopeOrBundle;
33use crate::ownership::{OwnershipStore, OwnershipTuples};
34
35/// Persistence for the grant table.
36///
37/// One method only at the moment — mint-time lookup. Grant *writes* are
38/// ownership-tuple writes; the right to write one is
39/// [`SchemaRegistry::may_grant`].
40#[async_trait]
41pub trait GrantStore: Send + Sync {
42    /// All grant entries the principal holds for `aud`. An empty list means
43    /// the principal is not entitled to mint a token for that audience — the
44    /// mint path MUST reject. Order is whatever the impl returns; mint-time
45    /// expansion dedupes ([`expand_scopes`](crate::bundles::expand_scopes)).
46    async fn list_for(
47        &self,
48        principal: &PrincipalId,
49        aud: &str,
50    ) -> Result<Vec<ScopeOrBundle>, StoreError>;
51}
52
53/// [`GrantStore`] derived from the ownership table through the schema.
54///
55/// The principal's holdings are [`SchemaRegistry::held_by`]: its direct live
56/// tuples and every tuple a subject set it belongs to confers, within the hop
57/// bound. Holdings whose kind has no schema are ignored (camp `owns` rows still
58/// feed the owns claim); an undeclared relation on a known kind is skipped
59/// with a warning. There is no direct-scope escape hatch: a scope is held only
60/// because a relation unlocks it.
61pub struct SchemaGrantStore<O> {
62    ownership: O,
63    schema: Arc<SchemaRegistry>,
64    scopes: Arc<ScopeRegistry>,
65}
66
67impl<O: OwnershipStore> SchemaGrantStore<O> {
68    pub fn new(ownership: O, schema: Arc<SchemaRegistry>, scopes: Arc<ScopeRegistry>) -> Self {
69        Self { ownership, schema, scopes }
70    }
71}
72
73#[async_trait]
74impl<O: OwnershipStore> GrantStore for SchemaGrantStore<O> {
75    async fn list_for(
76        &self,
77        principal: &PrincipalId,
78        aud: &str,
79    ) -> Result<Vec<ScopeOrBundle>, StoreError> {
80        let held = self.schema.held_by(&OwnershipTuples::at(&self.ownership, crate::revocation::now_unix()), principal).await?;
81        let mut out = BTreeSet::new();
82        for (kind, id, relation) in held.iter() {
83            match self.schema.lookup(kind, id, relation) {
84                Lookup::NoSchema => {}
85                Lookup::UnknownRelation => tracing::warn!(
86                    %principal,
87                    kind,
88                    id,
89                    relation,
90                    "ownership tuple names a relation the schema does not declare; skipped"
91                ),
92                Lookup::Resolved(res) => out.extend(
93                    res.scopes.iter().filter(|s| self.scopes.is_valid_at(s, aud)).cloned(),
94                ),
95            }
96        }
97        Ok(out.into_iter().map(ScopeOrBundle::Scope).collect())
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::ownership::{MemoryOwnershipStore, NewOwnership};
105    use cheers_core::{
106        scopes, RelationDef, ResourceSchema, Scope, Subject, KIND_RESOURCE,
107    };
108    use pollster::block_on;
109
110    scopes! {
111        DOC_READ = "doc:read" { description: "read", audiences: ["https://a", "https://b"] };
112        DOC_EDIT = "doc:edit" { description: "edit", audiences: ["https://a"] };
113    }
114
115    const DOC: ResourceSchema = ResourceSchema {
116        kind: "doc",
117        relations: &[
118            RelationDef { name: "reader", membership: true, implies: &[], scopes: &[DOC_READ], grants: &[] },
119            RelationDef { name: "editor", membership: true, implies: &["reader"], scopes: &[DOC_EDIT], grants: &[] },
120            RelationDef { name: "admin", membership: true, implies: &[], scopes: &[], grants: &["reader"] },
121        ],
122        kind_relations: &[RelationDef {
123            name: "auditor",
124            membership: true,
125            implies: &[],
126            scopes: &[DOC_READ],
127            grants: &[],
128        }],
129    };
130
131    fn store() -> SchemaGrantStore<MemoryOwnershipStore> {
132        let scopes = Arc::new(ScopeRegistry::builder().with(DEFS).build().unwrap());
133        let schema = Arc::new(SchemaRegistry::build(&[DOC], &scopes).unwrap());
134        SchemaGrantStore::new(MemoryOwnershipStore::new(), schema, scopes)
135    }
136
137    fn seed(s: &SchemaGrantStore<MemoryOwnershipStore>, p: &PrincipalId, kind: &str, id: &str, rel: &str) -> String {
138        seed_subject(s, p.clone().into(), kind, id, rel)
139    }
140
141    fn seed_subject(s: &SchemaGrantStore<MemoryOwnershipStore>, who: Subject, kind: &str, id: &str, rel: &str) -> String {
142        let n = NewOwnership::new(who, kind, id, rel, PrincipalId::service("seed"), None).unwrap();
143        block_on(s.ownership.insert(&n, 1)).unwrap().row.id
144    }
145
146    fn scopes_of(s: &SchemaGrantStore<MemoryOwnershipStore>, p: &PrincipalId, aud: &str) -> Vec<Scope> {
147        block_on(s.list_for(p, aud))
148            .unwrap()
149            .into_iter()
150            .map(|e| match e {
151                ScopeOrBundle::Scope(s) => s,
152                other => panic!("schema store yields scopes only: {other:?}"),
153            })
154            .collect()
155    }
156
157    #[test]
158    fn no_rows_yields_empty_list() {
159        let s = store();
160        assert!(scopes_of(&s, &PrincipalId::user("u1"), "https://a").is_empty());
161    }
162
163    #[test]
164    fn closure_unlocks_implied_scopes_and_aud_filters() {
165        let s = store();
166        let u = PrincipalId::user("alice");
167        seed(&s, &u, "doc", "d1", "editor");
168        assert_eq!(scopes_of(&s, &u, "https://a"), vec![DOC_EDIT, DOC_READ]);
169        assert_eq!(scopes_of(&s, &u, "https://b"), vec![DOC_READ], "doc:edit is aud-bound");
170    }
171
172    #[test]
173    fn relation_without_scopes_confers_none() {
174        let s = store();
175        let u = PrincipalId::user("alice");
176        seed(&s, &u, "doc", "d1", "admin");
177        assert!(scopes_of(&s, &u, "https://a").is_empty(), "admin grants reader but does not imply it");
178    }
179
180    #[test]
181    fn unknown_kind_and_relation_are_ignored_and_revoked_rows_confer_nothing() {
182        let s = store();
183        let u = PrincipalId::user("alice");
184        seed(&s, &u, "service", "svc", "owns");
185        seed(&s, &u, "doc", "d1", "bogus");
186        let id = seed(&s, &u, "doc", "d1", "reader");
187        assert_eq!(scopes_of(&s, &u, "https://a"), vec![DOC_READ]);
188        block_on(s.ownership.revoke_by_id(&id, 2)).unwrap();
189        assert!(scopes_of(&s, &u, "https://a").is_empty());
190    }
191
192    #[test]
193    fn kind_level_tuple_unlocks_its_scopes() {
194        let s = store();
195        let u = PrincipalId::service("auditor");
196        seed(&s, &u, KIND_RESOURCE, "doc", "auditor");
197        assert_eq!(scopes_of(&s, &u, "https://a"), vec![DOC_READ]);
198    }
199
200    #[test]
201    fn set_tuples_confer_derived_grants_at_mint() {
202        let s = store();
203        let alice = PrincipalId::user("alice");
204        // doc/d1#editor ← team/t1#member ← team/org#member ← alice
205        let set_row = seed_subject(&s, Subject::set("team", "t1", "member"), "doc", "d1", "editor");
206        seed_subject(&s, Subject::set("team", "org", "member"), "team", "t1", "member");
207        assert!(scopes_of(&s, &alice, "https://a").is_empty(), "not a member yet");
208        seed(&s, &alice, "team", "org", "member");
209        assert_eq!(scopes_of(&s, &alice, "https://a"), vec![DOC_EDIT, DOC_READ]);
210        assert_eq!(scopes_of(&s, &alice, "https://b"), vec![DOC_READ]);
211        assert!(scopes_of(&s, &PrincipalId::user("bob"), "https://a").is_empty());
212        // Revoking the set tuple ends what it conferred.
213        block_on(s.ownership.revoke_by_id(&set_row, 2)).unwrap();
214        assert!(scopes_of(&s, &alice, "https://a").is_empty());
215    }
216}