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//! Stored as the mixed [`ScopeOrBundle`] entries that ride into
9//! [`expand_scopes`](crate::bundles::expand_scopes) verbatim — bundles are
10//! deferred to mint time so an edit to a bundle propagates without rewriting
11//! grant rows (rule (2), per the doc §Open questions).
12
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex};
15
16use async_trait::async_trait;
17use cheers_core::{PrincipalId, StoreError};
18
19use crate::bundles::ScopeOrBundle;
20
21/// Persistence for the grant table.
22///
23/// One method only at the moment — mint-time lookup. Grant *writes* land on
24/// the HTTP grant API (a peer of the ownership writes); that surface enforces
25/// composition rule (4) via [`cheers_core::validate_grant`] before persisting.
26#[async_trait]
27pub trait GrantStore: Send + Sync {
28    /// All grant entries the principal holds for `aud`. An empty list means
29    /// the principal is not entitled to mint a token for that audience — the
30    /// mint path MUST reject. Order is whatever the impl returns; mint-time
31    /// expansion dedupes ([`expand_scopes`](crate::bundles::expand_scopes)).
32    async fn list_for(
33        &self,
34        principal: &PrincipalId,
35        aud: &str,
36    ) -> Result<Vec<ScopeOrBundle>, StoreError>;
37}
38
39/// In-memory [`GrantStore`] for tests and single-node bootstrapping before the
40/// persistent grant store lands. Cheap to `clone` — shares one backing map.
41#[derive(Default, Clone)]
42pub struct MemoryGrantStore {
43    inner: Arc<Mutex<HashMap<(PrincipalId, String), Vec<ScopeOrBundle>>>>,
44}
45
46impl MemoryGrantStore {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Set (or overwrite) the grant entries the principal holds for `aud`.
52    pub fn put(
53        &self,
54        principal: PrincipalId,
55        aud: impl Into<String>,
56        entries: Vec<ScopeOrBundle>,
57    ) {
58        self.inner
59            .lock()
60            .expect("grant store mutex poisoned")
61            .insert((principal, aud.into()), entries);
62    }
63}
64
65#[async_trait]
66impl GrantStore for MemoryGrantStore {
67    async fn list_for(
68        &self,
69        principal: &PrincipalId,
70        aud: &str,
71    ) -> Result<Vec<ScopeOrBundle>, StoreError> {
72        Ok(self
73            .inner
74            .lock()
75            .expect("grant store mutex poisoned")
76            .get(&(principal.clone(), aud.to_owned()))
77            .cloned()
78            .unwrap_or_default())
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use cheers_core::Scope;
86    use pollster::block_on;
87
88    #[test]
89    fn memory_store_missing_key_yields_empty_list() {
90        let store = MemoryGrantStore::new();
91        let entries = block_on(store.list_for(&PrincipalId::user("u1"), "https://aud")).unwrap();
92        assert!(entries.is_empty());
93    }
94
95    #[test]
96    fn memory_store_put_then_list_roundtrip() {
97        let store = MemoryGrantStore::new();
98        let user = PrincipalId::user("alice");
99        store.put(
100            user.clone(),
101            "https://aud",
102            vec![
103                ScopeOrBundle::Scope(Scope::CloudRead),
104                ScopeOrBundle::Scope(Scope::CloudDeploy),
105            ],
106        );
107        let entries = block_on(store.list_for(&user, "https://aud")).unwrap();
108        assert_eq!(
109            entries,
110            vec![
111                ScopeOrBundle::Scope(Scope::CloudRead),
112                ScopeOrBundle::Scope(Scope::CloudDeploy),
113            ]
114        );
115    }
116
117    #[test]
118    fn memory_store_is_keyed_per_aud() {
119        let store = MemoryGrantStore::new();
120        let user = PrincipalId::user("alice");
121        store.put(
122            user.clone(),
123            "https://aud-a",
124            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
125        );
126        let other = block_on(store.list_for(&user, "https://aud-b")).unwrap();
127        assert!(other.is_empty(), "other aud must not leak: {other:?}");
128    }
129}