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    /// Lifetime of a user API token when the mint request names none.
51    ///
52    /// Deliberately **not** session-shaped: a PAT is the credential a CI job
53    /// or a headless agent holds, and re-minting it is a human ceremony, so
54    /// the minutes-scale access TTL above would make it useless. 90 days is
55    /// long enough to not be a weekly chore and short enough that a forgotten
56    /// token dies on its own.
57    pub api_token_default_ttl_seconds: i64,
58    /// Hard ceiling on a user API token's lifetime. A request above it is
59    /// rejected ([`McpMintError::TtlOutOfRange`]) rather than clamped —
60    /// silently handing back a token that expires sooner than asked is the
61    /// kind of surprise that surfaces as a 2am outage.
62    pub api_token_max_ttl_seconds: i64,
63}
64
65impl McpPolicy {
66    /// 10 minutes — middle of the doc's 5–15 min range.
67    pub const DEFAULT_ACCESS_TTL_SECONDS: i64 = 10 * 60;
68    /// 90 days.
69    pub const DEFAULT_API_TOKEN_TTL_SECONDS: i64 = 90 * 24 * 60 * 60;
70    /// 365 days.
71    pub const MAX_API_TOKEN_TTL_SECONDS: i64 = 365 * 24 * 60 * 60;
72
73    pub fn new(access_ttl_seconds: i64) -> Self {
74        Self {
75            access_ttl_seconds,
76            ..Self::default()
77        }
78    }
79
80    pub fn with_access_ttl(mut self, seconds: i64) -> Self {
81        self.access_ttl_seconds = seconds;
82        self
83    }
84
85    /// Override both API-token TTL bounds. `default` is what an omitted
86    /// `expires_in_secs` resolves to; `max` is the ceiling a named one is
87    /// checked against.
88    pub fn with_api_token_ttls(mut self, default: i64, max: i64) -> Self {
89        self.api_token_default_ttl_seconds = default;
90        self.api_token_max_ttl_seconds = max;
91        self
92    }
93}
94
95impl Default for McpPolicy {
96    fn default() -> Self {
97        Self {
98            access_ttl_seconds: Self::DEFAULT_ACCESS_TTL_SECONDS,
99            api_token_default_ttl_seconds: Self::DEFAULT_API_TOKEN_TTL_SECONDS,
100            api_token_max_ttl_seconds: Self::MAX_API_TOKEN_TTL_SECONDS,
101        }
102    }
103}
104
105/// A freshly-minted MCP-call token plus the [`McpClaims`] it carries.
106#[derive(Debug, Clone)]
107#[non_exhaustive]
108pub struct MintedMcpToken {
109    /// The signed `v4.public.*` token string.
110    pub token: String,
111    /// The claims embedded in `token`, including the freshly minted `jti`.
112    pub claims: McpClaims,
113}
114
115/// Why a mint attempt failed before (or after) the signing step.
116///
117/// Typed so the HTTP layer (R020-T17 routes) can map specific failures to the
118/// right status code: `AudNotEntitled` → 403, `GrantMisconfigured` → 500
119/// (server-side data bug), `Store` → 503, `Codec` → 500.
120#[derive(Debug, thiserror::Error)]
121#[non_exhaustive]
122pub enum McpMintError {
123    /// Composition rule (5) — the principal holds no grant for the requested
124    /// `aud`. Per-call rejection, returned before any signing happens.
125    #[error("no entitlement: principal '{principal}' has no grant for aud '{aud}'")]
126    AudNotEntitled { principal: String, aud: String },
127    /// RFC 8693 — a requested scope on a token-exchange is not in the
128    /// intersection of both principals' grants for this `aud`. Per the doc
129    /// (`§Mint flows` → token exchange), the whole exchange is rejected —
130    /// `invalid_scope`, not a partial token. Maps to HTTP 400 `invalid_scope`.
131    #[error("requested scope '{scope}' is not granted by both principals for aud '{aud}'")]
132    InvalidScope { scope: Scope, aud: String },
133    /// The caller passed the wrong principal kind for this mint path — e.g.
134    /// `mint_user_fresh` invoked with `svc:` or `camp:` in the `sub` slot.
135    /// A programmer-error, not a user-input rejection: surface 500, not 403.
136    #[error("mint path expects {expected} principal; got {got}")]
137    WrongPrincipalKind {
138        expected: PrincipalKind,
139        got: PrincipalKind,
140    },
141    /// [`mint_api_token`](McpAuthority::mint_api_token) was asked for scopes
142    /// the principal's grants do not currently expand to. **Every** offender
143    /// is named: silently dropping the unheld ones would hand back a token
144    /// quietly weaker than the one asked for, which fails later, elsewhere,
145    /// as an unexplained 403. Maps to HTTP 400.
146    #[error(
147        "principal '{principal}' does not hold {} for aud '{aud}'",
148        .scopes.iter().map(|s| s.as_wire()).collect::<Vec<_>>().join(", ")
149    )]
150    UnentitledScopes {
151        principal: String,
152        scopes: Vec<Scope>,
153        aud: String,
154    },
155    /// A user-API-token mint named an `expires_in_secs` outside
156    /// `1..=policy.api_token_max_ttl_seconds`. Rejected, never clamped.
157    /// Maps to HTTP 400.
158    #[error("requested ttl {requested}s is outside the allowed range 1..={max}s")]
159    TtlOutOfRange { requested: i64, max: i64 },
160    /// Composition rule (4) defence in depth — bundle expansion produced a
161    /// service-only scope for a non-service principal. Indicates a
162    /// misconfigured bundle or a grant that should have been rejected at the
163    /// grant API; surface as 500.
164    #[error(transparent)]
165    GrantMisconfigured(#[from] GrantError),
166    /// Bundle expansion failed (unknown bundle / store error inside
167    /// expansion).
168    #[error(transparent)]
169    BundleExpansion(#[from] BundleExpansionError),
170    /// Underlying store failure (grants / ownership).
171    #[error(transparent)]
172    Store(#[from] StoreError),
173    /// Signing failure inside the codec layer.
174    #[error(transparent)]
175    Codec(#[from] CodecError),
176}
177
178impl From<McpMintError> for Error {
179    fn from(e: McpMintError) -> Self {
180        match e {
181            McpMintError::Store(s) => Error::Store(s),
182            McpMintError::Codec(c) => Error::Codec(c),
183            McpMintError::BundleExpansion(BundleExpansionError::Store(s)) => Error::Store(s),
184            // Structured rejections (AudNotEntitled, WrongPrincipalKind,
185            // GrantMisconfigured, BundleExpansion::Unknown) carry a message
186            // that's safe to surface; route them through InvalidInput so a
187            // caller that doesn't pattern-match on McpMintError still gets a
188            // sensible umbrella error.
189            other => Error::InvalidInput(other.to_string()),
190        }
191    }
192}
193
194/// Origin facade: everything that can *mint* an MCP-call token.
195///
196/// Holds the four origin-tier capabilities for the MCP boundary — a
197/// [`PasetoV4SecretMinter`], a [`BundleStore`], a [`GrantStore`], and an
198/// [`OwnershipStore`] — plus the cheers issuer URL and an [`McpPolicy`].
199/// Generic (not `dyn`) so the assembled capability set is visible in the
200/// type. The matching edge-side verifier is
201/// [`PasetoV4PublicVerifier`](cheers_verify::PasetoV4PublicVerifier) +
202/// `verify_mcp_at`; the verify-only consumer (kamaji) never depends on
203/// this crate.
204pub struct McpAuthority<B, G, O> {
205    minter: PasetoV4SecretMinter,
206    bundles: B,
207    grants: G,
208    ownership: O,
209    iss: String,
210    /// `kid` stamped into every minted token's PASETO footer (R592-B7 wire
211    /// convention) — identifies which published key `minter` signs with, so
212    /// an edge verifier's JWKS/footer lookup can find the matching pubkey.
213    kid: String,
214    policy: McpPolicy,
215}
216
217impl<B, G, O> McpAuthority<B, G, O>
218where
219    B: BundleStore,
220    G: GrantStore,
221    O: OwnershipStore,
222{
223    /// Assemble an authority with the [default policy](McpPolicy::default).
224    ///
225    /// `kid` is the footer identifier published alongside `minter`'s public
226    /// key (e.g. in cheers's JWKS) — every token this authority mints carries
227    /// it so a verifier can select the right key.
228    pub fn new(
229        minter: PasetoV4SecretMinter,
230        bundles: B,
231        grants: G,
232        ownership: O,
233        iss: impl Into<String>,
234        kid: impl Into<String>,
235    ) -> Self {
236        Self {
237            minter,
238            bundles,
239            grants,
240            ownership,
241            iss: iss.into(),
242            kid: kid.into(),
243            policy: McpPolicy::default(),
244        }
245    }
246
247    /// Override the TTL policy.
248    pub fn with_policy(mut self, policy: McpPolicy) -> Self {
249        self.policy = policy;
250        self
251    }
252
253    pub fn policy(&self) -> &McpPolicy {
254        &self.policy
255    }
256
257    pub fn issuer(&self) -> &str {
258        &self.iss
259    }
260
261    /// **Mint path 1** — user-initiated, passkey-fresh (R020-F6).
262    ///
263    /// Returns a token bearing
264    /// `{sub: user:<U>, act?, camp_id?, scope: [...], owns: {...}, auth_strength: "user-fresh"}`.
265    /// Required inputs:
266    ///
267    /// - `user`: the authenticated user's principal (kind MUST be
268    ///   [`PrincipalKind::User`]).
269    /// - `actor`: the optional `act` claim (RFC 8693) — the agent acting on
270    ///   the user's behalf. The agent is never the primary `sub`.
271    /// - `camp_id`: when set, both populates the `camp_id` claim and is the
272    ///   key used to look up the `owns` claim (rows held by
273    ///   `camp:<camp_id>`).
274    /// - `aud`: the target resource URI for this call. Per composition rule
275    ///   (5) the principal MUST hold at least one grant entry for `aud` — an
276    ///   empty grant list returns [`McpMintError::AudNotEntitled`] before
277    ///   any signing happens.
278    /// - `now`: unix seconds. `iat = now`, `exp = now + policy.access_ttl`.
279    ///
280    /// The pipeline: grants → [`expand_scopes`] → [`validate_grant`] per
281    /// scope (rule (4) defence) → ownership lookup → sign with `mint_mcp`.
282    pub async fn mint_user_fresh(
283        &self,
284        user: PrincipalId,
285        actor: Option<Actor>,
286        camp_id: Option<String>,
287        aud: impl Into<String>,
288        now: i64,
289    ) -> Result<MintedMcpToken, McpMintError> {
290        if user.kind != PrincipalKind::User {
291            return Err(McpMintError::WrongPrincipalKind {
292                expected: PrincipalKind::User,
293                got: user.kind,
294            });
295        }
296        let aud = aud.into();
297
298        let entries = self.grants.list_for(&user, &aud).await?;
299        if entries.is_empty() {
300            return Err(McpMintError::AudNotEntitled {
301                principal: user.to_string(),
302                aud,
303            });
304        }
305
306        let scopes = expand_scopes(&self.bundles, &entries).await?;
307        for s in &scopes {
308            validate_grant(user.kind, *s)?;
309        }
310
311        let owns = match &camp_id {
312            Some(id) => {
313                let camp_principal = PrincipalId::camp(id);
314                let rows = self.ownership.list_for_principal(&camp_principal).await?;
315                rows_to_owns(&rows)
316            }
317            None => Owns::default(),
318        };
319
320        let mut claims = McpClaims::new(
321            self.iss.clone(),
322            aud,
323            user,
324            now,
325            now + self.policy.access_ttl_seconds,
326            generate_jti(),
327            scopes,
328        )
329        .with_auth_strength(AuthStrength::UserFresh);
330        if let Some(a) = actor {
331            claims = claims.with_act(a);
332        }
333        if let Some(c) = camp_id {
334            claims = claims.with_camp_id(c);
335        }
336        if !owns.is_empty() {
337            claims = claims.with_owns(owns);
338        }
339
340        let token = self.minter.mint_mcp(&claims, &self.kid)?;
341        Ok(MintedMcpToken { token, claims })
342    }
343
344    /// **Mint path 3** — a long-lived user API token (PAT), R728-F1.
345    ///
346    /// The credential a CI job, a script or a headless agent holds so it can
347    /// *be* the user without a browser. Returns a token bearing
348    /// `{sub: user:<U>, scope: [...], auth_strength: "api-token"}`.
349    ///
350    /// ## It is the same stack, not a parallel one
351    ///
352    /// Same signing key, same `jti`, same revocation set the edge already
353    /// reads. A PAT differs from a
354    /// [`mint_user_fresh`](Self::mint_user_fresh) token in exactly three
355    /// claim-level ways — `auth_strength`, a caller-chosen `exp`, and a
356    /// possibly-narrower `scope` — so nothing downstream needs a new verify
357    /// path to accept one, and a route that must refuse one reads
358    /// [`AuthStrength`].
359    ///
360    /// ## Attenuation is the point
361    ///
362    /// `requested` is **intersected**, never taken verbatim:
363    ///
364    /// - The principal's grants for `aud` are looked up and expanded, exactly
365    ///   as [`mint_user_fresh`](Self::mint_user_fresh) does — empty ⇒
366    ///   [`McpMintError::AudNotEntitled`] before any signing.
367    /// - [`validate_grant`] runs over every expanded scope (rule (4) defence)
368    ///   *before* signing, so a bundle smuggling a service-only scope into a
369    ///   user grant is caught here.
370    /// - An empty `requested` means "everything I currently hold for this
371    ///   aud" — the effective set, not a wildcard, and it is frozen into the
372    ///   token at mint time.
373    /// - A requested scope outside the effective set is
374    ///   [`McpMintError::UnentitledScopes`] naming every offender, **not** a
375    ///   silent drop.
376    ///
377    /// So a PAT can never be wider than its minter was at mint time. It can
378    /// outlive that entitlement — a grant revoked afterwards does not shrink
379    /// an already-signed token — which is what the revocation set and the TTL
380    /// ceiling are for.
381    ///
382    /// ## No `owns`, no `camp_id`, no `act`
383    ///
384    /// Deliberate, and the reason is the TTL. The `owns` claim is a snapshot
385    /// of the ownership table read at mint time, and the token's lifetime is
386    /// the propagation bound for edits to it (see [`McpPolicy`]) — fine at 10
387    /// minutes, a 90-day stale-authorization hole here. A PAT therefore
388    /// asserts identity and scope only; a consumer needing per-resource
389    /// ownership looks it up live. `act` is absent for a simpler reason: the
390    /// user minted this themselves, nobody is acting on their behalf.
391    ///
392    /// `ttl_seconds`: `None` ⇒ [`McpPolicy::api_token_default_ttl_seconds`].
393    /// `Some(n)` outside `1..=api_token_max_ttl_seconds` ⇒
394    /// [`McpMintError::TtlOutOfRange`] — rejected, not clamped.
395    pub async fn mint_api_token(
396        &self,
397        user: PrincipalId,
398        aud: impl Into<String>,
399        requested: &[Scope],
400        ttl_seconds: Option<i64>,
401        now: i64,
402    ) -> Result<MintedMcpToken, McpMintError> {
403        if user.kind != PrincipalKind::User {
404            return Err(McpMintError::WrongPrincipalKind {
405                expected: PrincipalKind::User,
406                got: user.kind,
407            });
408        }
409        let aud = aud.into();
410
411        let ttl = self.api_token_ttl(ttl_seconds)?;
412
413        let entries = self.grants.list_for(&user, &aud).await?;
414        if entries.is_empty() {
415            return Err(McpMintError::AudNotEntitled {
416                principal: user.to_string(),
417                aud,
418            });
419        }
420
421        let effective = expand_scopes(&self.bundles, &entries).await?;
422        for s in &effective {
423            validate_grant(user.kind, *s)?;
424        }
425
426        let scopes = attenuate(requested, effective, &user, &aud)?;
427
428        self.sign_api_token(user, aud, scopes, ttl, now)
429    }
430
431    /// **Mint path 3b** — roll a user API token over, R728-F2.
432    ///
433    /// Backs `POST /me/tokens/{id}/rotate`: the caller presents a credential
434    /// they already hold and gets a replacement, so a leaked or ageing PAT can
435    /// be replaced *by the token itself* without a browser ceremony. "A
436    /// credential must be able to roll itself" is an operator requirement
437    /// (2026-09-12), and this is the mint it needs.
438    ///
439    /// ## `held` is the entire authority ceiling, and the grant table is NOT read
440    ///
441    /// Note the signature: **not `async`**, because this path performs no
442    /// store I/O at all — that absence is the security property, not an
443    /// optimization. [`mint_api_token`](Self::mint_api_token) derives the
444    /// ceiling from `grants.list_for`; if rotation did the same, a token
445    /// minted when the user held `cloud:read` would silently come back
446    /// carrying `cloud:destroy` the moment an operator widened the grant.
447    /// That would unpick the property
448    /// `a_minted_pat_is_frozen_and_does_not_track_later_grant_edits` exists to
449    /// pin. So the ceiling here is `held` — the authority the presented
450    /// credential *already* carries — and nothing else. A rotation can narrow
451    /// and can never widen.
452    ///
453    /// `requested` empty ⇒ the replacement carries exactly `held`. A requested
454    /// scope outside `held` is [`McpMintError::UnentitledScopes`] naming every
455    /// offender, the same refusal (and the same 400) as the create path.
456    ///
457    /// ## Lifetime is refreshed, but still bounded
458    ///
459    /// The replacement gets a *fresh* TTL from the same policy
460    /// ([`McpPolicy::api_token_default_ttl_seconds`], ceiling
461    /// [`McpPolicy::api_token_max_ttl_seconds`], out of range rejected rather
462    /// than clamped) — rotation is how a long-lived credential stays alive, so
463    /// inheriting the old token's remaining life would make it useless. The
464    /// ceiling is what keeps that from being indefinite extension: every hop
465    /// is bounded by the same policy a first mint is, and the caller can only
466    /// ever hold one live token per rotation because the old `jti` is revoked
467    /// by the route immediately after.
468    ///
469    /// [`validate_grant`] still runs over the resulting scopes: a service-only
470    /// scope riding inside a user's token is a data bug wherever it came from,
471    /// and re-signing it would launder it forward.
472    pub fn rotate_api_token(
473        &self,
474        user: PrincipalId,
475        aud: impl Into<String>,
476        held: &[Scope],
477        requested: &[Scope],
478        ttl_seconds: Option<i64>,
479        now: i64,
480    ) -> Result<MintedMcpToken, McpMintError> {
481        if user.kind != PrincipalKind::User {
482            return Err(McpMintError::WrongPrincipalKind {
483                expected: PrincipalKind::User,
484                got: user.kind,
485            });
486        }
487        let aud = aud.into();
488        let ttl = self.api_token_ttl(ttl_seconds)?;
489
490        let mut ceiling: Vec<Scope> = Vec::with_capacity(held.len());
491        for s in held {
492            validate_grant(user.kind, *s)?;
493            if !ceiling.contains(s) {
494                ceiling.push(*s);
495            }
496        }
497
498        let scopes = attenuate(requested, ceiling, &user, &aud)?;
499
500        self.sign_api_token(user, aud, scopes, ttl, now)
501    }
502
503    /// `None` ⇒ the policy default; `Some(n)` outside
504    /// `1..=api_token_max_ttl_seconds` ⇒ [`McpMintError::TtlOutOfRange`].
505    /// Rejected, never clamped — shared by both API-token mint paths so a
506    /// rotation cannot outlive the ceiling a first mint is held to.
507    fn api_token_ttl(&self, ttl_seconds: Option<i64>) -> Result<i64, McpMintError> {
508        match ttl_seconds {
509            None => Ok(self.policy.api_token_default_ttl_seconds),
510            Some(n) if n >= 1 && n <= self.policy.api_token_max_ttl_seconds => Ok(n),
511            Some(n) => Err(McpMintError::TtlOutOfRange {
512                requested: n,
513                max: self.policy.api_token_max_ttl_seconds,
514            }),
515        }
516    }
517
518    /// The last two steps both API-token paths share: build the claim with
519    /// `auth_strength: "api-token"` and sign it. Neither path adds `owns`,
520    /// `camp_id` or `act` — see [`mint_api_token`](Self::mint_api_token) for
521    /// why.
522    fn sign_api_token(
523        &self,
524        user: PrincipalId,
525        aud: String,
526        scopes: Vec<Scope>,
527        ttl: i64,
528        now: i64,
529    ) -> Result<MintedMcpToken, McpMintError> {
530        let claims = McpClaims::new(
531            self.iss.clone(),
532            aud,
533            user,
534            now,
535            now + ttl,
536            generate_jti(),
537            scopes,
538        )
539        .with_auth_strength(AuthStrength::ApiToken);
540
541        let token = self.minter.mint_mcp(&claims, &self.kid)?;
542        Ok(MintedMcpToken { token, claims })
543    }
544
545    /// **Mint path 2** — bootstrapped camp, autonomous (R020-F7).
546    ///
547    /// For yubaba-hosted camps operating without a live user session: the
548    /// camp's bootstrap credential authenticates upstream of this call, and
549    /// the verified camp principal is what arrives here. Returns a token
550    /// bearing
551    /// `{sub: camp:<C>, camp_id: <C>, scope: [...], owns: {...}, auth_strength: "bootstrap"}`
552    /// — note **no `act` claim** on this path, the camp itself is the
553    /// principal (not a user acted-on-by an agent).
554    ///
555    /// Required inputs:
556    ///
557    /// - `camp`: the authenticated camp's principal (kind MUST be
558    ///   [`PrincipalKind::Camp`]). `camp_id` on the resulting claim is the
559    ///   bare id (sans `camp:` prefix), so a consumer doesn't have to re-
560    ///   parse `sub` to learn it.
561    /// - `aud`: the target resource URI for this call. Per composition rule
562    ///   (5) the camp MUST hold at least one grant entry for `aud` — an empty
563    ///   grant list returns [`McpMintError::AudNotEntitled`] before signing.
564    /// - `now`: unix seconds. `iat = now`, `exp = now + policy.access_ttl`.
565    ///
566    /// Same pipeline as [`mint_user_fresh`](Self::mint_user_fresh): grants →
567    /// [`expand_scopes`] → [`validate_grant`] per scope (rule (4) defence —
568    /// catches a bundle that smuggles `ownership:write` into a camp grant) →
569    /// ownership lookup → sign with `mint_mcp`.
570    pub async fn mint_bootstrap(
571        &self,
572        camp: PrincipalId,
573        aud: impl Into<String>,
574        now: i64,
575    ) -> Result<MintedMcpToken, McpMintError> {
576        if camp.kind != PrincipalKind::Camp {
577            return Err(McpMintError::WrongPrincipalKind {
578                expected: PrincipalKind::Camp,
579                got: camp.kind,
580            });
581        }
582        let aud = aud.into();
583
584        let entries = self.grants.list_for(&camp, &aud).await?;
585        if entries.is_empty() {
586            return Err(McpMintError::AudNotEntitled {
587                principal: camp.to_string(),
588                aud,
589            });
590        }
591
592        let scopes = expand_scopes(&self.bundles, &entries).await?;
593        for s in &scopes {
594            validate_grant(camp.kind, *s)?;
595        }
596
597        let rows = self.ownership.list_for_principal(&camp).await?;
598        let owns = rows_to_owns(&rows);
599
600        let camp_id = camp.id.clone();
601        let mut claims = McpClaims::new(
602            self.iss.clone(),
603            aud,
604            camp,
605            now,
606            now + self.policy.access_ttl_seconds,
607            generate_jti(),
608            scopes,
609        )
610        .with_camp_id(camp_id)
611        .with_auth_strength(AuthStrength::Bootstrap);
612        if !owns.is_empty() {
613            claims = claims.with_owns(owns);
614        }
615
616        let token = self.minter.mint_mcp(&claims, &self.kid)?;
617        Ok(MintedMcpToken { token, claims })
618    }
619
620    /// **Mint path 3** — RFC 8693 token-exchange (R020-F8).
621    ///
622    /// The ONLY path that crosses principals: a multi-player camp daemon
623    /// (`subject_token` = camp bootstrap credential) presents a human's
624    /// session token (`actor_token` = user's session) and asks for a token
625    /// attributed to the *human*, with the camp as call context. The result
626    /// looks like a `mint_user_fresh` token — same `auth_strength=user-fresh`
627    /// because the user authenticated locally — but the camp's grants
628    /// constrain it.
629    ///
630    /// Inputs (already-verified principals — credential checks live at the
631    /// HTTP `/token` endpoint, same way passkey assertion sits upstream of
632    /// [`mint_user_fresh`](Self::mint_user_fresh)):
633    ///
634    /// - `user`: the verified user from `actor_token` (kind MUST be
635    ///   [`PrincipalKind::User`]).
636    /// - `camp`: the verified camp from `subject_token` (kind MUST be
637    ///   [`PrincipalKind::Camp`]).
638    /// - `actor`: the optional `act` claim — the agent variant acting on the
639    ///   user's behalf (RFC 8693).
640    /// - `aud`: target resource URI. BOTH principals must hold a grant for
641    ///   it (composition rule (5) applies per side).
642    /// - `requested_scope`: the scopes the exchange asks for. EVERY entry
643    ///   must be in the intersection of the user's expanded-grant scopes AND
644    ///   the camp's expanded-grant scopes for this aud. A requested scope
645    ///   outside the intersection rejects the whole exchange with
646    ///   [`McpMintError::InvalidScope`] — no partial token (RFC 8693 / doc
647    ///   §Mint flows).
648    /// - `now`: unix seconds. `iat = now`, `exp = now + policy.access_ttl`.
649    ///
650    /// Result claim:
651    /// `{sub: user:<U>, act: {sub: agent:<V>}?, camp_id: <C>, scope: [...requested...], owns: {...}, auth_strength: "user-fresh"}`.
652    /// `owns` is the CAMP's ownership (not the user's) — the call is scoped
653    /// to the camp's resources. Audit (R020-F13) captures both legs from the
654    /// resulting claim plus the endpoint's request context.
655    pub async fn mint_token_exchange(
656        &self,
657        user: PrincipalId,
658        camp: PrincipalId,
659        actor: Option<Actor>,
660        aud: impl Into<String>,
661        requested_scope: Vec<Scope>,
662        now: i64,
663    ) -> Result<MintedMcpToken, McpMintError> {
664        if user.kind != PrincipalKind::User {
665            return Err(McpMintError::WrongPrincipalKind {
666                expected: PrincipalKind::User,
667                got: user.kind,
668            });
669        }
670        if camp.kind != PrincipalKind::Camp {
671            return Err(McpMintError::WrongPrincipalKind {
672                expected: PrincipalKind::Camp,
673                got: camp.kind,
674            });
675        }
676        let aud = aud.into();
677
678        // (1) User side — composition rule (5) per side.
679        let user_entries = self.grants.list_for(&user, &aud).await?;
680        if user_entries.is_empty() {
681            return Err(McpMintError::AudNotEntitled {
682                principal: user.to_string(),
683                aud,
684            });
685        }
686        let user_scopes = expand_scopes(&self.bundles, &user_entries).await?;
687        for s in &user_scopes {
688            validate_grant(user.kind, *s)?;
689        }
690
691        // (2) Camp side — same rule (5) check. Even though the result is
692        //     user-attributed, the camp must also be entitled to the
693        //     audience.
694        let camp_entries = self.grants.list_for(&camp, &aud).await?;
695        if camp_entries.is_empty() {
696            return Err(McpMintError::AudNotEntitled {
697                principal: camp.to_string(),
698                aud,
699            });
700        }
701        let camp_scopes = expand_scopes(&self.bundles, &camp_entries).await?;
702        for s in &camp_scopes {
703            validate_grant(camp.kind, *s)?;
704        }
705
706        // (3) RFC 8693 — every requested scope must be in BOTH principals'
707        //     expanded grants. Any miss → reject the WHOLE exchange (not a
708        //     partial token).
709        for s in &requested_scope {
710            if !user_scopes.contains(s) || !camp_scopes.contains(s) {
711                return Err(McpMintError::InvalidScope {
712                    scope: *s,
713                    aud: aud.clone(),
714                });
715            }
716        }
717
718        // (4) `owns` comes from the CAMP — the camp is the resource context,
719        //     even though `sub` is the user.
720        let rows = self.ownership.list_for_principal(&camp).await?;
721        let owns = rows_to_owns(&rows);
722
723        let camp_id = camp.id.clone();
724        let mut claims = McpClaims::new(
725            self.iss.clone(),
726            aud,
727            user,
728            now,
729            now + self.policy.access_ttl_seconds,
730            generate_jti(),
731            requested_scope,
732        )
733        .with_camp_id(camp_id)
734        .with_auth_strength(AuthStrength::UserFresh);
735        if let Some(a) = actor {
736            claims = claims.with_act(a);
737        }
738        if !owns.is_empty() {
739            claims = claims.with_owns(owns);
740        }
741
742        let token = self.minter.mint_mcp(&claims, &self.kid)?;
743        Ok(MintedMcpToken { token, claims })
744    }
745}
746
747/// Intersect what a caller *asked* for with the `ceiling` they are entitled
748/// to, or take the whole ceiling when they asked for nothing.
749///
750/// Shared by both API-token mint paths (R728-F2) — they differ only in where
751/// the ceiling comes from (the grant table on create, the presented token's
752/// own claims on rotate), and the intersection rule must not differ at all.
753/// An unheld scope is [`McpMintError::UnentitledScopes`] naming **every**
754/// offender: a token silently narrower than the one requested is a debugging
755/// trap that surfaces days later as an unexplained 403.
756fn attenuate(
757    requested: &[Scope],
758    ceiling: Vec<Scope>,
759    user: &PrincipalId,
760    aud: &str,
761) -> Result<Vec<Scope>, McpMintError> {
762    if requested.is_empty() {
763        return Ok(ceiling);
764    }
765    let unentitled: Vec<Scope> = requested
766        .iter()
767        .filter(|s| !ceiling.contains(s))
768        .copied()
769        .collect();
770    if !unentitled.is_empty() {
771        return Err(McpMintError::UnentitledScopes {
772            principal: user.to_string(),
773            scopes: unentitled,
774            aud: aud.to_owned(),
775        });
776    }
777    // Dedupe while preserving the caller's order — `requested` is client
778    // input and may repeat.
779    let mut out: Vec<Scope> = Vec::with_capacity(requested.len());
780    for s in requested {
781        if !out.contains(s) {
782            out.push(*s);
783        }
784    }
785    Ok(out)
786}
787
788/// Convert ownership rows into the [`Owns`] claim shape. Revoked rows are
789/// filtered out (the store already excludes them, but a belt-and-braces
790/// check keeps the wire shape from leaking stale entries if the store
791/// contract evolves). Unknown resource kinds spill into
792/// [`Owns::extra`](cheers_core::Owns).
793///
794/// Duplicate `(kind, resource_id)` pairs collapse to one entry — ownership
795/// is set-membership, and while `POST /ownership` is idempotent for
796/// identical live rows, historical duplicates (or the residual
797/// concurrent-write race) may still exist in a store. The claim shape must
798/// not amplify them onto every minted token.
799fn rows_to_owns(rows: &[OwnershipRow]) -> Owns {
800    fn push_unique(list: &mut Vec<String>, id: &str) {
801        if !list.iter().any(|existing| existing == id) {
802            list.push(id.to_owned());
803        }
804    }
805    let mut o = Owns::default();
806    for r in rows {
807        if r.is_revoked() {
808            continue;
809        }
810        match r.resource_kind.as_str() {
811            "service" => push_unique(&mut o.service, &r.resource_id),
812            "arch_doc" => push_unique(&mut o.arch_doc, &r.resource_id),
813            "node" => push_unique(&mut o.node, &r.resource_id),
814            other => push_unique(
815                o.extra.entry(other.to_owned()).or_default(),
816                &r.resource_id,
817            ),
818        }
819    }
820    o
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use crate::bundles::{BundleName, MemoryBundleStore, ScopeOrBundle};
827    use crate::codec::PasetoV4SecretMinter;
828    use crate::grants::MemoryGrantStore;
829    use crate::ownership::{NewOwnership, OwnershipRow, OwnershipStore};
830    use async_trait::async_trait;
831    use cheers_core::{Scope, StoreError};
832    use cheers_verify::PasetoV4PublicVerifier;
833    use std::sync::Mutex;
834
835    // ---- in-memory OwnershipStore (test-only) ------------------------------
836    //
837    // cheers-sqlx ships the persistent impls; cheers-server tests only need
838    // a process-local one. Lives here rather than in ownership.rs because
839    // F4 deliberately kept that module impl-free for the trait surface.
840
841    #[derive(Default)]
842    struct MemOwnershipStore {
843        rows: Mutex<Vec<OwnershipRow>>,
844        next_id: Mutex<u64>,
845    }
846
847    impl MemOwnershipStore {
848        fn new() -> Self {
849            Self::default()
850        }
851    }
852
853    #[async_trait]
854    impl OwnershipStore for MemOwnershipStore {
855        async fn insert(
856            &self,
857            ownership: &NewOwnership,
858            now: i64,
859        ) -> Result<OwnershipRow, StoreError> {
860            let mut id_g = self.next_id.lock().unwrap();
861            *id_g += 1;
862            let id = format!("row-{}", *id_g);
863            drop(id_g);
864            let row = OwnershipRow::new(
865                id,
866                ownership.principal_id.clone(),
867                ownership.resource_kind.clone(),
868                ownership.resource_id.clone(),
869                ownership.relationship.clone(),
870                ownership.granted_by.clone(),
871                ownership.on_behalf_of.clone(),
872                now,
873                None,
874            );
875            self.rows.lock().unwrap().push(row.clone());
876            Ok(row)
877        }
878
879        async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError> {
880            Ok(self.rows.lock().unwrap().iter().find(|r| r.id == id).cloned())
881        }
882
883        async fn revoke_by_id(&self, id: &str, now: i64) -> Result<(), StoreError> {
884            let mut g = self.rows.lock().unwrap();
885            let row = g
886                .iter_mut()
887                .find(|r| r.id == id)
888                .ok_or(StoreError::NotFound)?;
889            if row.revoked_at.is_none() {
890                row.revoked_at = Some(now);
891            }
892            Ok(())
893        }
894
895        async fn revoke_by_on_behalf_of(
896            &self,
897            user: &PrincipalId,
898            now: i64,
899        ) -> Result<u64, StoreError> {
900            let mut count = 0u64;
901            for r in self.rows.lock().unwrap().iter_mut() {
902                if r.revoked_at.is_none() && r.on_behalf_of.as_ref() == Some(user) {
903                    r.revoked_at = Some(now);
904                    count += 1;
905                }
906            }
907            Ok(count)
908        }
909
910        async fn list_for_principal(
911            &self,
912            principal: &PrincipalId,
913        ) -> Result<Vec<OwnershipRow>, StoreError> {
914            Ok(self
915                .rows
916                .lock()
917                .unwrap()
918                .iter()
919                .filter(|r| r.revoked_at.is_none() && r.principal_id == *principal)
920                .cloned()
921                .collect())
922        }
923
924        async fn list_for_resource(
925            &self,
926            resource_kind: &str,
927            resource_id: &str,
928        ) -> Result<Vec<OwnershipRow>, StoreError> {
929            Ok(self
930                .rows
931                .lock()
932                .unwrap()
933                .iter()
934                .filter(|r| {
935                    r.revoked_at.is_none()
936                        && r.resource_kind == resource_kind
937                        && r.resource_id == resource_id
938                })
939                .cloned()
940                .collect())
941        }
942    }
943
944    // ---- assembly ----------------------------------------------------------
945
946    /// `kid` this test rig's authority stamps into every mint — tests that
947    /// verify a minted token must pass the same value to `verify_mcp_at`.
948    const RIG_KID: &str = "mcp-authority-test-kid-1";
949
950    fn rig() -> (
951        McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>,
952        PasetoV4PublicVerifier,
953    ) {
954        let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
955        let bundles = MemoryBundleStore::with_defaults();
956        let grants = MemoryGrantStore::new();
957        let ownership = MemOwnershipStore::new();
958        let authority = McpAuthority::new(
959            minter,
960            bundles,
961            grants,
962            ownership,
963            "https://cheers.example",
964            RIG_KID,
965        );
966        (authority, verifier)
967    }
968
969    fn put_simple_grant(authority: &McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>, principal: PrincipalId, aud: &str, entries: Vec<ScopeOrBundle>) {
970        authority.grants.put(principal, aud, entries);
971    }
972
973    // ---- McpPolicy ---------------------------------------------------------
974
975    #[test]
976    fn mcp_policy_default_is_ten_minutes() {
977        let p = McpPolicy::default();
978        assert_eq!(p.access_ttl_seconds, 10 * 60);
979        let p = McpPolicy::new(300);
980        assert_eq!(p.access_ttl_seconds, 300);
981        let p = McpPolicy::default().with_access_ttl(5 * 60);
982        assert_eq!(p.access_ttl_seconds, 5 * 60);
983    }
984
985    // ---- mint_user_fresh: success paths ------------------------------------
986
987    #[test]
988    fn mint_user_fresh_signs_token_verifiable_at_edge() {
989        let (authority, verifier) = rig();
990        let user = PrincipalId::user("alice");
991        let aud = "https://kamaji.camp.example";
992        put_simple_grant(
993            &authority,
994            user.clone(),
995            aud,
996            vec![ScopeOrBundle::Scope(Scope::CloudDeploy), ScopeOrBundle::Scope(Scope::CloudRead)],
997        );
998
999        pollster::block_on(async {
1000            let minted = authority
1001                .mint_user_fresh(user.clone(), None, None, aud, 1_000)
1002                .await
1003                .unwrap();
1004
1005            // Token starts with v4.public — the doc-pinned envelope.
1006            assert!(minted.token.starts_with("v4.public."));
1007            // auth_strength is user-fresh on this path.
1008            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
1009            // jti is non-empty + iat/exp respect the default TTL (10 min).
1010            assert!(!minted.claims.jti.is_empty());
1011            assert_eq!(minted.claims.iat, 1_000);
1012            assert_eq!(
1013                minted.claims.exp,
1014                1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1015            );
1016            // Edge verifies the freshly minted token under the public key.
1017            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1018            assert_eq!(back, minted.claims);
1019            assert_eq!(back.sub, user);
1020            assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
1021        });
1022    }
1023
1024    #[test]
1025    fn mint_user_fresh_expands_bundles_at_mint_time() {
1026        // A grant of `{"bundle":"deploy-admin"}` lands on the wire as the
1027        // literal scope list (rule (2)). Edit the bundle, re-mint, see the
1028        // change without rewriting the grant.
1029        let (authority, _verifier) = rig();
1030        let user = PrincipalId::user("bob");
1031        let aud = "https://kamaji.example";
1032        put_simple_grant(
1033            &authority,
1034            user.clone(),
1035            aud,
1036            vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
1037        );
1038
1039        pollster::block_on(async {
1040            let first = authority
1041                .mint_user_fresh(user.clone(), None, None, aud, 1_000)
1042                .await
1043                .unwrap();
1044            assert!(first.claims.scope.contains(&Scope::CloudDestroy));
1045
1046            // Edit the bundle: drop CloudDestroy. Grant is untouched.
1047            authority
1048                .bundles
1049                .put(
1050                    &BundleName::new("deploy-admin"),
1051                    &[Scope::CloudRead, Scope::CloudDeploy],
1052                )
1053                .await
1054                .unwrap();
1055
1056            let second = authority
1057                .mint_user_fresh(user, None, None, aud, 1_100)
1058                .await
1059                .unwrap();
1060            assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
1061            assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
1062        });
1063    }
1064
1065    #[test]
1066    fn mint_user_fresh_populates_act_camp_id_and_owns() {
1067        let (authority, verifier) = rig();
1068        let user = PrincipalId::user("carol");
1069        let camp_id = "camp-xyz";
1070        let aud = "https://aud.example";
1071        put_simple_grant(
1072            &authority,
1073            user.clone(),
1074            aud,
1075            vec![ScopeOrBundle::Scope(Scope::CampRead)],
1076        );
1077        // Seed two owned resources under the camp principal.
1078        pollster::block_on(async {
1079            let svc = NewOwnership::new(
1080                PrincipalId::camp(camp_id),
1081                "service",
1082                "svc-a",
1083                "owns",
1084                PrincipalId::service("yubaba"),
1085                Some(user.clone()),
1086            )
1087            .unwrap();
1088            authority.ownership.insert(&svc, 500).await.unwrap();
1089
1090            let doc = NewOwnership::new(
1091                PrincipalId::camp(camp_id),
1092                "arch_doc",
1093                "doc-1",
1094                "owns",
1095                PrincipalId::service("yubaba"),
1096                Some(user.clone()),
1097            )
1098            .unwrap();
1099            authority.ownership.insert(&doc, 500).await.unwrap();
1100
1101            let minted = authority
1102                .mint_user_fresh(
1103                    user.clone(),
1104                    Some(Actor::new(PrincipalId::service("agent-claude"))),
1105                    Some(camp_id.to_owned()),
1106                    aud,
1107                    1_000,
1108                )
1109                .await
1110                .unwrap();
1111
1112            assert_eq!(
1113                minted.claims.act.as_ref().unwrap().sub,
1114                PrincipalId::service("agent-claude")
1115            );
1116            assert_eq!(minted.claims.camp_id.as_deref(), Some(camp_id));
1117            assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
1118            assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
1119
1120            // Edge accepts it.
1121            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1122            assert_eq!(back, minted.claims);
1123        });
1124    }
1125
1126    #[test]
1127    fn mint_user_fresh_omits_owns_when_camp_owns_nothing() {
1128        // No ownership rows + camp_id present → owns is empty/omitted on the
1129        // wire. The claim skip_serializing_if guards this; the test pins it.
1130        let (authority, _verifier) = rig();
1131        let user = PrincipalId::user("dan");
1132        let aud = "https://aud.example";
1133        put_simple_grant(
1134            &authority,
1135            user.clone(),
1136            aud,
1137            vec![ScopeOrBundle::Scope(Scope::CampRead)],
1138        );
1139
1140        pollster::block_on(async {
1141            let minted = authority
1142                .mint_user_fresh(user, None, Some("camp-empty".into()), aud, 1_000)
1143                .await
1144                .unwrap();
1145            assert!(minted.claims.owns.is_empty());
1146            let json = serde_json::to_string(&minted.claims).unwrap();
1147            assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
1148        });
1149    }
1150
1151    // ---- mint_user_fresh: rejection paths ----------------------------------
1152
1153    #[test]
1154    fn mint_user_fresh_rejects_unentitled_aud() {
1155        let (authority, _verifier) = rig();
1156        let user = PrincipalId::user("eve");
1157        // No grant placed for any aud.
1158        pollster::block_on(async {
1159            let err = authority
1160                .mint_user_fresh(user, None, None, "https://aud.example", 1_000)
1161                .await
1162                .unwrap_err();
1163            match err {
1164                McpMintError::AudNotEntitled { principal, aud } => {
1165                    assert_eq!(principal, "user:eve");
1166                    assert_eq!(aud, "https://aud.example");
1167                }
1168                other => panic!("expected AudNotEntitled, got {other:?}"),
1169            }
1170        });
1171    }
1172
1173    #[test]
1174    fn mint_user_fresh_rejects_non_user_principal() {
1175        let (authority, _verifier) = rig();
1176        pollster::block_on(async {
1177            let err = authority
1178                .mint_user_fresh(
1179                    PrincipalId::service("yubaba"),
1180                    None,
1181                    None,
1182                    "https://aud.example",
1183                    1_000,
1184                )
1185                .await
1186                .unwrap_err();
1187            match err {
1188                McpMintError::WrongPrincipalKind { expected, got } => {
1189                    assert_eq!(expected, PrincipalKind::User);
1190                    assert_eq!(got, PrincipalKind::Service);
1191                }
1192                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1193            }
1194        });
1195    }
1196
1197    // ---- mint_api_token (R728-F1) ------------------------------------------
1198
1199    #[test]
1200    fn mint_api_token_defaults_to_ninety_days_and_the_full_effective_scope_set() {
1201        let (authority, verifier) = rig();
1202        let user = PrincipalId::user("alice");
1203        let aud = "https://kamaji.example";
1204        put_simple_grant(
1205            &authority,
1206            user.clone(),
1207            aud,
1208            vec![
1209                ScopeOrBundle::Scope(Scope::CloudRead),
1210                ScopeOrBundle::Scope(Scope::CloudDeploy),
1211            ],
1212        );
1213
1214        pollster::block_on(async {
1215            let minted = authority
1216                .mint_api_token(user.clone(), aud, &[], None, 1_000)
1217                .await
1218                .unwrap();
1219            assert_eq!(
1220                minted.claims.exp,
1221                1_000 + McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
1222            );
1223            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
1224            assert_eq!(minted.claims.sub, user);
1225            // Empty `requested` = everything held for this aud, frozen now.
1226            assert_eq!(
1227                minted.claims.scope,
1228                vec![Scope::CloudRead, Scope::CloudDeploy]
1229            );
1230            // Same signing key, same envelope — it verifies at the ordinary
1231            // edge with no new verification path.
1232            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1233            assert_eq!(back, minted.claims);
1234        });
1235    }
1236
1237    /// The whole point: a PAT is never wider than its minter. Asking for a
1238    /// subset yields exactly that subset.
1239    #[test]
1240    fn mint_api_token_attenuates_to_the_requested_subset() {
1241        let (authority, _verifier) = rig();
1242        let user = PrincipalId::user("alice");
1243        let aud = "https://kamaji.example";
1244        put_simple_grant(
1245            &authority,
1246            user.clone(),
1247            aud,
1248            vec![
1249                ScopeOrBundle::Scope(Scope::CloudRead),
1250                ScopeOrBundle::Scope(Scope::CloudDeploy),
1251                ScopeOrBundle::Scope(Scope::CloudDestroy),
1252            ],
1253        );
1254
1255        pollster::block_on(async {
1256            let minted = authority
1257                .mint_api_token(user, aud, &[Scope::CloudRead], None, 1_000)
1258                .await
1259                .unwrap();
1260            assert_eq!(minted.claims.scope, vec![Scope::CloudRead]);
1261        });
1262    }
1263
1264    /// A scope the caller does not hold is NAMED, not silently dropped. A
1265    /// token quietly weaker than the one asked for fails days later somewhere
1266    /// else as an unexplained 403.
1267    #[test]
1268    fn mint_api_token_names_every_unentitled_scope_instead_of_dropping_it() {
1269        let (authority, _verifier) = rig();
1270        let user = PrincipalId::user("alice");
1271        let aud = "https://kamaji.example";
1272        put_simple_grant(
1273            &authority,
1274            user.clone(),
1275            aud,
1276            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1277        );
1278
1279        pollster::block_on(async {
1280            let err = authority
1281                .mint_api_token(
1282                    user,
1283                    aud,
1284                    &[Scope::CloudRead, Scope::CloudDestroy, Scope::CampAdmin],
1285                    None,
1286                    1_000,
1287                )
1288                .await
1289                .unwrap_err();
1290            match err {
1291                McpMintError::UnentitledScopes { ref scopes, .. } => {
1292                    assert_eq!(scopes, &vec![Scope::CloudDestroy, Scope::CampAdmin]);
1293                    let msg = err.to_string();
1294                    assert!(msg.contains("cloud:destroy"), "{msg}");
1295                    assert!(msg.contains("camp:admin"), "{msg}");
1296                }
1297                other => panic!("expected UnentitledScopes, got {other:?}"),
1298            }
1299        });
1300    }
1301
1302    /// Composition rule (4) defence in depth applies to this path too: a
1303    /// bundle smuggling a service-only scope into a user grant is caught
1304    /// BEFORE signing, exactly as on `mint_user_fresh`.
1305    #[test]
1306    fn mint_api_token_rejects_service_only_scope_smuggled_via_bundle() {
1307        let (authority, _verifier) = rig();
1308        let user = PrincipalId::user("mallory");
1309        let aud = "https://aud.example";
1310
1311        pollster::block_on(async {
1312            authority
1313                .bundles
1314                .put(
1315                    &BundleName::new("dangerous"),
1316                    &[Scope::CampRead, Scope::OwnershipWrite],
1317                )
1318                .await
1319                .unwrap();
1320            authority.grants.put(
1321                user.clone(),
1322                aud,
1323                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1324            );
1325            match authority.mint_api_token(user, aud, &[], None, 1_000).await {
1326                Err(McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope {
1327                    scope,
1328                    kind,
1329                })) => {
1330                    assert_eq!(scope, Scope::OwnershipWrite);
1331                    assert_eq!(kind, PrincipalKind::User);
1332                }
1333                other => panic!("expected GrantMisconfigured, got {other:?}"),
1334            }
1335        });
1336    }
1337
1338    #[test]
1339    fn mint_api_token_rejects_an_out_of_range_ttl_rather_than_clamping() {
1340        let (authority, _verifier) = rig();
1341        let user = PrincipalId::user("alice");
1342        let aud = "https://kamaji.example";
1343        put_simple_grant(
1344            &authority,
1345            user.clone(),
1346            aud,
1347            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1348        );
1349
1350        pollster::block_on(async {
1351            for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
1352                match authority
1353                    .mint_api_token(user.clone(), aud, &[], Some(bad), 1_000)
1354                    .await
1355                {
1356                    Err(McpMintError::TtlOutOfRange { requested, max }) => {
1357                        assert_eq!(requested, bad);
1358                        assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
1359                    }
1360                    other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
1361                }
1362            }
1363            // The ceiling itself is accepted — the range is inclusive.
1364            let ok = authority
1365                .mint_api_token(
1366                    user,
1367                    aud,
1368                    &[],
1369                    Some(McpPolicy::MAX_API_TOKEN_TTL_SECONDS),
1370                    1_000,
1371                )
1372                .await
1373                .unwrap();
1374            assert_eq!(
1375                ok.claims.exp,
1376                1_000 + McpPolicy::MAX_API_TOKEN_TTL_SECONDS
1377            );
1378        });
1379    }
1380
1381    #[test]
1382    fn mint_api_token_rejects_an_unentitled_aud_before_signing() {
1383        let (authority, _verifier) = rig();
1384        let user = PrincipalId::user("alice");
1385        pollster::block_on(async {
1386            match authority
1387                .mint_api_token(user, "https://nothing-granted.example", &[], None, 1_000)
1388                .await
1389            {
1390                Err(McpMintError::AudNotEntitled { principal, aud }) => {
1391                    assert_eq!(principal, "user:alice");
1392                    assert_eq!(aud, "https://nothing-granted.example");
1393                }
1394                other => panic!("expected AudNotEntitled, got {other:?}"),
1395            }
1396        });
1397    }
1398
1399    /// `owns` is a mint-time snapshot whose staleness bound is the token's
1400    /// lifetime. At 10 minutes that is fine; at 90 days it would be a
1401    /// stale-authorization hole, so a PAT carries none — nor a `camp_id`
1402    /// (whose job is keying that lookup) nor an `act` (nobody is acting on
1403    /// the user's behalf; they minted it themselves).
1404    #[test]
1405    fn mint_api_token_carries_no_owns_no_camp_id_and_no_act() {
1406        let (authority, _verifier) = rig();
1407        let user = PrincipalId::user("alice");
1408        let aud = "https://kamaji.example";
1409        put_simple_grant(
1410            &authority,
1411            user.clone(),
1412            aud,
1413            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1414        );
1415
1416        pollster::block_on(async {
1417            let minted = authority
1418                .mint_api_token(user, aud, &[], None, 1_000)
1419                .await
1420                .unwrap();
1421            assert!(minted.claims.owns.is_empty());
1422            assert_eq!(minted.claims.camp_id, None);
1423            assert_eq!(minted.claims.act, None);
1424        });
1425    }
1426
1427    #[test]
1428    fn mint_api_token_rejects_a_non_user_principal() {
1429        let (authority, _verifier) = rig();
1430        pollster::block_on(async {
1431            match authority
1432                .mint_api_token(PrincipalId::camp("c1"), "https://aud.example", &[], None, 1)
1433                .await
1434            {
1435                Err(McpMintError::WrongPrincipalKind { expected, got }) => {
1436                    assert_eq!(expected, PrincipalKind::User);
1437                    assert_eq!(got, PrincipalKind::Camp);
1438                }
1439                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1440            }
1441        });
1442    }
1443
1444    // ---- rotate_api_token (R728-F2) ----------------------------------------
1445
1446    /// The whole point of the rotate path: the ceiling is the presented
1447    /// token's own scopes, so an EMPTY grant table still rolls a token.
1448    ///
1449    /// Stated as a contrast, because the contrast IS the property — the same
1450    /// inputs through `mint_api_token` are an `AudNotEntitled` 403. If this
1451    /// test ever starts needing a grant, someone has put a `grants.list_for`
1452    /// back on the rotate path and a frozen token can silently re-widen
1453    /// itself to whatever the grant table says today.
1454    #[test]
1455    fn rotate_api_token_mints_from_the_presented_scopes_without_reading_grants() {
1456        let (authority, verifier) = rig();
1457        let user = PrincipalId::user("alice");
1458        let aud = "https://kamaji.example";
1459        // No grant, deliberately.
1460
1461        let minted = authority
1462            .rotate_api_token(
1463                user.clone(),
1464                aud,
1465                &[Scope::CloudRead, Scope::CloudDeploy],
1466                &[],
1467                None,
1468                1_000,
1469            )
1470            .unwrap();
1471        assert_eq!(minted.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
1472        assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
1473        assert_eq!(minted.claims.sub, user.clone());
1474        assert_eq!(minted.claims.aud, aud);
1475        assert!(minted.claims.owns.is_empty());
1476        // A real token on the same stack, verifiable by the ordinary verifier.
1477        let claims = verifier.verify_mcp_at(&minted.token, 1_001, RIG_KID).unwrap();
1478        assert_eq!(claims.jti, minted.claims.jti);
1479
1480        // And the contrast: the same call through the create path is refused.
1481        pollster::block_on(async {
1482            assert!(matches!(
1483                authority.mint_api_token(user, aud, &[], None, 1_000).await,
1484                Err(McpMintError::AudNotEntitled { .. })
1485            ));
1486        });
1487    }
1488
1489    #[test]
1490    fn rotate_api_token_can_narrow_but_never_widen() {
1491        let (authority, _verifier) = rig();
1492        let user = PrincipalId::user("alice");
1493        let aud = "https://kamaji.example";
1494        // A wide grant table is present and must be ignored.
1495        put_simple_grant(
1496            &authority,
1497            user.clone(),
1498            aud,
1499            vec![
1500                ScopeOrBundle::Scope(Scope::CloudRead),
1501                ScopeOrBundle::Scope(Scope::CloudDeploy),
1502                ScopeOrBundle::Scope(Scope::CloudDestroy),
1503            ],
1504        );
1505
1506        // Narrowing works.
1507        let narrowed = authority
1508            .rotate_api_token(
1509                user.clone(),
1510                aud,
1511                &[Scope::CloudRead, Scope::CloudDeploy],
1512                &[Scope::CloudRead],
1513                None,
1514                1_000,
1515            )
1516            .unwrap();
1517        assert_eq!(narrowed.claims.scope, vec![Scope::CloudRead]);
1518
1519        // Widening — to a scope the GRANT holds but the token does not — is
1520        // refused by name.
1521        match authority.rotate_api_token(
1522            user,
1523            aud,
1524            &[Scope::CloudRead],
1525            &[Scope::CloudDestroy],
1526            None,
1527            1_000,
1528        ) {
1529            Err(McpMintError::UnentitledScopes { scopes, .. }) => {
1530                assert_eq!(scopes, vec![Scope::CloudDestroy]);
1531            }
1532            other => panic!("expected UnentitledScopes, got {other:?}"),
1533        }
1534    }
1535
1536    /// Rotation refreshes the clock — that is what keeps a long-lived
1537    /// credential alive — but under the SAME ceiling a first mint is held to,
1538    /// so repeated hops cannot extend authority indefinitely.
1539    #[test]
1540    fn rotate_api_token_gets_a_fresh_ttl_under_the_same_ceiling() {
1541        let (authority, _verifier) = rig();
1542        let user = PrincipalId::user("alice");
1543        let aud = "https://kamaji.example";
1544
1545        let default_ttl = authority
1546            .rotate_api_token(user.clone(), aud, &[Scope::CloudRead], &[], None, 5_000)
1547            .unwrap();
1548        assert_eq!(
1549            default_ttl.claims.exp - default_ttl.claims.iat,
1550            McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
1551        );
1552        assert_eq!(default_ttl.claims.iat, 5_000, "the clock restarts at `now`");
1553
1554        for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
1555            match authority.rotate_api_token(
1556                user.clone(),
1557                aud,
1558                &[Scope::CloudRead],
1559                &[],
1560                Some(bad),
1561                5_000,
1562            ) {
1563                Err(McpMintError::TtlOutOfRange { requested, max }) => {
1564                    assert_eq!(requested, bad);
1565                    assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
1566                }
1567                other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
1568            }
1569        }
1570    }
1571
1572    /// Composition rule (4) is not laundered by a re-sign: a service-only
1573    /// scope sitting in a user's token is refused on the way out, wherever it
1574    /// came from.
1575    #[test]
1576    fn rotate_api_token_rejects_a_service_only_scope_in_the_presented_token() {
1577        let (authority, _verifier) = rig();
1578        assert!(matches!(
1579            authority.rotate_api_token(
1580                PrincipalId::user("alice"),
1581                "https://kamaji.example",
1582                &[Scope::OwnershipWrite],
1583                &[],
1584                None,
1585                1_000,
1586            ),
1587            Err(McpMintError::GrantMisconfigured(_))
1588        ));
1589    }
1590
1591    #[test]
1592    fn rotate_api_token_rejects_a_non_user_principal() {
1593        let (authority, _verifier) = rig();
1594        match authority.rotate_api_token(
1595            PrincipalId::camp("c1"),
1596            "https://aud.example",
1597            &[Scope::CloudRead],
1598            &[],
1599            None,
1600            1,
1601        ) {
1602            Err(McpMintError::WrongPrincipalKind { expected, got }) => {
1603                assert_eq!(expected, PrincipalKind::User);
1604                assert_eq!(got, PrincipalKind::Camp);
1605            }
1606            other => panic!("expected WrongPrincipalKind, got {other:?}"),
1607        }
1608    }
1609
1610    #[test]
1611    fn mint_user_fresh_rejects_service_only_scope_smuggled_via_bundle() {
1612        // Defence in depth for composition rule (4): if a bundle granted to
1613        // a User principal contains ownership:write, the mint path catches
1614        // it via validate_grant BEFORE signing — the misconfigured bundle
1615        // never becomes a mintable token.
1616        let (authority, _verifier) = rig();
1617        let user = PrincipalId::user("mallory");
1618        let aud = "https://aud.example";
1619
1620        pollster::block_on(async {
1621            authority
1622                .bundles
1623                .put(
1624                    &BundleName::new("dangerous"),
1625                    &[Scope::CampRead, Scope::OwnershipWrite],
1626                )
1627                .await
1628                .unwrap();
1629            authority.grants.put(
1630                user.clone(),
1631                aud,
1632                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1633            );
1634
1635            let err = authority
1636                .mint_user_fresh(user, None, None, aud, 1_000)
1637                .await
1638                .unwrap_err();
1639            match err {
1640                McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1641                    assert_eq!(scope, Scope::OwnershipWrite);
1642                    assert_eq!(kind, PrincipalKind::User);
1643                }
1644                other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1645            }
1646        });
1647    }
1648
1649    // ---- mint_bootstrap: success paths -------------------------------------
1650
1651    #[test]
1652    fn mint_bootstrap_signs_token_verifiable_at_edge() {
1653        let (authority, verifier) = rig();
1654        let camp = PrincipalId::camp("c-xyz");
1655        let aud = "https://kamaji.camp.example";
1656        put_simple_grant(
1657            &authority,
1658            camp.clone(),
1659            aud,
1660            vec![
1661                ScopeOrBundle::Scope(Scope::CloudDeploy),
1662                ScopeOrBundle::Scope(Scope::CloudRead),
1663            ],
1664        );
1665
1666        pollster::block_on(async {
1667            let minted = authority
1668                .mint_bootstrap(camp.clone(), aud, 1_000)
1669                .await
1670                .unwrap();
1671
1672            // Same v4.public envelope as mint path 1.
1673            assert!(minted.token.starts_with("v4.public."));
1674            // auth_strength is bootstrap on this path.
1675            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::Bootstrap));
1676            // sub is the camp principal; camp_id is the bare id (no prefix) so
1677            // a consumer doesn't have to re-parse sub.
1678            assert_eq!(minted.claims.sub, camp);
1679            assert_eq!(minted.claims.camp_id.as_deref(), Some("c-xyz"));
1680            // No act claim — bootstrap is the camp acting as itself, not a
1681            // user acted-on-by an agent.
1682            assert!(minted.claims.act.is_none());
1683            assert!(!minted.claims.jti.is_empty());
1684            assert_eq!(minted.claims.iat, 1_000);
1685            assert_eq!(
1686                minted.claims.exp,
1687                1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1688            );
1689
1690            // Edge verifies the freshly minted token under the public key —
1691            // no per-call cheers round trip.
1692            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1693            assert_eq!(back, minted.claims);
1694            assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
1695        });
1696    }
1697
1698    #[test]
1699    fn mint_bootstrap_owns_reflects_ownership_table_state() {
1700        let (authority, _verifier) = rig();
1701        let camp = PrincipalId::camp("c-with-owns");
1702        let user = PrincipalId::user("u-on-behalf");
1703        let aud = "https://aud.example";
1704        put_simple_grant(
1705            &authority,
1706            camp.clone(),
1707            aud,
1708            vec![ScopeOrBundle::Scope(Scope::CampRead)],
1709        );
1710
1711        pollster::block_on(async {
1712            // Seed two owned resources under the camp principal — the camp is
1713            // the holder, the user is the human on whose behalf the grant
1714            // happened (W159 §audit / on_behalf_of).
1715            let svc = NewOwnership::new(
1716                camp.clone(),
1717                "service",
1718                "svc-a",
1719                "owns",
1720                PrincipalId::service("yubaba"),
1721                Some(user.clone()),
1722            )
1723            .unwrap();
1724            authority.ownership.insert(&svc, 500).await.unwrap();
1725
1726            let doc = NewOwnership::new(
1727                camp.clone(),
1728                "arch_doc",
1729                "doc-1",
1730                "owns",
1731                PrincipalId::service("yubaba"),
1732                Some(user.clone()),
1733            )
1734            .unwrap();
1735            authority.ownership.insert(&doc, 500).await.unwrap();
1736
1737            let minted = authority
1738                .mint_bootstrap(camp.clone(), aud, 1_000)
1739                .await
1740                .unwrap();
1741
1742            assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
1743            assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
1744        });
1745    }
1746
1747    #[test]
1748    fn rows_to_owns_dedups_duplicate_resource_ids() {
1749        // Historical duplicate rows (pre-idempotent-create stores, or the
1750        // residual concurrent-write race) must collapse to one entry per
1751        // (kind, resource_id) — the owns claim is set-membership.
1752        let camp = PrincipalId::camp("c-dup");
1753        let svc = PrincipalId::service("yubaba");
1754        let mk = |id: &str, kind: &str, rid: &str| {
1755            OwnershipRow::new(
1756                id.to_owned(),
1757                camp.clone(),
1758                kind.to_owned(),
1759                rid.to_owned(),
1760                "owns".to_owned(),
1761                svc.clone(),
1762                None,
1763                500,
1764                None,
1765            )
1766        };
1767        let rows = vec![
1768            mk("row-1", "service", "svc-a"),
1769            mk("row-2", "service", "svc-a"), // duplicate
1770            mk("row-3", "service", "svc-b"),
1771            mk("row-4", "node", "aa11"),
1772            mk("row-5", "node", "aa11"), // duplicate
1773            mk("row-6", "pond", "p-1"),
1774            mk("row-7", "pond", "p-1"), // duplicate in the extra spill
1775        ];
1776
1777        let owns = rows_to_owns(&rows);
1778        assert_eq!(owns.service, vec!["svc-a".to_string(), "svc-b".to_string()]);
1779        assert_eq!(owns.node, vec!["aa11".to_string()]);
1780        assert_eq!(
1781            owns.extra.get("pond"),
1782            Some(&vec!["p-1".to_string()])
1783        );
1784    }
1785
1786    #[test]
1787    fn mint_bootstrap_omits_owns_when_camp_owns_nothing() {
1788        // No ownership rows → owns is empty and omitted on the wire. Same
1789        // skip_serializing_if guard as mint path 1, pinned independently here
1790        // because the bootstrap mint path always sets camp_id (a token whose
1791        // sub is `camp:<C>` is always scoped to that camp).
1792        let (authority, _verifier) = rig();
1793        let camp = PrincipalId::camp("c-empty");
1794        let aud = "https://aud.example";
1795        put_simple_grant(
1796            &authority,
1797            camp.clone(),
1798            aud,
1799            vec![ScopeOrBundle::Scope(Scope::CampRead)],
1800        );
1801
1802        pollster::block_on(async {
1803            let minted = authority
1804                .mint_bootstrap(camp, aud, 1_000)
1805                .await
1806                .unwrap();
1807            assert!(minted.claims.owns.is_empty());
1808            let json = serde_json::to_string(&minted.claims).unwrap();
1809            assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
1810            // camp_id is still present even when owns is empty.
1811            assert!(json.contains("\"camp_id\":\"c-empty\""), "camp_id must remain: {json}");
1812        });
1813    }
1814
1815    #[test]
1816    fn mint_bootstrap_expands_bundles_at_mint_time() {
1817        // Same propagation property as F5/F6: a bundle edit shows up on the
1818        // next mint without rewriting the camp's grant.
1819        let (authority, _verifier) = rig();
1820        let camp = PrincipalId::camp("c-deploy");
1821        let aud = "https://aud.example";
1822        put_simple_grant(
1823            &authority,
1824            camp.clone(),
1825            aud,
1826            vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
1827        );
1828
1829        pollster::block_on(async {
1830            let first = authority
1831                .mint_bootstrap(camp.clone(), aud, 1_000)
1832                .await
1833                .unwrap();
1834            assert!(first.claims.scope.contains(&Scope::CloudDestroy));
1835
1836            authority
1837                .bundles
1838                .put(
1839                    &BundleName::new("deploy-admin"),
1840                    &[Scope::CloudRead, Scope::CloudDeploy],
1841                )
1842                .await
1843                .unwrap();
1844
1845            let second = authority
1846                .mint_bootstrap(camp, aud, 1_100)
1847                .await
1848                .unwrap();
1849            assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
1850            assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
1851        });
1852    }
1853
1854    // ---- mint_bootstrap: rejection paths -----------------------------------
1855
1856    #[test]
1857    fn mint_bootstrap_rejects_non_camp_principal() {
1858        let (authority, _verifier) = rig();
1859        pollster::block_on(async {
1860            // A user principal handed to the bootstrap path is a programmer
1861            // error — surfaces WrongPrincipalKind, not a 403.
1862            let err = authority
1863                .mint_bootstrap(
1864                    PrincipalId::user("alice"),
1865                    "https://aud.example",
1866                    1_000,
1867                )
1868                .await
1869                .unwrap_err();
1870            match err {
1871                McpMintError::WrongPrincipalKind { expected, got } => {
1872                    assert_eq!(expected, PrincipalKind::Camp);
1873                    assert_eq!(got, PrincipalKind::User);
1874                }
1875                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1876            }
1877
1878            // Same for a service principal.
1879            let err = authority
1880                .mint_bootstrap(
1881                    PrincipalId::service("yubaba"),
1882                    "https://aud.example",
1883                    1_000,
1884                )
1885                .await
1886                .unwrap_err();
1887            match err {
1888                McpMintError::WrongPrincipalKind { expected, got } => {
1889                    assert_eq!(expected, PrincipalKind::Camp);
1890                    assert_eq!(got, PrincipalKind::Service);
1891                }
1892                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1893            }
1894        });
1895    }
1896
1897    #[test]
1898    fn mint_bootstrap_rejects_unentitled_aud() {
1899        let (authority, _verifier) = rig();
1900        let camp = PrincipalId::camp("c-no-grants");
1901        // No grant placed for any aud — composition rule (5).
1902        pollster::block_on(async {
1903            let err = authority
1904                .mint_bootstrap(camp, "https://aud.example", 1_000)
1905                .await
1906                .unwrap_err();
1907            match err {
1908                McpMintError::AudNotEntitled { principal, aud } => {
1909                    assert_eq!(principal, "camp:c-no-grants");
1910                    assert_eq!(aud, "https://aud.example");
1911                }
1912                other => panic!("expected AudNotEntitled, got {other:?}"),
1913            }
1914        });
1915    }
1916
1917    #[test]
1918    fn mint_bootstrap_rejects_service_only_scope_smuggled_via_bundle() {
1919        // Composition rule (4) — `ownership:write` and `audit:write` are
1920        // grantable to kind=service ONLY. A bundle granted to a camp
1921        // principal that expands to a service-only scope is rejected by
1922        // validate_grant BEFORE signing, mirroring F6's defence in depth on
1923        // the user path.
1924        let (authority, _verifier) = rig();
1925        let camp = PrincipalId::camp("c-sneaky");
1926        let aud = "https://aud.example";
1927
1928        pollster::block_on(async {
1929            authority
1930                .bundles
1931                .put(
1932                    &BundleName::new("dangerous"),
1933                    &[Scope::CampRead, Scope::OwnershipWrite],
1934                )
1935                .await
1936                .unwrap();
1937            authority.grants.put(
1938                camp.clone(),
1939                aud,
1940                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1941            );
1942
1943            let err = authority
1944                .mint_bootstrap(camp, aud, 1_000)
1945                .await
1946                .unwrap_err();
1947            match err {
1948                McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1949                    assert_eq!(scope, Scope::OwnershipWrite);
1950                    assert_eq!(kind, PrincipalKind::Camp);
1951                }
1952                other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1953            }
1954        });
1955    }
1956
1957    // ---- mint_token_exchange: success paths --------------------------------
1958
1959    #[test]
1960    fn mint_token_exchange_attributes_to_user_with_camp_as_context() {
1961        let (authority, verifier) = rig();
1962        let user = PrincipalId::user("alice");
1963        let camp = PrincipalId::camp("c-multi");
1964        let aud = "https://kamaji.camp.example";
1965
1966        // User holds CloudDeploy + CloudRead for this aud; camp holds the
1967        // same. Requested scope is a subset.
1968        put_simple_grant(
1969            &authority,
1970            user.clone(),
1971            aud,
1972            vec![
1973                ScopeOrBundle::Scope(Scope::CloudDeploy),
1974                ScopeOrBundle::Scope(Scope::CloudRead),
1975            ],
1976        );
1977        put_simple_grant(
1978            &authority,
1979            camp.clone(),
1980            aud,
1981            vec![
1982                ScopeOrBundle::Scope(Scope::CloudDeploy),
1983                ScopeOrBundle::Scope(Scope::CloudRead),
1984            ],
1985        );
1986
1987        // Camp owns a service — owns claim must come from the camp.
1988        pollster::block_on(async {
1989            let svc = NewOwnership::new(
1990                camp.clone(),
1991                "service",
1992                "svc-prod",
1993                "owns",
1994                PrincipalId::service("yubaba"),
1995                Some(user.clone()),
1996            )
1997            .unwrap();
1998            authority.ownership.insert(&svc, 500).await.unwrap();
1999
2000            let minted = authority
2001                .mint_token_exchange(
2002                    user.clone(),
2003                    camp.clone(),
2004                    Some(Actor::new(PrincipalId::service("agent-claude"))),
2005                    aud,
2006                    vec![Scope::CloudDeploy],
2007                    1_000,
2008                )
2009                .await
2010                .unwrap();
2011
2012            assert!(minted.token.starts_with("v4.public."));
2013            // sub is the USER, not the camp — exchange attributes to the
2014            // human even though the camp is the bearer at the HTTP layer.
2015            assert_eq!(minted.claims.sub, user);
2016            // camp_id carries the camp context (bare id, no prefix).
2017            assert_eq!(minted.claims.camp_id.as_deref(), Some("c-multi"));
2018            // act carries the agent variant.
2019            assert_eq!(
2020                minted.claims.act.as_ref().unwrap().sub,
2021                PrincipalId::service("agent-claude"),
2022            );
2023            // user-fresh — the user authenticated locally.
2024            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
2025            // Result scope is the requested scope (verified in intersection).
2026            assert_eq!(minted.claims.scope, vec![Scope::CloudDeploy]);
2027            // owns comes from the CAMP, not the user.
2028            assert_eq!(minted.claims.owns.service, vec!["svc-prod".to_string()]);
2029            // jti present, iat/exp respect policy.
2030            assert!(!minted.claims.jti.is_empty());
2031            assert_eq!(minted.claims.iat, 1_000);
2032            assert_eq!(
2033                minted.claims.exp,
2034                1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
2035            );
2036            // Edge verifies.
2037            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
2038            assert_eq!(back, minted.claims);
2039        });
2040    }
2041
2042    #[test]
2043    fn mint_token_exchange_intersection_drops_scope_one_side_lacks() {
2044        // Verify intersection by removing a scope from one side and observing
2045        // it's rejected at the requested-scope check — proves the camp's
2046        // grants actually narrow the user's, not just rubber-stamp the
2047        // request.
2048        let (authority, _verifier) = rig();
2049        let user = PrincipalId::user("bob");
2050        let camp = PrincipalId::camp("c-narrow");
2051        let aud = "https://aud.example";
2052
2053        // User has both; camp has only CloudRead.
2054        put_simple_grant(
2055            &authority,
2056            user.clone(),
2057            aud,
2058            vec![
2059                ScopeOrBundle::Scope(Scope::CloudDeploy),
2060                ScopeOrBundle::Scope(Scope::CloudRead),
2061            ],
2062        );
2063        put_simple_grant(
2064            &authority,
2065            camp.clone(),
2066            aud,
2067            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
2068        );
2069
2070        pollster::block_on(async {
2071            // Requesting CloudRead alone succeeds — in both.
2072            let minted = authority
2073                .mint_token_exchange(
2074                    user.clone(),
2075                    camp.clone(),
2076                    None,
2077                    aud,
2078                    vec![Scope::CloudRead],
2079                    1_000,
2080                )
2081                .await
2082                .unwrap();
2083            assert_eq!(minted.claims.scope, vec![Scope::CloudRead]);
2084
2085            // Requesting CloudDeploy fails — camp lacks it. The whole
2086            // exchange rejects (not a partial token).
2087            let err = authority
2088                .mint_token_exchange(
2089                    user,
2090                    camp,
2091                    None,
2092                    aud,
2093                    vec![Scope::CloudRead, Scope::CloudDeploy],
2094                    1_000,
2095                )
2096                .await
2097                .unwrap_err();
2098            match err {
2099                McpMintError::InvalidScope { scope, aud: a } => {
2100                    assert_eq!(scope, Scope::CloudDeploy);
2101                    assert_eq!(a, aud);
2102                }
2103                other => panic!("expected InvalidScope, got {other:?}"),
2104            }
2105        });
2106    }
2107
2108    #[test]
2109    fn mint_token_exchange_rejects_scope_user_lacks() {
2110        // Mirror case: camp has it, user doesn't. Same all-or-nothing reject.
2111        let (authority, _verifier) = rig();
2112        let user = PrincipalId::user("carol");
2113        let camp = PrincipalId::camp("c-wide");
2114        let aud = "https://aud.example";
2115
2116        put_simple_grant(
2117            &authority,
2118            user.clone(),
2119            aud,
2120            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
2121        );
2122        put_simple_grant(
2123            &authority,
2124            camp.clone(),
2125            aud,
2126            vec![
2127                ScopeOrBundle::Scope(Scope::CloudRead),
2128                ScopeOrBundle::Scope(Scope::CloudDeploy),
2129            ],
2130        );
2131
2132        pollster::block_on(async {
2133            let err = authority
2134                .mint_token_exchange(
2135                    user,
2136                    camp,
2137                    None,
2138                    aud,
2139                    vec![Scope::CloudDeploy],
2140                    1_000,
2141                )
2142                .await
2143                .unwrap_err();
2144            match err {
2145                McpMintError::InvalidScope { scope, aud: _ } => {
2146                    assert_eq!(scope, Scope::CloudDeploy);
2147                }
2148                other => panic!("expected InvalidScope, got {other:?}"),
2149            }
2150        });
2151    }
2152
2153    // ---- mint_token_exchange: rejection paths ------------------------------
2154
2155    #[test]
2156    fn mint_token_exchange_rejects_wrong_user_kind() {
2157        let (authority, _verifier) = rig();
2158        pollster::block_on(async {
2159            let err = authority
2160                .mint_token_exchange(
2161                    PrincipalId::camp("c-as-user"),
2162                    PrincipalId::camp("c"),
2163                    None,
2164                    "https://aud.example",
2165                    vec![],
2166                    1_000,
2167                )
2168                .await
2169                .unwrap_err();
2170            match err {
2171                McpMintError::WrongPrincipalKind { expected, got } => {
2172                    assert_eq!(expected, PrincipalKind::User);
2173                    assert_eq!(got, PrincipalKind::Camp);
2174                }
2175                other => panic!("expected WrongPrincipalKind, got {other:?}"),
2176            }
2177        });
2178    }
2179
2180    #[test]
2181    fn mint_token_exchange_rejects_wrong_camp_kind() {
2182        let (authority, _verifier) = rig();
2183        pollster::block_on(async {
2184            let err = authority
2185                .mint_token_exchange(
2186                    PrincipalId::user("alice"),
2187                    PrincipalId::service("yubaba"),
2188                    None,
2189                    "https://aud.example",
2190                    vec![],
2191                    1_000,
2192                )
2193                .await
2194                .unwrap_err();
2195            match err {
2196                McpMintError::WrongPrincipalKind { expected, got } => {
2197                    assert_eq!(expected, PrincipalKind::Camp);
2198                    assert_eq!(got, PrincipalKind::Service);
2199                }
2200                other => panic!("expected WrongPrincipalKind, got {other:?}"),
2201            }
2202        });
2203    }
2204
2205    #[test]
2206    fn mint_token_exchange_rejects_unentitled_user() {
2207        let (authority, _verifier) = rig();
2208        let user = PrincipalId::user("eve");
2209        let camp = PrincipalId::camp("c");
2210        let aud = "https://aud.example";
2211        // Camp has a grant, user doesn't — must reject on the user side.
2212        put_simple_grant(
2213            &authority,
2214            camp.clone(),
2215            aud,
2216            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
2217        );
2218
2219        pollster::block_on(async {
2220            let err = authority
2221                .mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
2222                .await
2223                .unwrap_err();
2224            match err {
2225                McpMintError::AudNotEntitled { principal, aud: _ } => {
2226                    assert_eq!(principal, "user:eve");
2227                }
2228                other => panic!("expected AudNotEntitled, got {other:?}"),
2229            }
2230        });
2231    }
2232
2233    #[test]
2234    fn mint_token_exchange_rejects_unentitled_camp() {
2235        // Mirror: user has a grant, camp doesn't. Composition rule (5)
2236        // applies per side, so the camp's empty grant list rejects.
2237        let (authority, _verifier) = rig();
2238        let user = PrincipalId::user("frank");
2239        let camp = PrincipalId::camp("c-no-grants");
2240        let aud = "https://aud.example";
2241        put_simple_grant(
2242            &authority,
2243            user.clone(),
2244            aud,
2245            vec![ScopeOrBundle::Scope(Scope::CloudRead)],
2246        );
2247
2248        pollster::block_on(async {
2249            let err = authority
2250                .mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
2251                .await
2252                .unwrap_err();
2253            match err {
2254                McpMintError::AudNotEntitled { principal, aud: _ } => {
2255                    assert_eq!(principal, "camp:c-no-grants");
2256                }
2257                other => panic!("expected AudNotEntitled, got {other:?}"),
2258            }
2259        });
2260    }
2261
2262    #[test]
2263    fn mint_token_exchange_catches_service_only_scope_smuggled_via_camp_bundle() {
2264        // Defence in depth — composition rule (4) — even on the exchange
2265        // path: a camp bundle that expands to OwnershipWrite is caught by
2266        // validate_grant(Camp, scope) BEFORE intersection is computed.
2267        let (authority, _verifier) = rig();
2268        let user = PrincipalId::user("g");
2269        let camp = PrincipalId::camp("c-sneaky");
2270        let aud = "https://aud.example";
2271
2272        pollster::block_on(async {
2273            authority
2274                .bundles
2275                .put(
2276                    &BundleName::new("dangerous"),
2277                    &[Scope::CampRead, Scope::OwnershipWrite],
2278                )
2279                .await
2280                .unwrap();
2281            put_simple_grant(
2282                &authority,
2283                user.clone(),
2284                aud,
2285                vec![ScopeOrBundle::Scope(Scope::CampRead)],
2286            );
2287            put_simple_grant(
2288                &authority,
2289                camp,
2290                aud,
2291                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
2292            );
2293
2294            let err = authority
2295                .mint_token_exchange(
2296                    user,
2297                    PrincipalId::camp("c-sneaky"),
2298                    None,
2299                    aud,
2300                    vec![Scope::CampRead],
2301                    1_000,
2302                )
2303                .await
2304                .unwrap_err();
2305            match err {
2306                McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
2307                    assert_eq!(scope, Scope::OwnershipWrite);
2308                    assert_eq!(kind, PrincipalKind::Camp);
2309                }
2310                other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
2311            }
2312        });
2313    }
2314
2315    // ---- McpMintError → Error conversion -----------------------------------
2316
2317    #[test]
2318    fn mcp_mint_error_converts_to_umbrella_error() {
2319        let e: Error = McpMintError::AudNotEntitled {
2320            principal: "user:x".into(),
2321            aud: "a".into(),
2322        }
2323        .into();
2324        match e {
2325            Error::InvalidInput(msg) => assert!(msg.contains("aud")),
2326            other => panic!("expected InvalidInput, got {other:?}"),
2327        }
2328
2329        let e: Error = McpMintError::Store(StoreError::Conflict).into();
2330        assert!(matches!(e, Error::Store(StoreError::Conflict)));
2331
2332        let e: Error = McpMintError::Codec(CodecError::Malformed).into();
2333        assert!(matches!(e, Error::Codec(CodecError::Malformed)));
2334    }
2335}