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