Skip to main content

cheers_server/
mcp_authority.rs

1//! The [`McpAuthority`] origin facade for minting MCP-call tokens.
2//!
3//! Composes the moving parts the doc lays out in `.yah/docs/working/
4//! mcp-auth-and-ownership.md` §Mint flows:
5//!
6//! - [`PasetoV4SecretMinter::mint_mcp`](crate::codec::PasetoV4SecretMinter::mint_mcp)
7//!   — the signing primitive (R020-T15, v4.public over Ed25519).
8//! - [`GrantStore`](crate::grants::GrantStore) — per-(principal, aud) grant
9//!   entries; empty result = no entitlement = mint rejected (composition rule
10//!   (5)).
11//! - [`BundleStore`](crate::bundles::BundleStore) +
12//!   [`expand_scopes`](crate::bundles::expand_scopes) — bundle expansion at
13//!   mint time (R020-F5, rule (2)).
14//! - [`validate_grant`] — composition rule (4) defence in depth: a bundle
15//!   that smuggles a service-only scope into a user grant is caught here
16//!   before signing.
17//! - [`OwnershipStore`](crate::ownership::OwnershipStore) — the `owns` claim
18//!   source of truth, read at mint time and baked into the token (R020-F4 /
19//!   W159 §Layer 2).
20//!
21//! Mirrors the [`SessionAuthority`](crate::session::SessionAuthority) shape:
22//! generic over the capability set so the assembled deployment is visible in
23//! the type, and the absence of a verifier here keeps mint power confined to
24//! this crate (the edge holds
25//! [`PasetoV4PublicVerifier`](cheers_verify::PasetoV4PublicVerifier), never a
26//! minter).
27
28use cheers_core::{
29    validate_grant, Actor, AuthStrength, CodecError, Error, GrantError, McpClaims, Owns,
30    PrincipalId, PrincipalKind, Scope, StoreError,
31};
32
33use crate::bundles::{expand_scopes, BundleExpansionError, BundleStore};
34use crate::codec::PasetoV4SecretMinter;
35use crate::grants::GrantStore;
36use crate::ownership::{OwnershipRow, OwnershipStore};
37use crate::session::generate_jti;
38
39/// TTL defaults for an MCP-call token.
40///
41/// Short by design — per `.yah/docs/working/mcp-auth-and-ownership.md` §TTLs,
42/// the 5–15 minute access window is also the propagation bound for
43/// revocations and ownership-table edits when no `ownership_version`
44/// freshness backstop is in play.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub struct McpPolicy {
48    /// Per-call access-token lifetime in seconds.
49    pub access_ttl_seconds: i64,
50}
51
52impl McpPolicy {
53    /// 10 minutes — middle of the doc's 5–15 min range.
54    pub const DEFAULT_ACCESS_TTL_SECONDS: i64 = 10 * 60;
55
56    pub fn new(access_ttl_seconds: i64) -> Self {
57        Self { access_ttl_seconds }
58    }
59
60    pub fn with_access_ttl(mut self, seconds: i64) -> Self {
61        self.access_ttl_seconds = seconds;
62        self
63    }
64}
65
66impl Default for McpPolicy {
67    fn default() -> Self {
68        Self {
69            access_ttl_seconds: Self::DEFAULT_ACCESS_TTL_SECONDS,
70        }
71    }
72}
73
74/// A freshly-minted MCP-call token plus the [`McpClaims`] it carries.
75#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub struct MintedMcpToken {
78    /// The signed `v4.public.*` token string.
79    pub token: String,
80    /// The claims embedded in `token`, including the freshly minted `jti`.
81    pub claims: McpClaims,
82}
83
84/// Why a mint attempt failed before (or after) the signing step.
85///
86/// Typed so the HTTP layer (R020-T17 routes) can map specific failures to the
87/// right status code: `AudNotEntitled` → 403, `GrantMisconfigured` → 500
88/// (server-side data bug), `Store` → 503, `Codec` → 500.
89#[derive(Debug, thiserror::Error)]
90#[non_exhaustive]
91pub enum McpMintError {
92    /// Composition rule (5) — the principal holds no grant for the requested
93    /// `aud`. Per-call rejection, returned before any signing happens.
94    #[error("no entitlement: principal '{principal}' has no grant for aud '{aud}'")]
95    AudNotEntitled { principal: String, aud: String },
96    /// RFC 8693 — a requested scope on a token-exchange is not in the
97    /// intersection of both principals' grants for this `aud`. Per the doc
98    /// (`§Mint flows` → token exchange), the whole exchange is rejected —
99    /// `invalid_scope`, not a partial token. Maps to HTTP 400 `invalid_scope`.
100    #[error("requested scope '{scope}' is not granted by both principals for aud '{aud}'")]
101    InvalidScope { scope: Scope, aud: String },
102    /// The caller passed the wrong principal kind for this mint path — e.g.
103    /// `mint_user_fresh` invoked with `svc:` or `camp:` in the `sub` slot.
104    /// A programmer-error, not a user-input rejection: surface 500, not 403.
105    #[error("mint path expects {expected} principal; got {got}")]
106    WrongPrincipalKind {
107        expected: PrincipalKind,
108        got: PrincipalKind,
109    },
110    /// Composition rule (4) defence in depth — bundle expansion produced a
111    /// service-only scope for a non-service principal. Indicates a
112    /// misconfigured bundle or a grant that should have been rejected at the
113    /// grant API; surface as 500.
114    #[error(transparent)]
115    GrantMisconfigured(#[from] GrantError),
116    /// Bundle expansion failed (unknown bundle / store error inside
117    /// expansion).
118    #[error(transparent)]
119    BundleExpansion(#[from] BundleExpansionError),
120    /// Underlying store failure (grants / ownership).
121    #[error(transparent)]
122    Store(#[from] StoreError),
123    /// Signing failure inside the codec layer.
124    #[error(transparent)]
125    Codec(#[from] CodecError),
126}
127
128impl From<McpMintError> for Error {
129    fn from(e: McpMintError) -> Self {
130        match e {
131            McpMintError::Store(s) => Error::Store(s),
132            McpMintError::Codec(c) => Error::Codec(c),
133            McpMintError::BundleExpansion(BundleExpansionError::Store(s)) => Error::Store(s),
134            // Structured rejections (AudNotEntitled, WrongPrincipalKind,
135            // GrantMisconfigured, BundleExpansion::Unknown) carry a message
136            // that's safe to surface; route them through InvalidInput so a
137            // caller that doesn't pattern-match on McpMintError still gets a
138            // sensible umbrella error.
139            other => Error::InvalidInput(other.to_string()),
140        }
141    }
142}
143
144/// Origin facade: everything that can *mint* an MCP-call token.
145///
146/// Holds the four origin-tier capabilities for the MCP boundary — a
147/// [`PasetoV4SecretMinter`], a [`BundleStore`], a [`GrantStore`], and an
148/// [`OwnershipStore`] — plus the cheers issuer URL and an [`McpPolicy`].
149/// Generic (not `dyn`) so the assembled capability set is visible in the
150/// type. The matching edge-side verifier is
151/// [`PasetoV4PublicVerifier`](cheers_verify::PasetoV4PublicVerifier) +
152/// `verify_mcp_at`; the verify-only consumer (kamaji) never depends on
153/// this crate.
154pub struct McpAuthority<B, G, O> {
155    minter: PasetoV4SecretMinter,
156    bundles: B,
157    grants: G,
158    ownership: O,
159    iss: String,
160    /// `kid` stamped into every minted token's PASETO footer (R592-B7 wire
161    /// convention) — identifies which published key `minter` signs with, so
162    /// an edge verifier's JWKS/footer lookup can find the matching pubkey.
163    kid: String,
164    policy: McpPolicy,
165}
166
167impl<B, G, O> McpAuthority<B, G, O>
168where
169    B: BundleStore,
170    G: GrantStore,
171    O: OwnershipStore,
172{
173    /// Assemble an authority with the [default policy](McpPolicy::default).
174    ///
175    /// `kid` is the footer identifier published alongside `minter`'s public
176    /// key (e.g. in cheers's JWKS) — every token this authority mints carries
177    /// it so a verifier can select the right key.
178    pub fn new(
179        minter: PasetoV4SecretMinter,
180        bundles: B,
181        grants: G,
182        ownership: O,
183        iss: impl Into<String>,
184        kid: impl Into<String>,
185    ) -> Self {
186        Self {
187            minter,
188            bundles,
189            grants,
190            ownership,
191            iss: iss.into(),
192            kid: kid.into(),
193            policy: McpPolicy::default(),
194        }
195    }
196
197    /// Override the TTL policy.
198    pub fn with_policy(mut self, policy: McpPolicy) -> Self {
199        self.policy = policy;
200        self
201    }
202
203    pub fn policy(&self) -> &McpPolicy {
204        &self.policy
205    }
206
207    pub fn issuer(&self) -> &str {
208        &self.iss
209    }
210
211    /// **Mint path 1** — user-initiated, passkey-fresh (R020-F6).
212    ///
213    /// Returns a token bearing
214    /// `{sub: user:<U>, act?, camp_id?, scope: [...], owns: {...}, auth_strength: "user-fresh"}`.
215    /// Required inputs:
216    ///
217    /// - `user`: the authenticated user's principal (kind MUST be
218    ///   [`PrincipalKind::User`]).
219    /// - `actor`: the optional `act` claim (RFC 8693) — the agent acting on
220    ///   the user's behalf. The agent is never the primary `sub`.
221    /// - `camp_id`: when set, both populates the `camp_id` claim and is the
222    ///   key used to look up the `owns` claim (rows held by
223    ///   `camp:<camp_id>`).
224    /// - `aud`: the target resource URI for this call. Per composition rule
225    ///   (5) the principal MUST hold at least one grant entry for `aud` — an
226    ///   empty grant list returns [`McpMintError::AudNotEntitled`] before
227    ///   any signing happens.
228    /// - `now`: unix seconds. `iat = now`, `exp = now + policy.access_ttl`.
229    ///
230    /// The pipeline: grants → [`expand_scopes`] → [`validate_grant`] per
231    /// scope (rule (4) defence) → ownership lookup → sign with `mint_mcp`.
232    pub async fn mint_user_fresh(
233        &self,
234        user: PrincipalId,
235        actor: Option<Actor>,
236        camp_id: Option<String>,
237        aud: impl Into<String>,
238        now: i64,
239    ) -> Result<MintedMcpToken, McpMintError> {
240        if user.kind != PrincipalKind::User {
241            return Err(McpMintError::WrongPrincipalKind {
242                expected: PrincipalKind::User,
243                got: user.kind,
244            });
245        }
246        let aud = aud.into();
247
248        let entries = self.grants.list_for(&user, &aud).await?;
249        if entries.is_empty() {
250            return Err(McpMintError::AudNotEntitled {
251                principal: user.to_string(),
252                aud,
253            });
254        }
255
256        let scopes = expand_scopes(&self.bundles, &entries).await?;
257        for s in &scopes {
258            validate_grant(user.kind, *s)?;
259        }
260
261        let owns = match &camp_id {
262            Some(id) => {
263                let camp_principal = PrincipalId::camp(id);
264                let rows = self.ownership.list_for_principal(&camp_principal).await?;
265                rows_to_owns(&rows)
266            }
267            None => Owns::default(),
268        };
269
270        let mut claims = McpClaims::new(
271            self.iss.clone(),
272            aud,
273            user,
274            now,
275            now + self.policy.access_ttl_seconds,
276            generate_jti(),
277            scopes,
278        )
279        .with_auth_strength(AuthStrength::UserFresh);
280        if let Some(a) = actor {
281            claims = claims.with_act(a);
282        }
283        if let Some(c) = camp_id {
284            claims = claims.with_camp_id(c);
285        }
286        if !owns.is_empty() {
287            claims = claims.with_owns(owns);
288        }
289
290        let token = self.minter.mint_mcp(&claims, &self.kid)?;
291        Ok(MintedMcpToken { token, claims })
292    }
293
294    /// **Mint path 2** — bootstrapped camp, autonomous (R020-F7).
295    ///
296    /// For yubaba-hosted camps operating without a live user session: the
297    /// camp's bootstrap credential authenticates upstream of this call, and
298    /// the verified camp principal is what arrives here. Returns a token
299    /// bearing
300    /// `{sub: camp:<C>, camp_id: <C>, scope: [...], owns: {...}, auth_strength: "bootstrap"}`
301    /// — note **no `act` claim** on this path, the camp itself is the
302    /// principal (not a user acted-on-by an agent).
303    ///
304    /// Required inputs:
305    ///
306    /// - `camp`: the authenticated camp's principal (kind MUST be
307    ///   [`PrincipalKind::Camp`]). `camp_id` on the resulting claim is the
308    ///   bare id (sans `camp:` prefix), so a consumer doesn't have to re-
309    ///   parse `sub` to learn it.
310    /// - `aud`: the target resource URI for this call. Per composition rule
311    ///   (5) the camp MUST hold at least one grant entry for `aud` — an empty
312    ///   grant list returns [`McpMintError::AudNotEntitled`] before signing.
313    /// - `now`: unix seconds. `iat = now`, `exp = now + policy.access_ttl`.
314    ///
315    /// Same pipeline as [`mint_user_fresh`](Self::mint_user_fresh): grants →
316    /// [`expand_scopes`] → [`validate_grant`] per scope (rule (4) defence —
317    /// catches a bundle that smuggles `ownership:write` into a camp grant) →
318    /// ownership lookup → sign with `mint_mcp`.
319    pub async fn mint_bootstrap(
320        &self,
321        camp: PrincipalId,
322        aud: impl Into<String>,
323        now: i64,
324    ) -> Result<MintedMcpToken, McpMintError> {
325        if camp.kind != PrincipalKind::Camp {
326            return Err(McpMintError::WrongPrincipalKind {
327                expected: PrincipalKind::Camp,
328                got: camp.kind,
329            });
330        }
331        let aud = aud.into();
332
333        let entries = self.grants.list_for(&camp, &aud).await?;
334        if entries.is_empty() {
335            return Err(McpMintError::AudNotEntitled {
336                principal: camp.to_string(),
337                aud,
338            });
339        }
340
341        let scopes = expand_scopes(&self.bundles, &entries).await?;
342        for s in &scopes {
343            validate_grant(camp.kind, *s)?;
344        }
345
346        let rows = self.ownership.list_for_principal(&camp).await?;
347        let owns = rows_to_owns(&rows);
348
349        let camp_id = camp.id.clone();
350        let mut claims = McpClaims::new(
351            self.iss.clone(),
352            aud,
353            camp,
354            now,
355            now + self.policy.access_ttl_seconds,
356            generate_jti(),
357            scopes,
358        )
359        .with_camp_id(camp_id)
360        .with_auth_strength(AuthStrength::Bootstrap);
361        if !owns.is_empty() {
362            claims = claims.with_owns(owns);
363        }
364
365        let token = self.minter.mint_mcp(&claims, &self.kid)?;
366        Ok(MintedMcpToken { token, claims })
367    }
368
369    /// **Mint path 3** — RFC 8693 token-exchange (R020-F8).
370    ///
371    /// The ONLY path that crosses principals: a multi-player camp daemon
372    /// (`subject_token` = camp bootstrap credential) presents a human's
373    /// session token (`actor_token` = user's session) and asks for a token
374    /// attributed to the *human*, with the camp as call context. The result
375    /// looks like a `mint_user_fresh` token — same `auth_strength=user-fresh`
376    /// because the user authenticated locally — but the camp's grants
377    /// constrain it.
378    ///
379    /// Inputs (already-verified principals — credential checks live at the
380    /// HTTP `/token` endpoint, same way passkey assertion sits upstream of
381    /// [`mint_user_fresh`](Self::mint_user_fresh)):
382    ///
383    /// - `user`: the verified user from `actor_token` (kind MUST be
384    ///   [`PrincipalKind::User`]).
385    /// - `camp`: the verified camp from `subject_token` (kind MUST be
386    ///   [`PrincipalKind::Camp`]).
387    /// - `actor`: the optional `act` claim — the agent variant acting on the
388    ///   user's behalf (RFC 8693).
389    /// - `aud`: target resource URI. BOTH principals must hold a grant for
390    ///   it (composition rule (5) applies per side).
391    /// - `requested_scope`: the scopes the exchange asks for. EVERY entry
392    ///   must be in the intersection of the user's expanded-grant scopes AND
393    ///   the camp's expanded-grant scopes for this aud. A requested scope
394    ///   outside the intersection rejects the whole exchange with
395    ///   [`McpMintError::InvalidScope`] — no partial token (RFC 8693 / doc
396    ///   §Mint flows).
397    /// - `now`: unix seconds. `iat = now`, `exp = now + policy.access_ttl`.
398    ///
399    /// Result claim:
400    /// `{sub: user:<U>, act: {sub: agent:<V>}?, camp_id: <C>, scope: [...requested...], owns: {...}, auth_strength: "user-fresh"}`.
401    /// `owns` is the CAMP's ownership (not the user's) — the call is scoped
402    /// to the camp's resources. Audit (R020-F13) captures both legs from the
403    /// resulting claim plus the endpoint's request context.
404    pub async fn mint_token_exchange(
405        &self,
406        user: PrincipalId,
407        camp: PrincipalId,
408        actor: Option<Actor>,
409        aud: impl Into<String>,
410        requested_scope: Vec<Scope>,
411        now: i64,
412    ) -> Result<MintedMcpToken, McpMintError> {
413        if user.kind != PrincipalKind::User {
414            return Err(McpMintError::WrongPrincipalKind {
415                expected: PrincipalKind::User,
416                got: user.kind,
417            });
418        }
419        if camp.kind != PrincipalKind::Camp {
420            return Err(McpMintError::WrongPrincipalKind {
421                expected: PrincipalKind::Camp,
422                got: camp.kind,
423            });
424        }
425        let aud = aud.into();
426
427        // (1) User side — composition rule (5) per side.
428        let user_entries = self.grants.list_for(&user, &aud).await?;
429        if user_entries.is_empty() {
430            return Err(McpMintError::AudNotEntitled {
431                principal: user.to_string(),
432                aud,
433            });
434        }
435        let user_scopes = expand_scopes(&self.bundles, &user_entries).await?;
436        for s in &user_scopes {
437            validate_grant(user.kind, *s)?;
438        }
439
440        // (2) Camp side — same rule (5) check. Even though the result is
441        //     user-attributed, the camp must also be entitled to the
442        //     audience.
443        let camp_entries = self.grants.list_for(&camp, &aud).await?;
444        if camp_entries.is_empty() {
445            return Err(McpMintError::AudNotEntitled {
446                principal: camp.to_string(),
447                aud,
448            });
449        }
450        let camp_scopes = expand_scopes(&self.bundles, &camp_entries).await?;
451        for s in &camp_scopes {
452            validate_grant(camp.kind, *s)?;
453        }
454
455        // (3) RFC 8693 — every requested scope must be in BOTH principals'
456        //     expanded grants. Any miss → reject the WHOLE exchange (not a
457        //     partial token).
458        for s in &requested_scope {
459            if !user_scopes.contains(s) || !camp_scopes.contains(s) {
460                return Err(McpMintError::InvalidScope {
461                    scope: *s,
462                    aud: aud.clone(),
463                });
464            }
465        }
466
467        // (4) `owns` comes from the CAMP — the camp is the resource context,
468        //     even though `sub` is the user.
469        let rows = self.ownership.list_for_principal(&camp).await?;
470        let owns = rows_to_owns(&rows);
471
472        let camp_id = camp.id.clone();
473        let mut claims = McpClaims::new(
474            self.iss.clone(),
475            aud,
476            user,
477            now,
478            now + self.policy.access_ttl_seconds,
479            generate_jti(),
480            requested_scope,
481        )
482        .with_camp_id(camp_id)
483        .with_auth_strength(AuthStrength::UserFresh);
484        if let Some(a) = actor {
485            claims = claims.with_act(a);
486        }
487        if !owns.is_empty() {
488            claims = claims.with_owns(owns);
489        }
490
491        let token = self.minter.mint_mcp(&claims, &self.kid)?;
492        Ok(MintedMcpToken { token, claims })
493    }
494}
495
496/// Convert ownership rows into the [`Owns`] claim shape. Revoked rows are
497/// filtered out (the store already excludes them, but a belt-and-braces
498/// check keeps the wire shape from leaking stale entries if the store
499/// contract evolves). Unknown resource kinds spill into
500/// [`Owns::extra`](cheers_core::Owns).
501///
502/// Duplicate `(kind, resource_id)` pairs collapse to one entry — ownership
503/// is set-membership, and while `POST /ownership` is idempotent for
504/// identical live rows, historical duplicates (or the residual
505/// concurrent-write race) may still exist in a store. The claim shape must
506/// not amplify them onto every minted token.
507fn rows_to_owns(rows: &[OwnershipRow]) -> Owns {
508    fn push_unique(list: &mut Vec<String>, id: &str) {
509        if !list.iter().any(|existing| existing == id) {
510            list.push(id.to_owned());
511        }
512    }
513    let mut o = Owns::default();
514    for r in rows {
515        if r.is_revoked() {
516            continue;
517        }
518        match r.resource_kind.as_str() {
519            "service" => push_unique(&mut o.service, &r.resource_id),
520            "arch_doc" => push_unique(&mut o.arch_doc, &r.resource_id),
521            "node" => push_unique(&mut o.node, &r.resource_id),
522            other => push_unique(
523                o.extra.entry(other.to_owned()).or_default(),
524                &r.resource_id,
525            ),
526        }
527    }
528    o
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::bundles::{BundleName, MemoryBundleStore, ScopeOrBundle};
535    use crate::codec::PasetoV4SecretMinter;
536    use crate::grants::MemoryGrantStore;
537    use crate::ownership::{NewOwnership, OwnershipRow, OwnershipStore};
538    use async_trait::async_trait;
539    use cheers_core::{Scope, StoreError};
540    use cheers_verify::PasetoV4PublicVerifier;
541    use std::sync::Mutex;
542
543    // ---- in-memory OwnershipStore (test-only) ------------------------------
544    //
545    // cheers-sqlx ships the persistent impls; cheers-server tests only need
546    // a process-local one. Lives here rather than in ownership.rs because
547    // F4 deliberately kept that module impl-free for the trait surface.
548
549    #[derive(Default)]
550    struct MemOwnershipStore {
551        rows: Mutex<Vec<OwnershipRow>>,
552        next_id: Mutex<u64>,
553    }
554
555    impl MemOwnershipStore {
556        fn new() -> Self {
557            Self::default()
558        }
559    }
560
561    #[async_trait]
562    impl OwnershipStore for MemOwnershipStore {
563        async fn insert(
564            &self,
565            ownership: &NewOwnership,
566            now: i64,
567        ) -> Result<OwnershipRow, StoreError> {
568            let mut id_g = self.next_id.lock().unwrap();
569            *id_g += 1;
570            let id = format!("row-{}", *id_g);
571            drop(id_g);
572            let row = OwnershipRow::new(
573                id,
574                ownership.principal_id.clone(),
575                ownership.resource_kind.clone(),
576                ownership.resource_id.clone(),
577                ownership.relationship.clone(),
578                ownership.granted_by.clone(),
579                ownership.on_behalf_of.clone(),
580                now,
581                None,
582            );
583            self.rows.lock().unwrap().push(row.clone());
584            Ok(row)
585        }
586
587        async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError> {
588            Ok(self.rows.lock().unwrap().iter().find(|r| r.id == id).cloned())
589        }
590
591        async fn revoke_by_id(&self, id: &str, now: i64) -> Result<(), StoreError> {
592            let mut g = self.rows.lock().unwrap();
593            let row = g
594                .iter_mut()
595                .find(|r| r.id == id)
596                .ok_or(StoreError::NotFound)?;
597            if row.revoked_at.is_none() {
598                row.revoked_at = Some(now);
599            }
600            Ok(())
601        }
602
603        async fn revoke_by_on_behalf_of(
604            &self,
605            user: &PrincipalId,
606            now: i64,
607        ) -> Result<u64, StoreError> {
608            let mut count = 0u64;
609            for r in self.rows.lock().unwrap().iter_mut() {
610                if r.revoked_at.is_none() && r.on_behalf_of.as_ref() == Some(user) {
611                    r.revoked_at = Some(now);
612                    count += 1;
613                }
614            }
615            Ok(count)
616        }
617
618        async fn list_for_principal(
619            &self,
620            principal: &PrincipalId,
621        ) -> Result<Vec<OwnershipRow>, StoreError> {
622            Ok(self
623                .rows
624                .lock()
625                .unwrap()
626                .iter()
627                .filter(|r| r.revoked_at.is_none() && r.principal_id == *principal)
628                .cloned()
629                .collect())
630        }
631
632        async fn list_for_resource(
633            &self,
634            resource_kind: &str,
635            resource_id: &str,
636        ) -> Result<Vec<OwnershipRow>, StoreError> {
637            Ok(self
638                .rows
639                .lock()
640                .unwrap()
641                .iter()
642                .filter(|r| {
643                    r.revoked_at.is_none()
644                        && r.resource_kind == resource_kind
645                        && r.resource_id == resource_id
646                })
647                .cloned()
648                .collect())
649        }
650    }
651
652    // ---- assembly ----------------------------------------------------------
653
654    /// `kid` this test rig's authority stamps into every mint — tests that
655    /// verify a minted token must pass the same value to `verify_mcp_at`.
656    const RIG_KID: &str = "mcp-authority-test-kid-1";
657
658    fn rig() -> (
659        McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>,
660        PasetoV4PublicVerifier,
661    ) {
662        let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
663        let bundles = MemoryBundleStore::with_defaults();
664        let grants = MemoryGrantStore::new();
665        let ownership = MemOwnershipStore::new();
666        let authority = McpAuthority::new(
667            minter,
668            bundles,
669            grants,
670            ownership,
671            "https://cheers.example",
672            RIG_KID,
673        );
674        (authority, verifier)
675    }
676
677    fn put_simple_grant(authority: &McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>, principal: PrincipalId, aud: &str, entries: Vec<ScopeOrBundle>) {
678        authority.grants.put(principal, aud, entries);
679    }
680
681    // ---- McpPolicy ---------------------------------------------------------
682
683    #[test]
684    fn mcp_policy_default_is_ten_minutes() {
685        let p = McpPolicy::default();
686        assert_eq!(p.access_ttl_seconds, 10 * 60);
687        let p = McpPolicy::new(300);
688        assert_eq!(p.access_ttl_seconds, 300);
689        let p = McpPolicy::default().with_access_ttl(5 * 60);
690        assert_eq!(p.access_ttl_seconds, 5 * 60);
691    }
692
693    // ---- mint_user_fresh: success paths ------------------------------------
694
695    #[test]
696    fn mint_user_fresh_signs_token_verifiable_at_edge() {
697        let (authority, verifier) = rig();
698        let user = PrincipalId::user("alice");
699        let aud = "https://kamaji.camp.example";
700        put_simple_grant(
701            &authority,
702            user.clone(),
703            aud,
704            vec![ScopeOrBundle::Scope(Scope::CloudDeploy), ScopeOrBundle::Scope(Scope::CloudRead)],
705        );
706
707        pollster::block_on(async {
708            let minted = authority
709                .mint_user_fresh(user.clone(), None, None, aud, 1_000)
710                .await
711                .unwrap();
712
713            // Token starts with v4.public — the doc-pinned envelope.
714            assert!(minted.token.starts_with("v4.public."));
715            // auth_strength is user-fresh on this path.
716            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
717            // jti is non-empty + iat/exp respect the default TTL (10 min).
718            assert!(!minted.claims.jti.is_empty());
719            assert_eq!(minted.claims.iat, 1_000);
720            assert_eq!(
721                minted.claims.exp,
722                1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
723            );
724            // Edge verifies the freshly minted token under the public key.
725            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
726            assert_eq!(back, minted.claims);
727            assert_eq!(back.sub, user);
728            assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
729        });
730    }
731
732    #[test]
733    fn mint_user_fresh_expands_bundles_at_mint_time() {
734        // A grant of `{"bundle":"deploy-admin"}` lands on the wire as the
735        // literal scope list (rule (2)). Edit the bundle, re-mint, see the
736        // change without rewriting the grant.
737        let (authority, _verifier) = rig();
738        let user = PrincipalId::user("bob");
739        let aud = "https://kamaji.example";
740        put_simple_grant(
741            &authority,
742            user.clone(),
743            aud,
744            vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
745        );
746
747        pollster::block_on(async {
748            let first = authority
749                .mint_user_fresh(user.clone(), None, None, aud, 1_000)
750                .await
751                .unwrap();
752            assert!(first.claims.scope.contains(&Scope::CloudDestroy));
753
754            // Edit the bundle: drop CloudDestroy. Grant is untouched.
755            authority
756                .bundles
757                .put(
758                    &BundleName::new("deploy-admin"),
759                    &[Scope::CloudRead, Scope::CloudDeploy],
760                )
761                .await
762                .unwrap();
763
764            let second = authority
765                .mint_user_fresh(user, None, None, aud, 1_100)
766                .await
767                .unwrap();
768            assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
769            assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
770        });
771    }
772
773    #[test]
774    fn mint_user_fresh_populates_act_camp_id_and_owns() {
775        let (authority, verifier) = rig();
776        let user = PrincipalId::user("carol");
777        let camp_id = "camp-xyz";
778        let aud = "https://aud.example";
779        put_simple_grant(
780            &authority,
781            user.clone(),
782            aud,
783            vec![ScopeOrBundle::Scope(Scope::CampRead)],
784        );
785        // Seed two owned resources under the camp principal.
786        pollster::block_on(async {
787            let svc = NewOwnership::new(
788                PrincipalId::camp(camp_id),
789                "service",
790                "svc-a",
791                "owns",
792                PrincipalId::service("yubaba"),
793                Some(user.clone()),
794            )
795            .unwrap();
796            authority.ownership.insert(&svc, 500).await.unwrap();
797
798            let doc = NewOwnership::new(
799                PrincipalId::camp(camp_id),
800                "arch_doc",
801                "doc-1",
802                "owns",
803                PrincipalId::service("yubaba"),
804                Some(user.clone()),
805            )
806            .unwrap();
807            authority.ownership.insert(&doc, 500).await.unwrap();
808
809            let minted = authority
810                .mint_user_fresh(
811                    user.clone(),
812                    Some(Actor::new(PrincipalId::service("agent-claude"))),
813                    Some(camp_id.to_owned()),
814                    aud,
815                    1_000,
816                )
817                .await
818                .unwrap();
819
820            assert_eq!(
821                minted.claims.act.as_ref().unwrap().sub,
822                PrincipalId::service("agent-claude")
823            );
824            assert_eq!(minted.claims.camp_id.as_deref(), Some(camp_id));
825            assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
826            assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
827
828            // Edge accepts it.
829            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
830            assert_eq!(back, minted.claims);
831        });
832    }
833
834    #[test]
835    fn mint_user_fresh_omits_owns_when_camp_owns_nothing() {
836        // No ownership rows + camp_id present → owns is empty/omitted on the
837        // wire. The claim skip_serializing_if guards this; the test pins it.
838        let (authority, _verifier) = rig();
839        let user = PrincipalId::user("dan");
840        let aud = "https://aud.example";
841        put_simple_grant(
842            &authority,
843            user.clone(),
844            aud,
845            vec![ScopeOrBundle::Scope(Scope::CampRead)],
846        );
847
848        pollster::block_on(async {
849            let minted = authority
850                .mint_user_fresh(user, None, Some("camp-empty".into()), aud, 1_000)
851                .await
852                .unwrap();
853            assert!(minted.claims.owns.is_empty());
854            let json = serde_json::to_string(&minted.claims).unwrap();
855            assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
856        });
857    }
858
859    // ---- mint_user_fresh: rejection paths ----------------------------------
860
861    #[test]
862    fn mint_user_fresh_rejects_unentitled_aud() {
863        let (authority, _verifier) = rig();
864        let user = PrincipalId::user("eve");
865        // No grant placed for any aud.
866        pollster::block_on(async {
867            let err = authority
868                .mint_user_fresh(user, None, None, "https://aud.example", 1_000)
869                .await
870                .unwrap_err();
871            match err {
872                McpMintError::AudNotEntitled { principal, aud } => {
873                    assert_eq!(principal, "user:eve");
874                    assert_eq!(aud, "https://aud.example");
875                }
876                other => panic!("expected AudNotEntitled, got {other:?}"),
877            }
878        });
879    }
880
881    #[test]
882    fn mint_user_fresh_rejects_non_user_principal() {
883        let (authority, _verifier) = rig();
884        pollster::block_on(async {
885            let err = authority
886                .mint_user_fresh(
887                    PrincipalId::service("yubaba"),
888                    None,
889                    None,
890                    "https://aud.example",
891                    1_000,
892                )
893                .await
894                .unwrap_err();
895            match err {
896                McpMintError::WrongPrincipalKind { expected, got } => {
897                    assert_eq!(expected, PrincipalKind::User);
898                    assert_eq!(got, PrincipalKind::Service);
899                }
900                other => panic!("expected WrongPrincipalKind, got {other:?}"),
901            }
902        });
903    }
904
905    #[test]
906    fn mint_user_fresh_rejects_service_only_scope_smuggled_via_bundle() {
907        // Defence in depth for composition rule (4): if a bundle granted to
908        // a User principal contains ownership:write, the mint path catches
909        // it via validate_grant BEFORE signing — the misconfigured bundle
910        // never becomes a mintable token.
911        let (authority, _verifier) = rig();
912        let user = PrincipalId::user("mallory");
913        let aud = "https://aud.example";
914
915        pollster::block_on(async {
916            authority
917                .bundles
918                .put(
919                    &BundleName::new("dangerous"),
920                    &[Scope::CampRead, Scope::OwnershipWrite],
921                )
922                .await
923                .unwrap();
924            authority.grants.put(
925                user.clone(),
926                aud,
927                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
928            );
929
930            let err = authority
931                .mint_user_fresh(user, None, None, aud, 1_000)
932                .await
933                .unwrap_err();
934            match err {
935                McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
936                    assert_eq!(scope, Scope::OwnershipWrite);
937                    assert_eq!(kind, PrincipalKind::User);
938                }
939                other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
940            }
941        });
942    }
943
944    // ---- mint_bootstrap: success paths -------------------------------------
945
946    #[test]
947    fn mint_bootstrap_signs_token_verifiable_at_edge() {
948        let (authority, verifier) = rig();
949        let camp = PrincipalId::camp("c-xyz");
950        let aud = "https://kamaji.camp.example";
951        put_simple_grant(
952            &authority,
953            camp.clone(),
954            aud,
955            vec![
956                ScopeOrBundle::Scope(Scope::CloudDeploy),
957                ScopeOrBundle::Scope(Scope::CloudRead),
958            ],
959        );
960
961        pollster::block_on(async {
962            let minted = authority
963                .mint_bootstrap(camp.clone(), aud, 1_000)
964                .await
965                .unwrap();
966
967            // Same v4.public envelope as mint path 1.
968            assert!(minted.token.starts_with("v4.public."));
969            // auth_strength is bootstrap on this path.
970            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::Bootstrap));
971            // sub is the camp principal; camp_id is the bare id (no prefix) so
972            // a consumer doesn't have to re-parse sub.
973            assert_eq!(minted.claims.sub, camp);
974            assert_eq!(minted.claims.camp_id.as_deref(), Some("c-xyz"));
975            // No act claim — bootstrap is the camp acting as itself, not a
976            // user acted-on-by an agent.
977            assert!(minted.claims.act.is_none());
978            assert!(!minted.claims.jti.is_empty());
979            assert_eq!(minted.claims.iat, 1_000);
980            assert_eq!(
981                minted.claims.exp,
982                1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
983            );
984
985            // Edge verifies the freshly minted token under the public key —
986            // no per-call cheers round trip.
987            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
988            assert_eq!(back, minted.claims);
989            assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
990        });
991    }
992
993    #[test]
994    fn mint_bootstrap_owns_reflects_ownership_table_state() {
995        let (authority, _verifier) = rig();
996        let camp = PrincipalId::camp("c-with-owns");
997        let user = PrincipalId::user("u-on-behalf");
998        let aud = "https://aud.example";
999        put_simple_grant(
1000            &authority,
1001            camp.clone(),
1002            aud,
1003            vec![ScopeOrBundle::Scope(Scope::CampRead)],
1004        );
1005
1006        pollster::block_on(async {
1007            // Seed two owned resources under the camp principal — the camp is
1008            // the holder, the user is the human on whose behalf the grant
1009            // happened (W159 §audit / on_behalf_of).
1010            let svc = NewOwnership::new(
1011                camp.clone(),
1012                "service",
1013                "svc-a",
1014                "owns",
1015                PrincipalId::service("yubaba"),
1016                Some(user.clone()),
1017            )
1018            .unwrap();
1019            authority.ownership.insert(&svc, 500).await.unwrap();
1020
1021            let doc = NewOwnership::new(
1022                camp.clone(),
1023                "arch_doc",
1024                "doc-1",
1025                "owns",
1026                PrincipalId::service("yubaba"),
1027                Some(user.clone()),
1028            )
1029            .unwrap();
1030            authority.ownership.insert(&doc, 500).await.unwrap();
1031
1032            let minted = authority
1033                .mint_bootstrap(camp.clone(), aud, 1_000)
1034                .await
1035                .unwrap();
1036
1037            assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
1038            assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
1039        });
1040    }
1041
1042    #[test]
1043    fn rows_to_owns_dedups_duplicate_resource_ids() {
1044        // Historical duplicate rows (pre-idempotent-create stores, or the
1045        // residual concurrent-write race) must collapse to one entry per
1046        // (kind, resource_id) — the owns claim is set-membership.
1047        let camp = PrincipalId::camp("c-dup");
1048        let svc = PrincipalId::service("yubaba");
1049        let mk = |id: &str, kind: &str, rid: &str| {
1050            OwnershipRow::new(
1051                id.to_owned(),
1052                camp.clone(),
1053                kind.to_owned(),
1054                rid.to_owned(),
1055                "owns".to_owned(),
1056                svc.clone(),
1057                None,
1058                500,
1059                None,
1060            )
1061        };
1062        let rows = vec![
1063            mk("row-1", "service", "svc-a"),
1064            mk("row-2", "service", "svc-a"), // duplicate
1065            mk("row-3", "service", "svc-b"),
1066            mk("row-4", "node", "aa11"),
1067            mk("row-5", "node", "aa11"), // duplicate
1068            mk("row-6", "pond", "p-1"),
1069            mk("row-7", "pond", "p-1"), // duplicate in the extra spill
1070        ];
1071
1072        let owns = rows_to_owns(&rows);
1073        assert_eq!(owns.service, vec!["svc-a".to_string(), "svc-b".to_string()]);
1074        assert_eq!(owns.node, vec!["aa11".to_string()]);
1075        assert_eq!(
1076            owns.extra.get("pond"),
1077            Some(&vec!["p-1".to_string()])
1078        );
1079    }
1080
1081    #[test]
1082    fn mint_bootstrap_omits_owns_when_camp_owns_nothing() {
1083        // No ownership rows → owns is empty and omitted on the wire. Same
1084        // skip_serializing_if guard as mint path 1, pinned independently here
1085        // because the bootstrap mint path always sets camp_id (a token whose
1086        // sub is `camp:<C>` is always scoped to that camp).
1087        let (authority, _verifier) = rig();
1088        let camp = PrincipalId::camp("c-empty");
1089        let aud = "https://aud.example";
1090        put_simple_grant(
1091            &authority,
1092            camp.clone(),
1093            aud,
1094            vec![ScopeOrBundle::Scope(Scope::CampRead)],
1095        );
1096
1097        pollster::block_on(async {
1098            let minted = authority
1099                .mint_bootstrap(camp, aud, 1_000)
1100                .await
1101                .unwrap();
1102            assert!(minted.claims.owns.is_empty());
1103            let json = serde_json::to_string(&minted.claims).unwrap();
1104            assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
1105            // camp_id is still present even when owns is empty.
1106            assert!(json.contains("\"camp_id\":\"c-empty\""), "camp_id must remain: {json}");
1107        });
1108    }
1109
1110    #[test]
1111    fn mint_bootstrap_expands_bundles_at_mint_time() {
1112        // Same propagation property as F5/F6: a bundle edit shows up on the
1113        // next mint without rewriting the camp's grant.
1114        let (authority, _verifier) = rig();
1115        let camp = PrincipalId::camp("c-deploy");
1116        let aud = "https://aud.example";
1117        put_simple_grant(
1118            &authority,
1119            camp.clone(),
1120            aud,
1121            vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
1122        );
1123
1124        pollster::block_on(async {
1125            let first = authority
1126                .mint_bootstrap(camp.clone(), aud, 1_000)
1127                .await
1128                .unwrap();
1129            assert!(first.claims.scope.contains(&Scope::CloudDestroy));
1130
1131            authority
1132                .bundles
1133                .put(
1134                    &BundleName::new("deploy-admin"),
1135                    &[Scope::CloudRead, Scope::CloudDeploy],
1136                )
1137                .await
1138                .unwrap();
1139
1140            let second = authority
1141                .mint_bootstrap(camp, aud, 1_100)
1142                .await
1143                .unwrap();
1144            assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
1145            assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
1146        });
1147    }
1148
1149    // ---- mint_bootstrap: rejection paths -----------------------------------
1150
1151    #[test]
1152    fn mint_bootstrap_rejects_non_camp_principal() {
1153        let (authority, _verifier) = rig();
1154        pollster::block_on(async {
1155            // A user principal handed to the bootstrap path is a programmer
1156            // error — surfaces WrongPrincipalKind, not a 403.
1157            let err = authority
1158                .mint_bootstrap(
1159                    PrincipalId::user("alice"),
1160                    "https://aud.example",
1161                    1_000,
1162                )
1163                .await
1164                .unwrap_err();
1165            match err {
1166                McpMintError::WrongPrincipalKind { expected, got } => {
1167                    assert_eq!(expected, PrincipalKind::Camp);
1168                    assert_eq!(got, PrincipalKind::User);
1169                }
1170                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1171            }
1172
1173            // Same for a service principal.
1174            let err = authority
1175                .mint_bootstrap(
1176                    PrincipalId::service("yubaba"),
1177                    "https://aud.example",
1178                    1_000,
1179                )
1180                .await
1181                .unwrap_err();
1182            match err {
1183                McpMintError::WrongPrincipalKind { expected, got } => {
1184                    assert_eq!(expected, PrincipalKind::Camp);
1185                    assert_eq!(got, PrincipalKind::Service);
1186                }
1187                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1188            }
1189        });
1190    }
1191
1192    #[test]
1193    fn mint_bootstrap_rejects_unentitled_aud() {
1194        let (authority, _verifier) = rig();
1195        let camp = PrincipalId::camp("c-no-grants");
1196        // No grant placed for any aud — composition rule (5).
1197        pollster::block_on(async {
1198            let err = authority
1199                .mint_bootstrap(camp, "https://aud.example", 1_000)
1200                .await
1201                .unwrap_err();
1202            match err {
1203                McpMintError::AudNotEntitled { principal, aud } => {
1204                    assert_eq!(principal, "camp:c-no-grants");
1205                    assert_eq!(aud, "https://aud.example");
1206                }
1207                other => panic!("expected AudNotEntitled, got {other:?}"),
1208            }
1209        });
1210    }
1211
1212    #[test]
1213    fn mint_bootstrap_rejects_service_only_scope_smuggled_via_bundle() {
1214        // Composition rule (4) — `ownership:write` and `audit:write` are
1215        // grantable to kind=service ONLY. A bundle granted to a camp
1216        // principal that expands to a service-only scope is rejected by
1217        // validate_grant BEFORE signing, mirroring F6's defence in depth on
1218        // the user path.
1219        let (authority, _verifier) = rig();
1220        let camp = PrincipalId::camp("c-sneaky");
1221        let aud = "https://aud.example";
1222
1223        pollster::block_on(async {
1224            authority
1225                .bundles
1226                .put(
1227                    &BundleName::new("dangerous"),
1228                    &[Scope::CampRead, Scope::OwnershipWrite],
1229                )
1230                .await
1231                .unwrap();
1232            authority.grants.put(
1233                camp.clone(),
1234                aud,
1235                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1236            );
1237
1238            let err = authority
1239                .mint_bootstrap(camp, aud, 1_000)
1240                .await
1241                .unwrap_err();
1242            match err {
1243                McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1244                    assert_eq!(scope, Scope::OwnershipWrite);
1245                    assert_eq!(kind, PrincipalKind::Camp);
1246                }
1247                other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1248            }
1249        });
1250    }
1251
1252    // ---- mint_token_exchange: success paths --------------------------------
1253
1254    #[test]
1255    fn mint_token_exchange_attributes_to_user_with_camp_as_context() {
1256        let (authority, verifier) = rig();
1257        let user = PrincipalId::user("alice");
1258        let camp = PrincipalId::camp("c-multi");
1259        let aud = "https://kamaji.camp.example";
1260
1261        // User holds CloudDeploy + CloudRead for this aud; camp holds the
1262        // same. Requested scope is a subset.
1263        put_simple_grant(
1264            &authority,
1265            user.clone(),
1266            aud,
1267            vec![
1268                ScopeOrBundle::Scope(Scope::CloudDeploy),
1269                ScopeOrBundle::Scope(Scope::CloudRead),
1270            ],
1271        );
1272        put_simple_grant(
1273            &authority,
1274            camp.clone(),
1275            aud,
1276            vec![
1277                ScopeOrBundle::Scope(Scope::CloudDeploy),
1278                ScopeOrBundle::Scope(Scope::CloudRead),
1279            ],
1280        );
1281
1282        // Camp owns a service — owns claim must come from the camp.
1283        pollster::block_on(async {
1284            let svc = NewOwnership::new(
1285                camp.clone(),
1286                "service",
1287                "svc-prod",
1288                "owns",
1289                PrincipalId::service("yubaba"),
1290                Some(user.clone()),
1291            )
1292            .unwrap();
1293            authority.ownership.insert(&svc, 500).await.unwrap();
1294
1295            let minted = authority
1296                .mint_token_exchange(
1297                    user.clone(),
1298                    camp.clone(),
1299                    Some(Actor::new(PrincipalId::service("agent-claude"))),
1300                    aud,
1301                    vec![Scope::CloudDeploy],
1302                    1_000,
1303                )
1304                .await
1305                .unwrap();
1306
1307            assert!(minted.token.starts_with("v4.public."));
1308            // sub is the USER, not the camp — exchange attributes to the
1309            // human even though the camp is the bearer at the HTTP layer.
1310            assert_eq!(minted.claims.sub, user);
1311            // camp_id carries the camp context (bare id, no prefix).
1312            assert_eq!(minted.claims.camp_id.as_deref(), Some("c-multi"));
1313            // act carries the agent variant.
1314            assert_eq!(
1315                minted.claims.act.as_ref().unwrap().sub,
1316                PrincipalId::service("agent-claude"),
1317            );
1318            // user-fresh — the user authenticated locally.
1319            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
1320            // Result scope is the requested scope (verified in intersection).
1321            assert_eq!(minted.claims.scope, vec![Scope::CloudDeploy]);
1322            // owns comes from the CAMP, not the user.
1323            assert_eq!(minted.claims.owns.service, vec!["svc-prod".to_string()]);
1324            // jti present, iat/exp respect policy.
1325            assert!(!minted.claims.jti.is_empty());
1326            assert_eq!(minted.claims.iat, 1_000);
1327            assert_eq!(
1328                minted.claims.exp,
1329                1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1330            );
1331            // Edge verifies.
1332            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1333            assert_eq!(back, minted.claims);
1334        });
1335    }
1336
1337    #[test]
1338    fn mint_token_exchange_intersection_drops_scope_one_side_lacks() {
1339        // Verify intersection by removing a scope from one side and observing
1340        // it's rejected at the requested-scope check — proves the camp's
1341        // grants actually narrow the user's, not just rubber-stamp the
1342        // request.
1343        let (authority, _verifier) = rig();
1344        let user = PrincipalId::user("bob");
1345        let camp = PrincipalId::camp("c-narrow");
1346        let aud = "https://aud.example";
1347
1348        // User has both; camp has only CloudRead.
1349        put_simple_grant(
1350            &authority,
1351            user.clone(),
1352            aud,
1353            vec![
1354                ScopeOrBundle::Scope(Scope::CloudDeploy),
1355                ScopeOrBundle::Scope(Scope::CloudRead),
1356            ],
1357        );
1358        put_simple_grant(
1359            &authority,
1360            camp.clone(),
1361            aud,
1362            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1363        );
1364
1365        pollster::block_on(async {
1366            // Requesting CloudRead alone succeeds — in both.
1367            let minted = authority
1368                .mint_token_exchange(
1369                    user.clone(),
1370                    camp.clone(),
1371                    None,
1372                    aud,
1373                    vec![Scope::CloudRead],
1374                    1_000,
1375                )
1376                .await
1377                .unwrap();
1378            assert_eq!(minted.claims.scope, vec![Scope::CloudRead]);
1379
1380            // Requesting CloudDeploy fails — camp lacks it. The whole
1381            // exchange rejects (not a partial token).
1382            let err = authority
1383                .mint_token_exchange(
1384                    user,
1385                    camp,
1386                    None,
1387                    aud,
1388                    vec![Scope::CloudRead, Scope::CloudDeploy],
1389                    1_000,
1390                )
1391                .await
1392                .unwrap_err();
1393            match err {
1394                McpMintError::InvalidScope { scope, aud: a } => {
1395                    assert_eq!(scope, Scope::CloudDeploy);
1396                    assert_eq!(a, aud);
1397                }
1398                other => panic!("expected InvalidScope, got {other:?}"),
1399            }
1400        });
1401    }
1402
1403    #[test]
1404    fn mint_token_exchange_rejects_scope_user_lacks() {
1405        // Mirror case: camp has it, user doesn't. Same all-or-nothing reject.
1406        let (authority, _verifier) = rig();
1407        let user = PrincipalId::user("carol");
1408        let camp = PrincipalId::camp("c-wide");
1409        let aud = "https://aud.example";
1410
1411        put_simple_grant(
1412            &authority,
1413            user.clone(),
1414            aud,
1415            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1416        );
1417        put_simple_grant(
1418            &authority,
1419            camp.clone(),
1420            aud,
1421            vec![
1422                ScopeOrBundle::Scope(Scope::CloudRead),
1423                ScopeOrBundle::Scope(Scope::CloudDeploy),
1424            ],
1425        );
1426
1427        pollster::block_on(async {
1428            let err = authority
1429                .mint_token_exchange(
1430                    user,
1431                    camp,
1432                    None,
1433                    aud,
1434                    vec![Scope::CloudDeploy],
1435                    1_000,
1436                )
1437                .await
1438                .unwrap_err();
1439            match err {
1440                McpMintError::InvalidScope { scope, aud: _ } => {
1441                    assert_eq!(scope, Scope::CloudDeploy);
1442                }
1443                other => panic!("expected InvalidScope, got {other:?}"),
1444            }
1445        });
1446    }
1447
1448    // ---- mint_token_exchange: rejection paths ------------------------------
1449
1450    #[test]
1451    fn mint_token_exchange_rejects_wrong_user_kind() {
1452        let (authority, _verifier) = rig();
1453        pollster::block_on(async {
1454            let err = authority
1455                .mint_token_exchange(
1456                    PrincipalId::camp("c-as-user"),
1457                    PrincipalId::camp("c"),
1458                    None,
1459                    "https://aud.example",
1460                    vec![],
1461                    1_000,
1462                )
1463                .await
1464                .unwrap_err();
1465            match err {
1466                McpMintError::WrongPrincipalKind { expected, got } => {
1467                    assert_eq!(expected, PrincipalKind::User);
1468                    assert_eq!(got, PrincipalKind::Camp);
1469                }
1470                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1471            }
1472        });
1473    }
1474
1475    #[test]
1476    fn mint_token_exchange_rejects_wrong_camp_kind() {
1477        let (authority, _verifier) = rig();
1478        pollster::block_on(async {
1479            let err = authority
1480                .mint_token_exchange(
1481                    PrincipalId::user("alice"),
1482                    PrincipalId::service("yubaba"),
1483                    None,
1484                    "https://aud.example",
1485                    vec![],
1486                    1_000,
1487                )
1488                .await
1489                .unwrap_err();
1490            match err {
1491                McpMintError::WrongPrincipalKind { expected, got } => {
1492                    assert_eq!(expected, PrincipalKind::Camp);
1493                    assert_eq!(got, PrincipalKind::Service);
1494                }
1495                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1496            }
1497        });
1498    }
1499
1500    #[test]
1501    fn mint_token_exchange_rejects_unentitled_user() {
1502        let (authority, _verifier) = rig();
1503        let user = PrincipalId::user("eve");
1504        let camp = PrincipalId::camp("c");
1505        let aud = "https://aud.example";
1506        // Camp has a grant, user doesn't — must reject on the user side.
1507        put_simple_grant(
1508            &authority,
1509            camp.clone(),
1510            aud,
1511            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1512        );
1513
1514        pollster::block_on(async {
1515            let err = authority
1516                .mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
1517                .await
1518                .unwrap_err();
1519            match err {
1520                McpMintError::AudNotEntitled { principal, aud: _ } => {
1521                    assert_eq!(principal, "user:eve");
1522                }
1523                other => panic!("expected AudNotEntitled, got {other:?}"),
1524            }
1525        });
1526    }
1527
1528    #[test]
1529    fn mint_token_exchange_rejects_unentitled_camp() {
1530        // Mirror: user has a grant, camp doesn't. Composition rule (5)
1531        // applies per side, so the camp's empty grant list rejects.
1532        let (authority, _verifier) = rig();
1533        let user = PrincipalId::user("frank");
1534        let camp = PrincipalId::camp("c-no-grants");
1535        let aud = "https://aud.example";
1536        put_simple_grant(
1537            &authority,
1538            user.clone(),
1539            aud,
1540            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1541        );
1542
1543        pollster::block_on(async {
1544            let err = authority
1545                .mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
1546                .await
1547                .unwrap_err();
1548            match err {
1549                McpMintError::AudNotEntitled { principal, aud: _ } => {
1550                    assert_eq!(principal, "camp:c-no-grants");
1551                }
1552                other => panic!("expected AudNotEntitled, got {other:?}"),
1553            }
1554        });
1555    }
1556
1557    #[test]
1558    fn mint_token_exchange_catches_service_only_scope_smuggled_via_camp_bundle() {
1559        // Defence in depth — composition rule (4) — even on the exchange
1560        // path: a camp bundle that expands to OwnershipWrite is caught by
1561        // validate_grant(Camp, scope) BEFORE intersection is computed.
1562        let (authority, _verifier) = rig();
1563        let user = PrincipalId::user("g");
1564        let camp = PrincipalId::camp("c-sneaky");
1565        let aud = "https://aud.example";
1566
1567        pollster::block_on(async {
1568            authority
1569                .bundles
1570                .put(
1571                    &BundleName::new("dangerous"),
1572                    &[Scope::CampRead, Scope::OwnershipWrite],
1573                )
1574                .await
1575                .unwrap();
1576            put_simple_grant(
1577                &authority,
1578                user.clone(),
1579                aud,
1580                vec![ScopeOrBundle::Scope(Scope::CampRead)],
1581            );
1582            put_simple_grant(
1583                &authority,
1584                camp,
1585                aud,
1586                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1587            );
1588
1589            let err = authority
1590                .mint_token_exchange(
1591                    user,
1592                    PrincipalId::camp("c-sneaky"),
1593                    None,
1594                    aud,
1595                    vec![Scope::CampRead],
1596                    1_000,
1597                )
1598                .await
1599                .unwrap_err();
1600            match err {
1601                McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1602                    assert_eq!(scope, Scope::OwnershipWrite);
1603                    assert_eq!(kind, PrincipalKind::Camp);
1604                }
1605                other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1606            }
1607        });
1608    }
1609
1610    // ---- McpMintError → Error conversion -----------------------------------
1611
1612    #[test]
1613    fn mcp_mint_error_converts_to_umbrella_error() {
1614        let e: Error = McpMintError::AudNotEntitled {
1615            principal: "user:x".into(),
1616            aud: "a".into(),
1617        }
1618        .into();
1619        match e {
1620            Error::InvalidInput(msg) => assert!(msg.contains("aud")),
1621            other => panic!("expected InvalidInput, got {other:?}"),
1622        }
1623
1624        let e: Error = McpMintError::Store(StoreError::Conflict).into();
1625        assert!(matches!(e, Error::Store(StoreError::Conflict)));
1626
1627        let e: Error = McpMintError::Codec(CodecError::Malformed).into();
1628        assert!(matches!(e, Error::Codec(CodecError::Malformed)));
1629    }
1630}