Skip to main content

cheers_server/
bundles.rs

1//! Role-bundle storage and mint-time expansion.
2//!
3//! A bundle is a named list of [`Scope`]s — `"camp-operator"`,
4//! `"deploy-admin"`, etc. — granted to a principal as a unit. The wire never
5//! sees the bundle name: at mint, cheers expands every
6//! [`ScopeOrBundle::Bundle`] grant entry into its current [`Scope`] list before
7//! signing the token. See `.yah/docs/working/mcp-auth-and-ownership.md` §Scope
8//! vocabulary and composition rules — rule (2).
9//!
10//! Expanding at mint (rather than freezing the list at grant time) means a
11//! bundle edit propagates on every principal's *next* token mint, without
12//! anyone touching the grant rows. That's the trade the doc takes: one
13//! lookup per grant entry at mint, acceptable at SMB scale.
14//!
15//! The wire-shape invariant — bundle names never reach the token — falls out
16//! of the type discipline rather than runtime checks: `McpClaims.scope` is
17//! `Vec<Scope>`, and [`Scope`] only deserializes the closed-vocabulary wire
18//! strings. A bundle name fed in by mistake would fail to parse before
19//! anything is signed. [`expand_scopes`] is the one place a bundle reference
20//! crosses into a literal [`Scope`] — everything downstream is `Vec<Scope>`.
21//!
22//! Bundles hold literal scopes only — no nested bundles. That rules out
23//! cycles by construction and keeps expansion a single lookup per entry.
24
25use std::collections::{BTreeMap, HashSet};
26use cheers_core::yah_scopes;
27use std::sync::{Arc, Mutex};
28
29use async_trait::async_trait;
30use cheers_core::{Scope, StoreError};
31use serde::{Deserialize, Serialize};
32
33/// The handle for a named bundle.
34///
35/// A newtype around `String`. The distinct type stops it being confused with a
36/// wire-form scope (`"cloud:deploy"`) at API boundaries.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(transparent)]
39pub struct BundleName(String);
40
41impl BundleName {
42    pub fn new(name: impl Into<String>) -> Self {
43        Self(name.into())
44    }
45
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49}
50
51impl std::fmt::Display for BundleName {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.write_str(&self.0)
54    }
55}
56
57impl From<&str> for BundleName {
58    fn from(s: &str) -> Self {
59        Self::new(s)
60    }
61}
62
63impl From<String> for BundleName {
64    fn from(s: String) -> Self {
65        Self(s)
66    }
67}
68
69/// A single grant entry — what the grant table stores per row. Mint expands
70/// the [`Bundle`](Self::Bundle) variants against the bundle table; the
71/// [`Scope`](Self::Scope) variants pass through.
72///
73/// Wire format is an externally-tagged JSON object — `{"scope": "cloud:deploy"}`
74/// or `{"bundle": "camp-operator"}` — so a row can be persisted, audited, and
75/// re-loaded without losing the distinction.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[non_exhaustive]
78#[serde(rename_all = "snake_case")]
79pub enum ScopeOrBundle {
80    Scope(Scope),
81    Bundle(BundleName),
82}
83
84impl From<Scope> for ScopeOrBundle {
85    fn from(s: Scope) -> Self {
86        Self::Scope(s)
87    }
88}
89
90impl From<BundleName> for ScopeOrBundle {
91    fn from(b: BundleName) -> Self {
92        Self::Bundle(b)
93    }
94}
95
96/// Why an expansion failed.
97#[derive(Debug, thiserror::Error)]
98#[non_exhaustive]
99pub enum BundleExpansionError {
100    /// A grant referenced a bundle name with no entry in the store. Surfaces a
101    /// stale grant: the bundle was deleted but the grant wasn't cleaned up.
102    #[error("unknown bundle '{0}'")]
103    Unknown(BundleName),
104    /// The backing store failed.
105    #[error("store: {0}")]
106    Store(#[from] StoreError),
107}
108
109/// Persistence for the bundle table.
110///
111/// Mints read individual bundles by name; cross-call atomicity is not required
112/// — every mint takes a fresh snapshot.
113#[async_trait]
114pub trait BundleStore: Send + Sync {
115    /// The literal scope list for `name`, or `None` if no such bundle.
116    async fn get(&self, name: &BundleName) -> Result<Option<Vec<Scope>>, StoreError>;
117
118    /// Create or overwrite the bundle named `name` with the supplied scopes.
119    async fn put(&self, name: &BundleName, scopes: &[Scope]) -> Result<(), StoreError>;
120
121    /// Remove the bundle named `name`. Grants still referencing it will fail
122    /// [`expand_scopes`] with [`BundleExpansionError::Unknown`] until the
123    /// stale grants are cleaned up.
124    async fn delete(&self, name: &BundleName) -> Result<(), StoreError>;
125}
126
127/// Mint-time helper: turn a grant's mixed `Scope`/`Bundle` entries into a
128/// deduped, literal [`Scope`] list. The output is what reaches the wire —
129/// `McpClaims.scope` is `Vec<Scope>`, and any bundle name fed in by mistake
130/// would fail to deserialize on the receive side.
131///
132/// Insertion order is preserved (first occurrence wins on duplicates); inside
133/// a bundle, the order is whatever the bundle was stored with.
134pub async fn expand_scopes<S: BundleStore + ?Sized>(
135    store: &S,
136    entries: &[ScopeOrBundle],
137) -> Result<Vec<Scope>, BundleExpansionError> {
138    let mut out: Vec<Scope> = Vec::with_capacity(entries.len());
139    let mut seen: HashSet<Scope> = HashSet::with_capacity(entries.len());
140
141    for entry in entries {
142        match entry {
143            ScopeOrBundle::Scope(s) => {
144                if seen.insert(s.clone()) {
145                    out.push(s.clone());
146                }
147            }
148            ScopeOrBundle::Bundle(name) => {
149                let scopes = store
150                    .get(name)
151                    .await?
152                    .ok_or_else(|| BundleExpansionError::Unknown(name.clone()))?;
153                for s in scopes {
154                    if seen.insert(s.clone()) {
155                        out.push(s);
156                    }
157                }
158            }
159        }
160    }
161    Ok(out)
162}
163
164/// In-memory [`BundleStore`] for tests and single-node bootstrapping before
165/// the persistent store lands.
166#[derive(Default, Clone)]
167pub struct MemoryBundleStore {
168    inner: Arc<Mutex<BTreeMap<String, Vec<Scope>>>>,
169}
170
171impl MemoryBundleStore {
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Pre-seed with the doc-named bundles `camp-operator` and `deploy-admin`.
177    ///
178    /// The exact scope contents are illustrative defaults — an operator-side
179    /// decision in production, not a wire contract. They are here so the
180    /// in-memory rig and tests have a meaningful starting point.
181    pub fn with_defaults() -> Self {
182        let store = Self::new();
183        let camp_operator = vec![
184            yah_scopes::CAMP_READ,
185            yah_scopes::CAMP_ADMIN,
186            yah_scopes::BOARD_READ,
187            yah_scopes::BOARD_WRITE,
188            yah_scopes::PARTY_READ,
189            yah_scopes::PARTY_WRITE,
190            yah_scopes::SUBAGENT_SPAWN,
191            yah_scopes::SUBAGENT_CONTROL,
192        ];
193        let deploy_admin = vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY, yah_scopes::CLOUD_DESTROY];
194        {
195            let mut g = store.inner.lock().expect("bundle store mutex poisoned");
196            g.insert("camp-operator".to_owned(), camp_operator);
197            g.insert("deploy-admin".to_owned(), deploy_admin);
198        }
199        store
200    }
201}
202
203#[async_trait]
204impl BundleStore for MemoryBundleStore {
205    async fn get(&self, name: &BundleName) -> Result<Option<Vec<Scope>>, StoreError> {
206        Ok(self
207            .inner
208            .lock()
209            .expect("bundle store mutex poisoned")
210            .get(name.as_str())
211            .cloned())
212    }
213
214    async fn put(&self, name: &BundleName, scopes: &[Scope]) -> Result<(), StoreError> {
215        self.inner
216            .lock()
217            .expect("bundle store mutex poisoned")
218            .insert(name.as_str().to_owned(), scopes.to_vec());
219        Ok(())
220    }
221
222    async fn delete(&self, name: &BundleName) -> Result<(), StoreError> {
223        self.inner
224            .lock()
225            .expect("bundle store mutex poisoned")
226            .remove(name.as_str());
227        Ok(())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use cheers_core::{McpClaims, PrincipalId};
235    use pollster::block_on;
236
237    #[test]
238    fn memory_store_get_put_delete_roundtrip() {
239        let store = MemoryBundleStore::new();
240        let name = BundleName::new("custom");
241        block_on(async {
242            assert!(store.get(&name).await.unwrap().is_none());
243            store.put(&name, &[yah_scopes::CLOUD_READ]).await.unwrap();
244            assert_eq!(
245                store.get(&name).await.unwrap(),
246                Some(vec![yah_scopes::CLOUD_READ])
247            );
248            store.delete(&name).await.unwrap();
249            assert!(store.get(&name).await.unwrap().is_none());
250        });
251    }
252
253    #[test]
254    fn with_defaults_seeds_doc_named_bundles() {
255        let store = MemoryBundleStore::with_defaults();
256        block_on(async {
257            assert!(
258                store
259                    .get(&BundleName::new("camp-operator"))
260                    .await
261                    .unwrap()
262                    .is_some(),
263                "camp-operator must be seeded"
264            );
265            assert!(
266                store
267                    .get(&BundleName::new("deploy-admin"))
268                    .await
269                    .unwrap()
270                    .is_some(),
271                "deploy-admin must be seeded"
272            );
273        });
274    }
275
276    #[test]
277    fn expand_literal_scopes_passes_through_in_order_and_dedups() {
278        let store = MemoryBundleStore::new();
279        let grants = vec![
280            ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
281            ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
282            ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
283        ];
284        let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
285        assert_eq!(expanded, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]);
286    }
287
288    #[test]
289    fn expand_bundle_replaces_name_with_literal_scope_list() {
290        // R020-F5 verify: "grant 'camp-operator', mint a token, observe the
291        // expanded literal scope list on the wire".
292        let store = MemoryBundleStore::with_defaults();
293        let grants = vec![ScopeOrBundle::Bundle(BundleName::new("camp-operator"))];
294        let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
295        assert_eq!(
296            expanded,
297            vec![
298                yah_scopes::CAMP_READ,
299                yah_scopes::CAMP_ADMIN,
300                yah_scopes::BOARD_READ,
301                yah_scopes::BOARD_WRITE,
302                yah_scopes::PARTY_READ,
303                yah_scopes::PARTY_WRITE,
304                yah_scopes::SUBAGENT_SPAWN,
305                yah_scopes::SUBAGENT_CONTROL,
306            ]
307        );
308    }
309
310    #[test]
311    fn bundle_edit_propagates_on_next_expand_without_rewriting_grant() {
312        // R020-F5 verify: "edit the bundle to remove a scope, re-mint, observe
313        // the removal propagates without rewriting the grant."
314        let store = MemoryBundleStore::with_defaults();
315        let grants = vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))];
316
317        let before = block_on(expand_scopes(&store, &grants)).unwrap();
318        assert!(before.contains(&yah_scopes::CLOUD_DESTROY));
319
320        // Mutate the bundle: drop CloudDestroy. The grants vector is untouched.
321        block_on(store.put(
322            &BundleName::new("deploy-admin"),
323            &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
324        ))
325        .unwrap();
326
327        let after = block_on(expand_scopes(&store, &grants)).unwrap();
328        assert!(!after.contains(&yah_scopes::CLOUD_DESTROY));
329        assert_eq!(after, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]);
330    }
331
332    #[test]
333    fn expand_mixed_scope_and_bundle_dedups_across_both() {
334        let store = MemoryBundleStore::with_defaults();
335        let grants = vec![
336            ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
337            ScopeOrBundle::Bundle(BundleName::new("deploy-admin")),
338        ];
339        let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
340        // CloudRead appears once, in its original position from the literal.
341        let count = expanded.iter().filter(|s| **s == yah_scopes::CLOUD_READ).count();
342        assert_eq!(count, 1);
343        assert_eq!(expanded[0], yah_scopes::CLOUD_READ);
344        assert!(expanded.contains(&yah_scopes::CLOUD_DEPLOY));
345        assert!(expanded.contains(&yah_scopes::CLOUD_DESTROY));
346    }
347
348    #[test]
349    fn expand_unknown_bundle_is_typed_error() {
350        let store = MemoryBundleStore::new();
351        let grants = vec![ScopeOrBundle::Bundle(BundleName::new("does-not-exist"))];
352        let err = block_on(expand_scopes(&store, &grants)).unwrap_err();
353        match err {
354            BundleExpansionError::Unknown(name) => assert_eq!(name.as_str(), "does-not-exist"),
355            other => panic!("expected Unknown, got {other:?}"),
356        }
357    }
358
359    #[test]
360    fn expanded_scopes_serialize_as_wire_strings_no_bundle_name() {
361        // Wire invariant: McpClaims.scope is Vec<Scope>; a bundle name MUST
362        // NOT appear in the serialized claim. Vec<Scope> only deserializes
363        // literal wire strings, so this property also nails round-trip: the
364        // expansion output IS what the wire carries.
365        let store = MemoryBundleStore::with_defaults();
366        let grants = vec![ScopeOrBundle::Bundle(BundleName::new("camp-operator"))];
367        let expanded = block_on(expand_scopes(&store, &grants)).unwrap();
368
369        let claims = McpClaims::new(
370            "https://cheers.example",
371            "https://kamaji.camp.example",
372            PrincipalId::user("alice"),
373            1000,
374            1300,
375            "jti-1",
376            expanded,
377        );
378        let json = serde_json::to_string(&claims).unwrap();
379        assert!(
380            !json.contains("camp-operator"),
381            "bundle name leaked to wire: {json}"
382        );
383        assert!(
384            json.contains(
385                r#""scope":["camp:read","camp:admin","board:read","board:write","party:read","party:write","subagent:spawn","subagent:control"]"#
386            ),
387            "expected literal scope wire list in {json}"
388        );
389        let back: McpClaims = serde_json::from_str(&json).unwrap();
390        assert_eq!(back.scope, claims.scope);
391    }
392
393    #[test]
394    fn scope_or_bundle_serialize_is_externally_tagged() {
395        let s = ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY);
396        let json = serde_json::to_string(&s).unwrap();
397        assert_eq!(json, r#"{"scope":"cloud:deploy"}"#);
398        let back: ScopeOrBundle = serde_json::from_str(&json).unwrap();
399        assert_eq!(back, s);
400
401        let b = ScopeOrBundle::Bundle(BundleName::new("camp-operator"));
402        let json = serde_json::to_string(&b).unwrap();
403        assert_eq!(json, r#"{"bundle":"camp-operator"}"#);
404        let back: ScopeOrBundle = serde_json::from_str(&json).unwrap();
405        assert_eq!(back, b);
406    }
407
408    #[test]
409    fn scope_or_bundle_deserialize_rejects_wildcard_inside_scope_variant() {
410        // Defense in depth: even if a grant somehow contained a wildcard in the
411        // scope slot, deserialization should reject it via Scope's parser.
412        let err =
413            serde_json::from_str::<ScopeOrBundle>(r#"{"scope":"cloud:*"}"#).unwrap_err();
414        assert!(
415            err.to_string().contains("wildcard"),
416            "expected wildcard rejection: {err}"
417        );
418    }
419}