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//!
28//! @yah:ticket(R731-F3, "Relationship schema with derived grants — mint walks live tuples through the schema (D2)")
29//! @yah:status(review)
30//! @yah:assignee(agent:bundle-anthropic-ashguard)
31//! @yah:at(2026-10-06T23:18:35Z)
32//! @yah:phase(P1)
33//! @yah:parent(R731)
34//! @yah:next("Doc D2. Per resource kind the product declares relations, what each implies, the scopes each unlocks, and which relations a holder may grant and revoke on the same resource (the D4 authority).")
35//! @yah:next("GrantStore becomes a derived view of the ownership table. Delete hand-coded impls as consumers move (noisetable PublishGrants). Lean: no direct-scope escape hatch until a consumer needs one.")
36//! @yah:next("Decide here: how a machine writer such as yubaba gets grant authority over a resource it just created. Lean a kind-level relation (provisioner on kind service grants owns on any service) over parent relations.")
37//! @yah:next("The check stays at mint (TTL at most 1 h); ownership_version stays deferred.")
38//! @yah:verify("mint paths derive scopes from tuples through the schema; admin does not imply read unless the schema says so.")
39//! @yah:depends_on(R731-F2)
40//! @yah:tier(Wizard)
41//! @yah:next("F3 DESIGN DECIDED (leader, 2026-10-06), in cheers-core beside ScopeRegistry. (1) Products declare const data: `ResourceSchema { kind, relations: &[RelationDef], kind_relations: &[RelationDef] }` with `RelationDef { name, membership: true, implies: &[&str], scopes: &[Scope], grants: &[&str] }`. (2) `SchemaRegistry::build(&[ResourceSchema], &ScopeRegistry)` validates at startup: every implies/grants target is declared in the same kind, the implies graph is acyclic, every scope is registered, and duplicates are refused. It precomputes each relation's implies-closure. (3) The closure applies to BOTH scopes and grant rights: a holder of admin, where admin implies triager, holds triager, so it gets triager's scopes and grants. Nothing is implied unless the schema says so. (4) Kind-level relations (the yubaba provisioner case) are stored as tuples on the reserved resource_kind `kind` with resource_id = the kind name, e.g. `kind/service#provisioner`. A kind_relation's grants apply to every resource of that kind and its scopes unlock as usual. No parent relations, no wildcard ids.")
42//! @yah:gotcha("F3 DERIVATION + AUTHORITY DECIDED (leader). (5) `SchemaGrantStore<O: OwnershipStore>` implements GrantStore: list_for(principal, aud) = the principal's live rows, expanded through the schema closure, scopes filtered by ScopeRegistry::is_valid_at(aud), returned as ScopeOrBundle::Scope. Empty output means the mint rejects, as today. Rows whose resource_kind has no schema are ignored by derivation (camp `owns` rows still serve the owns claim at mcp_authority ~327/610/733). An unknown relation on a known kind is skipped with tracing::warn. (6) No direct-scope escape hatch. Delete MemoryGrantStore (it has no production constructor in cheers or the yah tree; tests seed tuples through a test schema instead). Keep the GrantStore TRAIT: noisetable's PublishGrants implements it until its phase-4 migration. (7) F3 also ships the pure authority check F8 wires into the router: `SchemaRegistry::may_grant(caller_rows, kind, id, relation) -> bool`. It is true iff the caller holds on (kind,id), by closure, a relation whose grants include `relation`, OR holds on (kind, <kind>) a kind_relation whose grants include it. Revoke uses the same rule. Seed tuples are F8's.")
43//! @yah:handoff("cheers-core/src/schema.rs (new, re-exported from lib.rs): ResourceSchema{kind,relations,kind_relations}, RelationDef{name,implies,scopes,grants}, SchemaRegistry::build(&[ResourceSchema], &ScopeRegistry) -> Result<_, SchemaError> (refuses reserved kind 'kind', duplicate kind/relation, unknown implies/grants target, implies cycle via 3-colour DFS, unregistered scope) and precomputes per-relation closure (ResolvedRelation{scopes, grants}). lookup(kind,id,rel) -> Lookup::{NoSchema,UnknownRelation,Resolved}. KIND_RESOURCE='kind'; kind_relation grants name the kind's per-resource relations.")
44//! @yah:handoff("SchemaRegistry::may_grant<T: RelationTuple>(caller_rows: &[T], kind, id, relation) -> bool lives in cheers-core/src/schema.rs. It takes a RelationTuple trait (not OwnershipRow) because OwnershipRow is in cheers-server; cheers-server/src/ownership.rs implements RelationTuple for OwnershipRow, so F8 calls it with &[OwnershipRow] directly.")
45//! @yah:handoff("cheers-server/src/grants.rs: MemoryGrantStore deleted; SchemaGrantStore<O: OwnershipStore>::new(ownership, Arc<SchemaRegistry>, Arc<ScopeRegistry>) implements GrantStore (live rows -> closure -> is_valid_at(aud) -> ScopeOrBundle::Scope, sorted/deduped; NoSchema ignored; UnknownRelation tracing::warn). GrantStore trait kept. cheers-server gained a tracing dep and lib.rs exports SchemaGrantStore.")
46//! @yah:handoff("EXTRA: ownership.rs adds blanket `impl OwnershipStore for Arc<T>` so SchemaGrantStore and McpAuthority share one table. mcp_authority.rs rows_to_owns now skips resource_kind 'kind' rows: kind-level tuples are authority, not owned resources, and would otherwise leak into owns.extra['kind'].")
47//! @yah:handoff("Tests moved: mcp_authority tests seed scopes as kind/grant#<scope> tuples via a leaked test schema (TestGrants wraps SchemaGrantStore plus a `raw` map that stands in for a hand-coded GrantStore like noisetable's, used only for bundle cases and the unregistered-scope case, which no schema can express). cheers-axum tokens_basic.rs and camps.rs seed tuples through const test schemas. Behaviour changes in 3 tests: derived scopes come back in scope order (2 order asserts updated), and the 'aud with no grant' 403 now checks before any tuple exists, because yah scopes are Audiences::Any so a derived grant is held at every aud.")
48//! @yah:verify("cargo test --workspace in oss/cheers: 683 passed / 0 failed / 3 ignored (baseline 652/0/3, measured before the first edit; the delta includes peer F6 tests).")
49//! @yah:verify("cheers-core schema tests: admin_does_not_imply_read_unless_declared, implied_relations_carry_their_grants, may_grant_rules (incl. revoked row), may_grant_kind_level, build_refuses_bad_schemas, lookup_distinguishes_no_schema_and_unknown_relation. cheers-server grants::tests cover closure+aud filter, unknown kind/relation ignored, revoked confers nothing, and kind-level scopes.")
50//! @yah:handoff("FOLLOW-UP (leader decision): removed Audiences::Any. The enum is now Only(&'static [&'static str]) | BoundAtStartup, and the scopes! arm with no audiences emits BoundAtStartup. ScopeRegistryBuilder::bind_audiences(namespace, auds) binds every BoundAtStartup scope in that namespace; the registry stores the resolved audiences per scope (ScopeRegistry::audiences(scope)), and is_valid_at and validate_grant are exact. build() returns ScopeRegistryError::{Duplicate, Unbound, NoAudiences, UnusedBinding}, replacing DuplicateScope. The UnusedBinding refusal, for a namespace bound but never used, is my own addition: it catches a typo'd namespace.")
51//! @yah:handoff("yah_scopes: every scope is BoundAtStartup. registry() is gone, replaced by NAMESPACES (8), builder() (DEFS preloaded, bind per namespace from config) and registry_at(auds) for single-audience issuers and tests. cheers itself constructs no production registry. The only production site is yah app/yah/cli/src/cloud_cheers.rs::token, now bound to its --aud (one issuer serves one consumer). kamaji-bin builds no registry, so the fence held. The yah-tree compile of cloud_cheers.rs is UNVERIFIED: `cargo check -p yah --tests` is blocked by 23 unrelated pre-existing errors in crates/yah/agent-tools/src/board_tools.rs (TicketStatus::Open etc.), none of them in cheers code.")
52//! @yah:handoff("Tests: scope.rs unbound_or_empty_or_unused_bindings_fail_build; yah_scopes namespaces_cover_defs_and_unbound_fails and a_scope_is_valid_only_where_its_namespace_was_bound; tokens_basic's 403 test is back to its original shape. There a derived cloud:read tuple exists but is refused at the unbound https://not-granted.test (BOUND_AUDS). Product Only scopes behave as before (scope.rs, mcp.rs pinned test). Every test registry now binds the audiences it mints at.")
53//! @yah:verify("cargo test --workspace (oss/cheers) after the audience follow-up: 686 passed / 0 failed / 3 ignored (previous end state 683/0/3).")
54//! @yah:verify("Leader re-verify 2026-10-06, after the Audiences::Any removal: cargo test --workspace (oss/cheers) 686 pass / 0 fail / 3 ignored, no build skew; 0 remaining yah_scopes::registry() or Audiences::Any call sites across cheers, kamaji, passway, yubaba and the yah crates/app.")
55
56use std::sync::Arc;
57
58use cheers_core::{
59    validate_grant, Actor, AuthStrength, ClientAssertion, CodecError, Error, GrantError,
60    McpClaims, Owns, PrincipalId, PrincipalKind, Scope, ScopeRegistry, StoreError, UsedJtiStore,
61};
62use pasetors::keys::AsymmetricPublicKey;
63use pasetors::token::UntrustedToken;
64use pasetors::version4::{PublicToken, V4};
65
66use crate::bundles::{expand_scopes, BundleExpansionError, BundleStore};
67use crate::codec::PasetoV4SecretMinter;
68use crate::grants::GrantStore;
69use crate::ownership::{OwnershipRow, OwnershipStore};
70use crate::service_principal::{ServicePrincipalStore, SigningKeyStatus};
71use crate::session::generate_jti;
72
73/// TTL defaults for an MCP-call token.
74///
75/// Short by design — per `.yah/docs/working/mcp-auth-and-ownership.md` §TTLs,
76/// the 5–15 minute access window is also the propagation bound for
77/// revocations and ownership-table edits when no `ownership_version`
78/// freshness backstop is in play.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct McpPolicy {
82    /// Per-call access-token lifetime in seconds.
83    pub access_ttl_seconds: i64,
84    /// Lifetime of a user API token when the mint request names none.
85    ///
86    /// Deliberately **not** session-shaped: a PAT is the credential a CI job
87    /// or a headless agent holds, and re-minting it is a human ceremony, so
88    /// the minutes-scale access TTL above would make it useless. 90 days is
89    /// long enough to not be a weekly chore and short enough that a forgotten
90    /// token dies on its own.
91    pub api_token_default_ttl_seconds: i64,
92    /// Hard ceiling on a user API token's lifetime. A request above it is
93    /// rejected ([`McpMintError::TtlOutOfRange`]) rather than clamped —
94    /// silently handing back a token that expires sooner than asked is the
95    /// kind of surprise that surfaces as a 2am outage.
96    pub api_token_max_ttl_seconds: i64,
97}
98
99impl McpPolicy {
100    /// 10 minutes — middle of the doc's 5–15 min range.
101    pub const DEFAULT_ACCESS_TTL_SECONDS: i64 = 10 * 60;
102    /// 90 days.
103    pub const DEFAULT_API_TOKEN_TTL_SECONDS: i64 = 90 * 24 * 60 * 60;
104    /// 365 days.
105    pub const MAX_API_TOKEN_TTL_SECONDS: i64 = 365 * 24 * 60 * 60;
106
107    pub fn new(access_ttl_seconds: i64) -> Self {
108        Self {
109            access_ttl_seconds,
110            ..Self::default()
111        }
112    }
113
114    pub fn with_access_ttl(mut self, seconds: i64) -> Self {
115        self.access_ttl_seconds = seconds;
116        self
117    }
118
119    /// Override both API-token TTL bounds. `default` is what an omitted
120    /// `expires_in_secs` resolves to; `max` is the ceiling a named one is
121    /// checked against.
122    pub fn with_api_token_ttls(mut self, default: i64, max: i64) -> Self {
123        self.api_token_default_ttl_seconds = default;
124        self.api_token_max_ttl_seconds = max;
125        self
126    }
127}
128
129impl Default for McpPolicy {
130    fn default() -> Self {
131        Self {
132            access_ttl_seconds: Self::DEFAULT_ACCESS_TTL_SECONDS,
133            api_token_default_ttl_seconds: Self::DEFAULT_API_TOKEN_TTL_SECONDS,
134            api_token_max_ttl_seconds: Self::MAX_API_TOKEN_TTL_SECONDS,
135        }
136    }
137}
138
139/// A freshly-minted MCP-call token plus the [`McpClaims`] it carries.
140#[derive(Debug, Clone)]
141#[non_exhaustive]
142pub struct MintedMcpToken {
143    /// The signed `v4.public.*` token string.
144    pub token: String,
145    /// The claims embedded in `token`, including the freshly minted `jti`.
146    pub claims: McpClaims,
147}
148
149/// Why a mint attempt failed before (or after) the signing step.
150///
151/// Typed so the HTTP layer (R020-T17 routes) can map specific failures to the
152/// right status code: `AudNotEntitled` → 403, `GrantMisconfigured` → 500
153/// (server-side data bug), `Store` → 503, `Codec` → 500.
154#[derive(Debug, thiserror::Error)]
155#[non_exhaustive]
156pub enum McpMintError {
157    /// Composition rule (5) — the principal holds no grant for the requested
158    /// `aud`. Per-call rejection, returned before any signing happens.
159    #[error("no entitlement: principal '{principal}' has no grant for aud '{aud}'")]
160    AudNotEntitled { principal: String, aud: String },
161    /// RFC 8693 — a requested scope on a token-exchange is not in the
162    /// intersection of both principals' grants for this `aud`. Per the doc
163    /// (`§Mint flows` → token exchange), the whole exchange is rejected —
164    /// `invalid_scope`, not a partial token. Maps to HTTP 400 `invalid_scope`.
165    #[error("requested scope '{scope}' is not granted by both principals for aud '{aud}'")]
166    InvalidScope { scope: Scope, aud: String },
167    /// The caller passed the wrong principal kind for this mint path — e.g.
168    /// `mint_user_fresh` invoked with `svc:` or `camp:` in the `sub` slot.
169    /// A programmer-error, not a user-input rejection: surface 500, not 403.
170    #[error("mint path expects {expected} principal; got {got}")]
171    WrongPrincipalKind {
172        expected: PrincipalKind,
173        got: PrincipalKind,
174    },
175    /// [`mint_api_token`](McpAuthority::mint_api_token) was asked for scopes
176    /// the principal's grants do not currently expand to. **Every** offender
177    /// is named: silently dropping the unheld ones would hand back a token
178    /// quietly weaker than the one asked for, which fails later, elsewhere,
179    /// as an unexplained 403. Maps to HTTP 400.
180    #[error(
181        "principal '{principal}' does not hold {} for aud '{aud}'",
182        .scopes.iter().map(|s| s.as_wire()).collect::<Vec<_>>().join(", ")
183    )]
184    UnentitledScopes {
185        principal: String,
186        scopes: Vec<Scope>,
187        aud: String,
188    },
189    /// A user-API-token mint named an `expires_in_secs` outside
190    /// `1..=policy.api_token_max_ttl_seconds`. Rejected, never clamped.
191    /// Maps to HTTP 400.
192    #[error("requested ttl {requested}s is outside the allowed range 1..={max}s")]
193    TtlOutOfRange { requested: i64, max: i64 },
194    /// Composition rule (4) defence in depth — bundle expansion produced a
195    /// service-only scope for a non-service principal. Indicates a
196    /// misconfigured bundle or a grant that should have been rejected at the
197    /// grant API; surface as 500.
198    #[error(transparent)]
199    GrantMisconfigured(#[from] GrantError),
200    /// Bundle expansion failed (unknown bundle / store error inside
201    /// expansion).
202    #[error(transparent)]
203    BundleExpansion(#[from] BundleExpansionError),
204    /// Underlying store failure (grants / ownership).
205    #[error(transparent)]
206    Store(#[from] StoreError),
207    /// Signing failure inside the codec layer.
208    #[error(transparent)]
209    Codec(#[from] CodecError),
210    /// [`mint_service`](McpAuthority::mint_service) was called on an
211    /// authority assembled without
212    /// [`with_service_assertions`](McpAuthority::with_service_assertions).
213    /// A wiring bug; surface 500.
214    #[error("service assertions are not configured on this authority")]
215    ServiceAssertionsUnconfigured,
216    /// The client assertion is not a well-formed PASETO v4.public token, has
217    /// no readable footer `kid`, fails signature verification, carries an
218    /// undecodable payload, or has an empty `jti`. Maps to 400
219    /// `invalid_client`.
220    #[error("malformed client assertion: {0}")]
221    AssertionMalformed(String),
222    /// The footer `kid` names no service-principal key cheers holds.
223    #[error("client assertion kid '{0}' is unknown")]
224    AssertionUnknownKid(String),
225    /// The footer `kid` names a key that has been rotated out (`Retiring`).
226    /// Only an ACTIVE key may sign an assertion, even inside the overlap
227    /// window that still publishes it.
228    #[error("client assertion kid '{0}' is a retired key")]
229    AssertionRetiredKey(String),
230    /// `iss` or `sub` is not the service principal that owns the signing key
231    /// (or `iss != sub`).
232    #[error("client assertion names '{iss}'/'{sub}' but kid '{kid}' belongs to '{owner}'")]
233    AssertionPrincipalMismatch {
234        kid: String,
235        owner: PrincipalId,
236        iss: PrincipalId,
237        sub: PrincipalId,
238    },
239    /// `aud` is not this deployment's token endpoint URL.
240    #[error("client assertion aud '{got}' is not the token endpoint '{expected}'")]
241    AssertionBadAudience { got: String, expected: String },
242    /// `exp <= now`.
243    #[error("client assertion expired at {exp} (now {now})")]
244    AssertionExpired { exp: i64, now: i64 },
245    /// `exp - iat` exceeds [`ClientAssertion::MAX_LIFETIME_SECONDS`].
246    #[error("client assertion lifetime {lifetime}s exceeds the {max}s maximum")]
247    AssertionLifetimeTooLong { lifetime: i64, max: i64 },
248    /// The assertion's `jti` was already consumed.
249    #[error("client assertion jti '{0}' was already used")]
250    AssertionReplayed(String),
251    /// The used-jti backend failed; the assertion was neither accepted nor
252    /// rejected. Surface 503.
253    #[error("used-jti store failure: {0}")]
254    JtiStore(String),
255    /// The principal's relationship-derived scopes for `aud`, intersected
256    /// with the request, are empty. Maps to 400 `invalid_scope`.
257    #[error("principal '{principal}' holds none of the requested scopes for aud '{aud}'")]
258    NoGrantedScopes { principal: String, aud: String },
259}
260
261impl From<McpMintError> for Error {
262    fn from(e: McpMintError) -> Self {
263        match e {
264            McpMintError::Store(s) => Error::Store(s),
265            McpMintError::Codec(c) => Error::Codec(c),
266            McpMintError::BundleExpansion(BundleExpansionError::Store(s)) => Error::Store(s),
267            // Structured rejections (AudNotEntitled, WrongPrincipalKind,
268            // GrantMisconfigured, BundleExpansion::Unknown) carry a message
269            // that's safe to surface; route them through InvalidInput so a
270            // caller that doesn't pattern-match on McpMintError still gets a
271            // sensible umbrella error.
272            other => Error::InvalidInput(other.to_string()),
273        }
274    }
275}
276
277/// Origin facade: everything that can *mint* an MCP-call token.
278///
279/// Holds the four origin-tier capabilities for the MCP boundary — a
280/// [`PasetoV4SecretMinter`], a [`BundleStore`], a [`GrantStore`], and an
281/// [`OwnershipStore`] — plus the cheers issuer URL and an [`McpPolicy`].
282/// Generic (not `dyn`) so the assembled capability set is visible in the
283/// type. The matching edge-side verifier is
284/// [`PasetoV4PublicVerifier`](cheers_verify::PasetoV4PublicVerifier) +
285/// `verify_mcp_at`; the verify-only consumer (kamaji) never depends on
286/// this crate.
287pub struct McpAuthority<B, G, O> {
288    minter: PasetoV4SecretMinter,
289    bundles: B,
290    grants: G,
291    ownership: O,
292    /// The deployment's scope vocabulary. Every scope a mint path is about to
293    /// sign runs through [`validate_grant`] against it.
294    scopes: Arc<ScopeRegistry>,
295    iss: String,
296    /// `kid` stamped into every minted token's PASETO footer (R592-B7 wire
297    /// convention) — identifies which published key `minter` signs with, so
298    /// an edge verifier's JWKS/footer lookup can find the matching pubkey.
299    kid: String,
300    policy: McpPolicy,
301    /// Service-principal key table + used-jti ledger for
302    /// [`mint_service`](Self::mint_service). `None` until
303    /// [`with_service_assertions`](Self::with_service_assertions).
304    service: Option<ServiceAssertions>,
305}
306
307/// What [`McpAuthority::mint_service`] needs beyond the user-facing paths.
308struct ServiceAssertions {
309    keys: Arc<dyn ServicePrincipalStore>,
310    used_jtis: Arc<dyn UsedJtiStore>,
311}
312
313impl<B, G, O> McpAuthority<B, G, O>
314where
315    B: BundleStore,
316    G: GrantStore,
317    O: OwnershipStore,
318{
319    /// Assemble an authority with the [default policy](McpPolicy::default).
320    ///
321    /// `kid` is the footer identifier published alongside `minter`'s public
322    /// key (e.g. in cheers's JWKS) — every token this authority mints carries
323    /// it so a verifier can select the right key.
324    pub fn new(
325        minter: PasetoV4SecretMinter,
326        bundles: B,
327        grants: G,
328        ownership: O,
329        scopes: Arc<ScopeRegistry>,
330        iss: impl Into<String>,
331        kid: impl Into<String>,
332    ) -> Self {
333        Self {
334            minter,
335            bundles,
336            grants,
337            ownership,
338            scopes,
339            iss: iss.into(),
340            kid: kid.into(),
341            policy: McpPolicy::default(),
342            service: None,
343        }
344    }
345
346    /// Override the TTL policy.
347    pub fn with_policy(mut self, policy: McpPolicy) -> Self {
348        self.policy = policy;
349        self
350    }
351
352    pub fn policy(&self) -> &McpPolicy {
353        &self.policy
354    }
355
356    /// Enable [`mint_service`](Self::mint_service): `keys` resolves an
357    /// assertion's footer `kid` to a service-principal signing key, and
358    /// `used_jtis` consumes each assertion `jti` exactly once.
359    pub fn with_service_assertions(
360        mut self,
361        keys: Arc<dyn ServicePrincipalStore>,
362        used_jtis: Arc<dyn UsedJtiStore>,
363    ) -> Self {
364        self.service = Some(ServiceAssertions { keys, used_jtis });
365        self
366    }
367
368    /// The URL a client assertion's `aud` must name: `<issuer>/token`.
369    pub fn token_endpoint(&self) -> String {
370        format!("{}/token", self.iss.trim_end_matches('/'))
371    }
372
373    pub fn scopes(&self) -> &ScopeRegistry {
374        &self.scopes
375    }
376
377    pub fn issuer(&self) -> &str {
378        &self.iss
379    }
380
381    /// **Mint path 1** — user-initiated, passkey-fresh (R020-F6).
382    ///
383    /// Returns a token bearing
384    /// `{sub: user:<U>, act?, camp_id?, scope: [...], owns: {...}, auth_strength: "user-fresh"}`.
385    /// Required inputs:
386    ///
387    /// - `user`: the authenticated user's principal (kind MUST be
388    ///   [`PrincipalKind::User`]).
389    /// - `actor`: the optional `act` claim (RFC 8693) — the agent acting on
390    ///   the user's behalf. The agent is never the primary `sub`.
391    /// - `camp_id`: when set, both populates the `camp_id` claim and is the
392    ///   key used to look up the `owns` claim (rows held by
393    ///   `camp:<camp_id>`).
394    /// - `aud`: the target resource URI for this call. Per composition rule
395    ///   (5) the principal MUST hold at least one grant entry for `aud` — an
396    ///   empty grant list returns [`McpMintError::AudNotEntitled`] before
397    ///   any signing happens.
398    /// - `now`: unix seconds. `iat = now`, `exp = now + policy.access_ttl`.
399    ///
400    /// The pipeline: grants → [`expand_scopes`] → [`validate_grant`] per
401    /// scope (rule (4) defence) → ownership lookup → sign with `mint_mcp`.
402    pub async fn mint_user_fresh(
403        &self,
404        user: PrincipalId,
405        actor: Option<Actor>,
406        camp_id: Option<String>,
407        aud: impl Into<String>,
408        now: i64,
409    ) -> Result<MintedMcpToken, McpMintError> {
410        if user.kind != PrincipalKind::User {
411            return Err(McpMintError::WrongPrincipalKind {
412                expected: PrincipalKind::User,
413                got: user.kind,
414            });
415        }
416        let aud = aud.into();
417
418        let entries = self.grants.list_for(&user, &aud).await?;
419        if entries.is_empty() {
420            return Err(McpMintError::AudNotEntitled {
421                principal: user.to_string(),
422                aud,
423            });
424        }
425
426        let scopes = expand_scopes(&self.bundles, &entries).await?;
427        for s in &scopes {
428            validate_grant(&self.scopes, user.kind, s, &aud)?;
429        }
430
431        let owns = match &camp_id {
432            Some(id) => {
433                let camp_principal = PrincipalId::camp(id);
434                let rows = self.ownership.list_for_principal(&camp_principal).await?;
435                rows_to_owns(&rows)
436            }
437            None => Owns::default(),
438        };
439
440        let mut claims = McpClaims::new(
441            self.iss.clone(),
442            aud,
443            user,
444            now,
445            now + self.policy.access_ttl_seconds,
446            generate_jti(),
447            scopes,
448        )
449        .with_auth_strength(AuthStrength::UserFresh);
450        if let Some(a) = actor {
451            claims = claims.with_act(a);
452        }
453        if let Some(c) = camp_id {
454            claims = claims.with_camp_id(c);
455        }
456        if !owns.is_empty() {
457            claims = claims.with_owns(owns);
458        }
459
460        let token = self.minter.mint_mcp(&claims, &self.kid)?;
461        Ok(MintedMcpToken { token, claims })
462    }
463
464    /// **Mint path 3** — a long-lived user API token (PAT), R728-F1.
465    ///
466    /// The credential a CI job, a script or a headless agent holds so it can
467    /// *be* the user without a browser. Returns a token bearing
468    /// `{sub: user:<U>, scope: [...], auth_strength: "api-token"}`.
469    ///
470    /// ## It is the same stack, not a parallel one
471    ///
472    /// Same signing key, same `jti`, same revocation set the edge already
473    /// reads. A PAT differs from a
474    /// [`mint_user_fresh`](Self::mint_user_fresh) token in exactly three
475    /// claim-level ways — `auth_strength`, a caller-chosen `exp`, and a
476    /// possibly-narrower `scope` — so nothing downstream needs a new verify
477    /// path to accept one, and a route that must refuse one reads
478    /// [`AuthStrength`].
479    ///
480    /// ## Attenuation is the point
481    ///
482    /// `requested` is **intersected**, never taken verbatim:
483    ///
484    /// - The principal's grants for `aud` are looked up and expanded, exactly
485    ///   as [`mint_user_fresh`](Self::mint_user_fresh) does — empty ⇒
486    ///   [`McpMintError::AudNotEntitled`] before any signing.
487    /// - [`validate_grant`] runs over every expanded scope (rule (4) defence)
488    ///   *before* signing, so a bundle smuggling a service-only scope into a
489    ///   user grant is caught here.
490    /// - An empty `requested` means "everything I currently hold for this
491    ///   aud" — the effective set, not a wildcard, and it is frozen into the
492    ///   token at mint time.
493    /// - A requested scope outside the effective set is
494    ///   [`McpMintError::UnentitledScopes`] naming every offender, **not** a
495    ///   silent drop.
496    ///
497    /// So a PAT can never be wider than its minter was at mint time. It can
498    /// outlive that entitlement — a grant revoked afterwards does not shrink
499    /// an already-signed token — which is what the revocation set and the TTL
500    /// ceiling are for.
501    ///
502    /// ## No `owns`, no `camp_id`, no `act`
503    ///
504    /// Deliberate, and the reason is the TTL. The `owns` claim is a snapshot
505    /// of the ownership table read at mint time, and the token's lifetime is
506    /// the propagation bound for edits to it (see [`McpPolicy`]) — fine at 10
507    /// minutes, a 90-day stale-authorization hole here. A PAT therefore
508    /// asserts identity and scope only; a consumer needing per-resource
509    /// ownership looks it up live. `act` is absent for a simpler reason: the
510    /// user minted this themselves, nobody is acting on their behalf.
511    ///
512    /// `ttl_seconds`: `None` ⇒ [`McpPolicy::api_token_default_ttl_seconds`].
513    /// `Some(n)` outside `1..=api_token_max_ttl_seconds` ⇒
514    /// [`McpMintError::TtlOutOfRange`] — rejected, not clamped.
515    pub async fn mint_api_token(
516        &self,
517        user: PrincipalId,
518        aud: impl Into<String>,
519        requested: &[Scope],
520        ttl_seconds: Option<i64>,
521        now: i64,
522    ) -> Result<MintedMcpToken, McpMintError> {
523        if user.kind != PrincipalKind::User {
524            return Err(McpMintError::WrongPrincipalKind {
525                expected: PrincipalKind::User,
526                got: user.kind,
527            });
528        }
529        let aud = aud.into();
530
531        let ttl = self.api_token_ttl(ttl_seconds)?;
532
533        let entries = self.grants.list_for(&user, &aud).await?;
534        if entries.is_empty() {
535            return Err(McpMintError::AudNotEntitled {
536                principal: user.to_string(),
537                aud,
538            });
539        }
540
541        let effective = expand_scopes(&self.bundles, &entries).await?;
542        for s in &effective {
543            validate_grant(&self.scopes, user.kind, s, &aud)?;
544        }
545
546        let scopes = attenuate(requested, effective, &user, &aud)?;
547
548        self.sign_api_token(user, aud, scopes, ttl, now)
549    }
550
551    /// **Mint path 3b** — roll a user API token over, R728-F2.
552    ///
553    /// Backs `POST /me/tokens/{id}/rotate`: the caller presents a credential
554    /// they already hold and gets a replacement, so a leaked or ageing PAT can
555    /// be replaced *by the token itself* without a browser ceremony. "A
556    /// credential must be able to roll itself" is an operator requirement
557    /// (2026-09-12), and this is the mint it needs.
558    ///
559    /// ## `held` is the entire authority ceiling, and the grant table is NOT read
560    ///
561    /// Note the signature: **not `async`**, because this path performs no
562    /// store I/O at all — that absence is the security property, not an
563    /// optimization. [`mint_api_token`](Self::mint_api_token) derives the
564    /// ceiling from `grants.list_for`; if rotation did the same, a token
565    /// minted when the user held `cloud:read` would silently come back
566    /// carrying `cloud:destroy` the moment an operator widened the grant.
567    /// That would unpick the property
568    /// `a_minted_pat_is_frozen_and_does_not_track_later_grant_edits` exists to
569    /// pin. So the ceiling here is `held` — the authority the presented
570    /// credential *already* carries — and nothing else. A rotation can narrow
571    /// and can never widen.
572    ///
573    /// `requested` empty ⇒ the replacement carries exactly `held`. A requested
574    /// scope outside `held` is [`McpMintError::UnentitledScopes`] naming every
575    /// offender, the same refusal (and the same 400) as the create path.
576    ///
577    /// ## Lifetime is refreshed, but still bounded
578    ///
579    /// The replacement gets a *fresh* TTL from the same policy
580    /// ([`McpPolicy::api_token_default_ttl_seconds`], ceiling
581    /// [`McpPolicy::api_token_max_ttl_seconds`], out of range rejected rather
582    /// than clamped) — rotation is how a long-lived credential stays alive, so
583    /// inheriting the old token's remaining life would make it useless. The
584    /// ceiling is what keeps that from being indefinite extension: every hop
585    /// is bounded by the same policy a first mint is, and the caller can only
586    /// ever hold one live token per rotation because the old `jti` is revoked
587    /// by the route immediately after.
588    ///
589    /// [`validate_grant`] still runs over the resulting scopes: a service-only
590    /// scope riding inside a user's token is a data bug wherever it came from,
591    /// and re-signing it would launder it forward.
592    pub fn rotate_api_token(
593        &self,
594        user: PrincipalId,
595        aud: impl Into<String>,
596        held: &[Scope],
597        requested: &[Scope],
598        ttl_seconds: Option<i64>,
599        now: i64,
600    ) -> Result<MintedMcpToken, McpMintError> {
601        if user.kind != PrincipalKind::User {
602            return Err(McpMintError::WrongPrincipalKind {
603                expected: PrincipalKind::User,
604                got: user.kind,
605            });
606        }
607        let aud = aud.into();
608        let ttl = self.api_token_ttl(ttl_seconds)?;
609
610        let mut ceiling: Vec<Scope> = Vec::with_capacity(held.len());
611        for s in held {
612            validate_grant(&self.scopes, user.kind, s, &aud)?;
613            if !ceiling.contains(s) {
614                ceiling.push(s.clone());
615            }
616        }
617
618        let scopes = attenuate(requested, ceiling, &user, &aud)?;
619
620        self.sign_api_token(user, aud, scopes, ttl, now)
621    }
622
623    /// `None` ⇒ the policy default; `Some(n)` outside
624    /// `1..=api_token_max_ttl_seconds` ⇒ [`McpMintError::TtlOutOfRange`].
625    /// Rejected, never clamped — shared by both API-token mint paths so a
626    /// rotation cannot outlive the ceiling a first mint is held to.
627    fn api_token_ttl(&self, ttl_seconds: Option<i64>) -> Result<i64, McpMintError> {
628        match ttl_seconds {
629            None => Ok(self.policy.api_token_default_ttl_seconds),
630            Some(n) if n >= 1 && n <= self.policy.api_token_max_ttl_seconds => Ok(n),
631            Some(n) => Err(McpMintError::TtlOutOfRange {
632                requested: n,
633                max: self.policy.api_token_max_ttl_seconds,
634            }),
635        }
636    }
637
638    /// The last two steps both API-token paths share: build the claim with
639    /// `auth_strength: "api-token"` and sign it. Neither path adds `owns`,
640    /// `camp_id` or `act` — see [`mint_api_token`](Self::mint_api_token) for
641    /// why.
642    fn sign_api_token(
643        &self,
644        user: PrincipalId,
645        aud: String,
646        scopes: Vec<Scope>,
647        ttl: i64,
648        now: i64,
649    ) -> Result<MintedMcpToken, McpMintError> {
650        let claims = McpClaims::new(
651            self.iss.clone(),
652            aud,
653            user,
654            now,
655            now + ttl,
656            generate_jti(),
657            scopes,
658        )
659        .with_auth_strength(AuthStrength::ApiToken);
660
661        let token = self.minter.mint_mcp(&claims, &self.kid)?;
662        Ok(MintedMcpToken { token, claims })
663    }
664
665    /// **Mint path 2** — bootstrapped camp, autonomous (R020-F7).
666    ///
667    /// For yubaba-hosted camps operating without a live user session: the
668    /// camp's bootstrap credential authenticates upstream of this call, and
669    /// the verified camp principal is what arrives here. Returns a token
670    /// bearing
671    /// `{sub: camp:<C>, camp_id: <C>, scope: [...], owns: {...}, auth_strength: "bootstrap"}`
672    /// — note **no `act` claim** on this path, the camp itself is the
673    /// principal (not a user acted-on-by an agent).
674    ///
675    /// Required inputs:
676    ///
677    /// - `camp`: the authenticated camp's principal (kind MUST be
678    ///   [`PrincipalKind::Camp`]). `camp_id` on the resulting claim is the
679    ///   bare id (sans `camp:` prefix), so a consumer doesn't have to re-
680    ///   parse `sub` to learn it.
681    /// - `aud`: the target resource URI for this call. Per composition rule
682    ///   (5) the camp MUST hold at least one grant entry for `aud` — an empty
683    ///   grant list returns [`McpMintError::AudNotEntitled`] before signing.
684    /// - `now`: unix seconds. `iat = now`, `exp = now + policy.access_ttl`.
685    ///
686    /// Same pipeline as [`mint_user_fresh`](Self::mint_user_fresh): grants →
687    /// [`expand_scopes`] → [`validate_grant`] per scope (rule (4) defence —
688    /// catches a bundle that smuggles `audit:write` into a camp grant) →
689    /// ownership lookup → sign with `mint_mcp`.
690    pub async fn mint_bootstrap(
691        &self,
692        camp: PrincipalId,
693        aud: impl Into<String>,
694        now: i64,
695    ) -> Result<MintedMcpToken, McpMintError> {
696        if camp.kind != PrincipalKind::Camp {
697            return Err(McpMintError::WrongPrincipalKind {
698                expected: PrincipalKind::Camp,
699                got: camp.kind,
700            });
701        }
702        let aud = aud.into();
703
704        let entries = self.grants.list_for(&camp, &aud).await?;
705        if entries.is_empty() {
706            return Err(McpMintError::AudNotEntitled {
707                principal: camp.to_string(),
708                aud,
709            });
710        }
711
712        let scopes = expand_scopes(&self.bundles, &entries).await?;
713        for s in &scopes {
714            validate_grant(&self.scopes, camp.kind, s, &aud)?;
715        }
716
717        let rows = self.ownership.list_for_principal(&camp).await?;
718        let owns = rows_to_owns(&rows);
719
720        let camp_id = camp.id.clone();
721        let mut claims = McpClaims::new(
722            self.iss.clone(),
723            aud,
724            camp,
725            now,
726            now + self.policy.access_ttl_seconds,
727            generate_jti(),
728            scopes,
729        )
730        .with_camp_id(camp_id)
731        .with_auth_strength(AuthStrength::Bootstrap);
732        if !owns.is_empty() {
733            claims = claims.with_owns(owns);
734        }
735
736        let token = self.minter.mint_mcp(&claims, &self.kid)?;
737        Ok(MintedMcpToken { token, claims })
738    }
739
740    /// **Mint path 3** — RFC 8693 token-exchange (R020-F8).
741    ///
742    /// The ONLY path that crosses principals: a multi-player camp daemon
743    /// (`subject_token` = camp bootstrap credential) presents a human's
744    /// session token (`actor_token` = user's session) and asks for a token
745    /// attributed to the *human*, with the camp as call context. The result
746    /// looks like a `mint_user_fresh` token — same `auth_strength=user-fresh`
747    /// because the user authenticated locally — but the camp's grants
748    /// constrain it.
749    ///
750    /// Inputs (already-verified principals — credential checks live at the
751    /// HTTP `/token` endpoint, same way passkey assertion sits upstream of
752    /// [`mint_user_fresh`](Self::mint_user_fresh)):
753    ///
754    /// - `user`: the verified user from `actor_token` (kind MUST be
755    ///   [`PrincipalKind::User`]).
756    /// - `camp`: the verified camp from `subject_token` (kind MUST be
757    ///   [`PrincipalKind::Camp`]).
758    /// - `actor`: the optional `act` claim — the agent variant acting on the
759    ///   user's behalf (RFC 8693).
760    /// - `aud`: target resource URI. BOTH principals must hold a grant for
761    ///   it (composition rule (5) applies per side).
762    /// - `requested_scope`: the scopes the exchange asks for. EVERY entry
763    ///   must be in the intersection of the user's expanded-grant scopes AND
764    ///   the camp's expanded-grant scopes for this aud. A requested scope
765    ///   outside the intersection rejects the whole exchange with
766    ///   [`McpMintError::InvalidScope`] — no partial token (RFC 8693 / doc
767    ///   §Mint flows).
768    /// - `now`: unix seconds. `iat = now`, `exp = now + policy.access_ttl`.
769    ///
770    /// Result claim:
771    /// `{sub: user:<U>, act: {sub: agent:<V>}?, camp_id: <C>, scope: [...requested...], owns: {...}, auth_strength: "user-fresh"}`.
772    /// `owns` is the CAMP's ownership (not the user's) — the call is scoped
773    /// to the camp's resources. Audit (R020-F13) captures both legs from the
774    /// resulting claim plus the endpoint's request context.
775    pub async fn mint_token_exchange(
776        &self,
777        user: PrincipalId,
778        camp: PrincipalId,
779        actor: Option<Actor>,
780        aud: impl Into<String>,
781        requested_scope: Vec<Scope>,
782        now: i64,
783    ) -> Result<MintedMcpToken, McpMintError> {
784        if user.kind != PrincipalKind::User {
785            return Err(McpMintError::WrongPrincipalKind {
786                expected: PrincipalKind::User,
787                got: user.kind,
788            });
789        }
790        if camp.kind != PrincipalKind::Camp {
791            return Err(McpMintError::WrongPrincipalKind {
792                expected: PrincipalKind::Camp,
793                got: camp.kind,
794            });
795        }
796        let aud = aud.into();
797
798        // (1) User side — composition rule (5) per side.
799        let user_entries = self.grants.list_for(&user, &aud).await?;
800        if user_entries.is_empty() {
801            return Err(McpMintError::AudNotEntitled {
802                principal: user.to_string(),
803                aud,
804            });
805        }
806        let user_scopes = expand_scopes(&self.bundles, &user_entries).await?;
807        for s in &user_scopes {
808            validate_grant(&self.scopes, user.kind, s, &aud)?;
809        }
810
811        // (2) Camp side — same rule (5) check. Even though the result is
812        //     user-attributed, the camp must also be entitled to the
813        //     audience.
814        let camp_entries = self.grants.list_for(&camp, &aud).await?;
815        if camp_entries.is_empty() {
816            return Err(McpMintError::AudNotEntitled {
817                principal: camp.to_string(),
818                aud,
819            });
820        }
821        let camp_scopes = expand_scopes(&self.bundles, &camp_entries).await?;
822        for s in &camp_scopes {
823            validate_grant(&self.scopes, camp.kind, s, &aud)?;
824        }
825
826        // (3) RFC 8693 — every requested scope must be in BOTH principals'
827        //     expanded grants. Any miss → reject the WHOLE exchange (not a
828        //     partial token).
829        for s in &requested_scope {
830            if !user_scopes.contains(s) || !camp_scopes.contains(s) {
831                return Err(McpMintError::InvalidScope {
832                    scope: s.clone(),
833                    aud: aud.clone(),
834                });
835            }
836        }
837
838        // (4) `owns` comes from the CAMP — the camp is the resource context,
839        //     even though `sub` is the user.
840        let rows = self.ownership.list_for_principal(&camp).await?;
841        let owns = rows_to_owns(&rows);
842
843        let camp_id = camp.id.clone();
844        let mut claims = McpClaims::new(
845            self.iss.clone(),
846            aud,
847            user,
848            now,
849            now + self.policy.access_ttl_seconds,
850            generate_jti(),
851            requested_scope,
852        )
853        .with_camp_id(camp_id)
854        .with_auth_strength(AuthStrength::UserFresh);
855        if let Some(a) = actor {
856            claims = claims.with_act(a);
857        }
858        if !owns.is_empty() {
859            claims = claims.with_owns(owns);
860        }
861
862        let token = self.minter.mint_mcp(&claims, &self.kid)?;
863        Ok(MintedMcpToken { token, claims })
864    }
865
866    /// **Mint path 5** — service principal via RFC 7523 client assertion
867    /// (R731-F4, doc D3).
868    ///
869    /// `assertion` is a PASETO v4.public token carrying [`ClientAssertion`]
870    /// claims, signed by one of the principal's own ACTIVE keys and naming it
871    /// in the footer `kid`. Checks, in order: footer kid → key known → key
872    /// active → signature → `iss == sub ==` the key's owner → `aud ==`
873    /// [`token_endpoint`](Self::token_endpoint) → `exp > now` →
874    /// `exp - iat <= 300s` → `jti` consumed (replay rejected). Each failure is
875    /// its own [`McpMintError`] variant.
876    ///
877    /// Scopes are the principal's relationship-derived grants for `aud`
878    /// intersected with `requested_scopes` (an empty request takes every held
879    /// scope); an empty result is [`McpMintError::NoGrantedScopes`]. The
880    /// access token is signed by cheers's issuer key with `sub = svc:<id>`,
881    /// TTL `min(policy.access_ttl, 1h)`, and no ceiling.
882    pub async fn mint_service(
883        &self,
884        assertion: &str,
885        aud: impl Into<String>,
886        requested_scopes: &[Scope],
887        now: i64,
888    ) -> Result<MintedMcpToken, McpMintError> {
889        let aud = aud.into();
890        let svc = self
891            .service
892            .as_ref()
893            .ok_or(McpMintError::ServiceAssertionsUnconfigured)?;
894        let malformed = |m: &str| McpMintError::AssertionMalformed(m.to_owned());
895
896        let untrusted = UntrustedToken::<pasetors::token::Public, V4>::try_from(assertion)
897            .map_err(|_| malformed("not a v4.public token"))?;
898        // The footer is bound into the signature; reading it first only
899        // selects the key.
900        let kid = serde_json::from_slice::<serde_json::Value>(untrusted.untrusted_footer())
901            .ok()
902            .and_then(|f| f.get("kid").and_then(|k| k.as_str()).map(str::to_owned))
903            .ok_or_else(|| malformed("footer has no kid"))?;
904
905        let key = svc
906            .keys
907            .list_all_signing_keys()
908            .await?
909            .into_iter()
910            .find(|k| k.kid == kid)
911            .ok_or_else(|| McpMintError::AssertionUnknownKid(kid.clone()))?;
912        if key.status != SigningKeyStatus::Active {
913            return Err(McpMintError::AssertionRetiredKey(kid));
914        }
915
916        let public = AsymmetricPublicKey::<V4>::from(&key.public_key[..])
917            .map_err(|_| malformed("stored public key is not Ed25519"))?;
918        let trusted = PublicToken::verify(&public, &untrusted, None, None)
919            .map_err(|_| malformed("signature verification failed"))?;
920        let claims: ClientAssertion = serde_json::from_str(trusted.payload())
921            .map_err(|e| McpMintError::AssertionMalformed(format!("payload: {e}")))?;
922
923        if claims.iss != key.principal_id
924            || claims.sub != key.principal_id
925            || key.principal_id.kind != PrincipalKind::Service
926        {
927            return Err(McpMintError::AssertionPrincipalMismatch {
928                kid,
929                owner: key.principal_id,
930                iss: claims.iss,
931                sub: claims.sub,
932            });
933        }
934        let expected = self.token_endpoint();
935        if claims.aud != expected {
936            return Err(McpMintError::AssertionBadAudience {
937                got: claims.aud,
938                expected,
939            });
940        }
941        if claims.exp <= now {
942            return Err(McpMintError::AssertionExpired { exp: claims.exp, now });
943        }
944        let lifetime = claims.exp - claims.iat;
945        if lifetime > ClientAssertion::MAX_LIFETIME_SECONDS {
946            return Err(McpMintError::AssertionLifetimeTooLong {
947                lifetime,
948                max: ClientAssertion::MAX_LIFETIME_SECONDS,
949            });
950        }
951        if claims.jti.is_empty() {
952            return Err(malformed("empty jti"));
953        }
954        // Consume the jti last, so a rejected assertion doesn't burn it.
955        if !svc
956            .used_jtis
957            .try_mark_used(&claims.jti, claims.exp)
958            .await
959            .map_err(McpMintError::JtiStore)?
960        {
961            return Err(McpMintError::AssertionReplayed(claims.jti));
962        }
963
964        let principal = claims.sub;
965        let entries = self.grants.list_for(&principal, &aud).await?;
966        let held = expand_scopes(&self.bundles, &entries).await?;
967        for s in &held {
968            validate_grant(&self.scopes, principal.kind, s, &aud)?;
969        }
970        let scopes: Vec<Scope> = if requested_scopes.is_empty() {
971            held
972        } else {
973            held.into_iter()
974                .filter(|s| requested_scopes.contains(s))
975                .collect()
976        };
977        if scopes.is_empty() {
978            return Err(McpMintError::NoGrantedScopes {
979                principal: principal.to_string(),
980                aud,
981            });
982        }
983
984        let ttl = self.policy.access_ttl_seconds.min(SERVICE_MAX_TTL_SECONDS);
985        let claims = McpClaims::new(
986            self.iss.clone(),
987            aud,
988            principal,
989            now,
990            now + ttl,
991            generate_jti(),
992            scopes,
993        );
994        let token = self.minter.mint_mcp(&claims, &self.kid)?;
995        Ok(MintedMcpToken { token, claims })
996    }
997}
998
999/// Hard cap on a service access token's lifetime (doc D3: TTL <= 1 h).
1000const SERVICE_MAX_TTL_SECONDS: i64 = 60 * 60;
1001
1002/// Intersect what a caller *asked* for with the `ceiling` they are entitled
1003/// to, or take the whole ceiling when they asked for nothing.
1004///
1005/// Shared by both API-token mint paths (R728-F2) — they differ only in where
1006/// the ceiling comes from (the grant table on create, the presented token's
1007/// own claims on rotate), and the intersection rule must not differ at all.
1008/// An unheld scope is [`McpMintError::UnentitledScopes`] naming **every**
1009/// offender: a token silently narrower than the one requested is a debugging
1010/// trap that surfaces days later as an unexplained 403.
1011fn attenuate(
1012    requested: &[Scope],
1013    ceiling: Vec<Scope>,
1014    user: &PrincipalId,
1015    aud: &str,
1016) -> Result<Vec<Scope>, McpMintError> {
1017    if requested.is_empty() {
1018        return Ok(ceiling);
1019    }
1020    let unentitled: Vec<Scope> = requested
1021        .iter()
1022        .filter(|s| !ceiling.contains(s))
1023        .cloned()
1024        .collect();
1025    if !unentitled.is_empty() {
1026        return Err(McpMintError::UnentitledScopes {
1027            principal: user.to_string(),
1028            scopes: unentitled,
1029            aud: aud.to_owned(),
1030        });
1031    }
1032    // Dedupe while preserving the caller's order — `requested` is client
1033    // input and may repeat.
1034    let mut out: Vec<Scope> = Vec::with_capacity(requested.len());
1035    for s in requested {
1036        if !out.contains(s) {
1037            out.push(s.clone());
1038        }
1039    }
1040    Ok(out)
1041}
1042
1043/// Convert ownership rows into the [`Owns`] claim shape. Revoked rows are
1044/// filtered out (the store already excludes them, but a belt-and-braces
1045/// check keeps the wire shape from leaking stale entries if the store
1046/// contract evolves). Unknown resource kinds spill into
1047/// [`Owns::extra`](cheers_core::Owns).
1048///
1049/// Duplicate `(kind, resource_id)` pairs collapse to one entry — ownership
1050/// is set-membership, and while `POST /ownership` is idempotent for
1051/// identical live rows, historical duplicates (or the residual
1052/// concurrent-write race) may still exist in a store. The claim shape must
1053/// not amplify them onto every minted token.
1054fn rows_to_owns(rows: &[OwnershipRow]) -> Owns {
1055    fn push_unique(list: &mut Vec<String>, id: &str) {
1056        if !list.iter().any(|existing| existing == id) {
1057            list.push(id.to_owned());
1058        }
1059    }
1060    let mut o = Owns::default();
1061    for r in rows {
1062        // Kind-level tuples (`kind/<kind>#rel`) are authority, not ownership
1063        // of a resource — they never appear in the owns claim.
1064        if r.is_revoked() || r.resource_kind == cheers_core::KIND_RESOURCE {
1065            continue;
1066        }
1067        match r.resource_kind.as_str() {
1068            "service" => push_unique(&mut o.service, &r.resource_id),
1069            "arch_doc" => push_unique(&mut o.arch_doc, &r.resource_id),
1070            "node" => push_unique(&mut o.node, &r.resource_id),
1071            other => push_unique(
1072                o.extra.entry(other.to_owned()).or_default(),
1073                &r.resource_id,
1074            ),
1075        }
1076    }
1077    o
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083    use crate::bundles::{BundleName, MemoryBundleStore, ScopeOrBundle};
1084    use crate::codec::PasetoV4SecretMinter;
1085    use crate::grants::SchemaGrantStore;
1086    use cheers_core::{RelationDef, ResourceSchema, SchemaRegistry, KIND_RESOURCE};
1087    use std::collections::HashMap;
1088    use crate::ownership::{MemoryOwnershipStore, NewOwnership, OwnershipStore};
1089    use async_trait::async_trait;
1090    use cheers_core::{yah_scopes, Scope, StoreError};
1091    use cheers_verify::PasetoV4PublicVerifier;
1092    use std::sync::Mutex;
1093
1094    // ---- grants ------------------------------------------------------------
1095    //
1096    // Scopes are seeded as ownership tuples through a test schema: kind
1097    // `grant`, one kind-level relation per yah scope (named by its wire form)
1098    // unlocking exactly that scope. Kind-level rows stay out of `owns`, so the
1099    // owns assertions below see only the resource rows each test inserts.
1100    // `raw` stands in for a hand-coded GrantStore (noisetable's
1101    // PublishGrants): bundles and unregistered scopes, which no schema can
1102    // express, go there.
1103
1104    const GRANT_KIND: &str = "grant";
1105
1106    fn test_schema(scopes: &cheers_core::ScopeRegistry) -> SchemaRegistry {
1107        let rels: Vec<RelationDef> = yah_scopes::DEFS
1108            .iter()
1109            .map(|d| RelationDef {
1110                name: Box::leak(d.scope.as_wire().to_owned().into_boxed_str()),
1111                membership: true,
1112                implies: &[],
1113                scopes: Box::leak(vec![d.scope.clone()].into_boxed_slice()),
1114                grants: &[],
1115            })
1116            .collect();
1117        SchemaRegistry::build(
1118            &[ResourceSchema {
1119                kind: GRANT_KIND,
1120                relations: &[],
1121                kind_relations: Box::leak(rels.into_boxed_slice()),
1122            }],
1123            scopes,
1124        )
1125        .unwrap()
1126    }
1127
1128    struct TestGrants {
1129        schema: SchemaGrantStore<MemoryOwnershipStore>,
1130        ownership: MemoryOwnershipStore,
1131        raw: Mutex<HashMap<(PrincipalId, String), Vec<ScopeOrBundle>>>,
1132    }
1133
1134    impl TestGrants {
1135        fn put_raw(&self, principal: PrincipalId, aud: &str, entries: Vec<ScopeOrBundle>) {
1136            self.raw.lock().unwrap().insert((principal, aud.to_owned()), entries);
1137        }
1138    }
1139
1140    #[async_trait]
1141    impl GrantStore for TestGrants {
1142        async fn list_for(
1143            &self,
1144            principal: &PrincipalId,
1145            aud: &str,
1146        ) -> Result<Vec<ScopeOrBundle>, StoreError> {
1147            let mut out = self.schema.list_for(principal, aud).await?;
1148            if let Some(raw) = self.raw.lock().unwrap().get(&(principal.clone(), aud.to_owned())) {
1149                out.extend(raw.iter().cloned());
1150            }
1151            Ok(out)
1152        }
1153    }
1154
1155    // ---- assembly ----------------------------------------------------------
1156
1157    /// `kid` this test rig's authority stamps into every mint — tests that
1158    /// verify a minted token must pass the same value to `verify_mcp_at`.
1159    const RIG_KID: &str = "mcp-authority-test-kid-1";
1160
1161    fn rig() -> (
1162        McpAuthority<MemoryBundleStore, TestGrants, MemoryOwnershipStore>,
1163        PasetoV4PublicVerifier,
1164    ) {
1165        let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
1166        let bundles = MemoryBundleStore::with_defaults();
1167        let ownership = MemoryOwnershipStore::new();
1168        let scopes = Arc::new(
1169            yah_scopes::registry_at([
1170                "https://aud.example",
1171                "https://kamaji.camp.example",
1172                "https://kamaji.example",
1173            ])
1174            .unwrap(),
1175        );
1176        let grants = TestGrants {
1177            schema: SchemaGrantStore::new(
1178                ownership.clone(),
1179                Arc::new(test_schema(&scopes)),
1180                scopes.clone(),
1181            ),
1182            ownership: ownership.clone(),
1183            raw: Mutex::default(),
1184        };
1185        let authority = McpAuthority::new(
1186            minter,
1187            bundles,
1188            grants,
1189            ownership,
1190            scopes,
1191            "https://cheers.example",
1192            RIG_KID,
1193        );
1194        (authority, verifier)
1195    }
1196
1197    /// Scopes become `kind/grant#<scope>` tuples; bundles go to the raw
1198    /// store. `aud` only matters for raw entries — derived scopes are filtered
1199    /// by the registry's audiences (all `Any` for yah).
1200    fn put_simple_grant(
1201        authority: &McpAuthority<MemoryBundleStore, TestGrants, MemoryOwnershipStore>,
1202        principal: PrincipalId,
1203        aud: &str,
1204        entries: Vec<ScopeOrBundle>,
1205    ) {
1206        let mut raw = Vec::new();
1207        for e in entries {
1208            match e {
1209                ScopeOrBundle::Scope(scope) => {
1210                    let n = NewOwnership::new(
1211                        principal.clone(),
1212                        KIND_RESOURCE,
1213                        GRANT_KIND,
1214                        scope.as_wire(),
1215                        PrincipalId::service("seed"),
1216                        None,
1217                    )
1218                    .unwrap();
1219                    pollster::block_on(authority.grants.ownership.insert(&n, 1)).unwrap();
1220                }
1221                bundle => raw.push(bundle),
1222            }
1223        }
1224        if !raw.is_empty() {
1225            authority.grants.put_raw(principal, aud, raw);
1226        }
1227    }
1228
1229    // ---- McpPolicy ---------------------------------------------------------
1230
1231    #[test]
1232    fn mcp_policy_default_is_ten_minutes() {
1233        let p = McpPolicy::default();
1234        assert_eq!(p.access_ttl_seconds, 10 * 60);
1235        let p = McpPolicy::new(300);
1236        assert_eq!(p.access_ttl_seconds, 300);
1237        let p = McpPolicy::default().with_access_ttl(5 * 60);
1238        assert_eq!(p.access_ttl_seconds, 5 * 60);
1239    }
1240
1241    // ---- mint_user_fresh: success paths ------------------------------------
1242
1243    #[test]
1244    fn mint_user_fresh_signs_token_verifiable_at_edge() {
1245        let (authority, verifier) = rig();
1246        let user = PrincipalId::user("alice");
1247        let aud = "https://kamaji.camp.example";
1248        put_simple_grant(
1249            &authority,
1250            user.clone(),
1251            aud,
1252            vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY), ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
1253        );
1254
1255        pollster::block_on(async {
1256            let minted = authority
1257                .mint_user_fresh(user.clone(), None, None, aud, 1_000)
1258                .await
1259                .unwrap();
1260
1261            // Token starts with v4.public — the doc-pinned envelope.
1262            assert!(minted.token.starts_with("v4.public."));
1263            // auth_strength is user-fresh on this path.
1264            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
1265            // jti is non-empty + iat/exp respect the default TTL (10 min).
1266            assert!(!minted.claims.jti.is_empty());
1267            assert_eq!(minted.claims.iat, 1_000);
1268            assert_eq!(
1269                minted.claims.exp,
1270                1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1271            );
1272            // Edge verifies the freshly minted token under the public key.
1273            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1274            assert_eq!(back, minted.claims);
1275            assert_eq!(back.sub, user);
1276            assert_eq!(back.scope, vec![yah_scopes::CLOUD_DEPLOY, yah_scopes::CLOUD_READ]);
1277        });
1278    }
1279
1280    #[test]
1281    fn mint_user_fresh_expands_bundles_at_mint_time() {
1282        // A grant of `{"bundle":"deploy-admin"}` lands on the wire as the
1283        // literal scope list (rule (2)). Edit the bundle, re-mint, see the
1284        // change without rewriting the grant.
1285        let (authority, _verifier) = rig();
1286        let user = PrincipalId::user("bob");
1287        let aud = "https://kamaji.example";
1288        put_simple_grant(
1289            &authority,
1290            user.clone(),
1291            aud,
1292            vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
1293        );
1294
1295        pollster::block_on(async {
1296            let first = authority
1297                .mint_user_fresh(user.clone(), None, None, aud, 1_000)
1298                .await
1299                .unwrap();
1300            assert!(first.claims.scope.contains(&yah_scopes::CLOUD_DESTROY));
1301
1302            // Edit the bundle: drop CloudDestroy. Grant is untouched.
1303            authority
1304                .bundles
1305                .put(
1306                    &BundleName::new("deploy-admin"),
1307                    &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
1308                )
1309                .await
1310                .unwrap();
1311
1312            let second = authority
1313                .mint_user_fresh(user, None, None, aud, 1_100)
1314                .await
1315                .unwrap();
1316            assert!(!second.claims.scope.contains(&yah_scopes::CLOUD_DESTROY));
1317            assert_eq!(second.claims.scope, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]);
1318        });
1319    }
1320
1321    #[test]
1322    fn mint_user_fresh_populates_act_camp_id_and_owns() {
1323        let (authority, verifier) = rig();
1324        let user = PrincipalId::user("carol");
1325        let camp_id = "camp-xyz";
1326        let aud = "https://aud.example";
1327        put_simple_grant(
1328            &authority,
1329            user.clone(),
1330            aud,
1331            vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
1332        );
1333        // Seed two owned resources under the camp principal.
1334        pollster::block_on(async {
1335            let svc = NewOwnership::new(
1336                PrincipalId::camp(camp_id),
1337                "service",
1338                "svc-a",
1339                "owns",
1340                PrincipalId::service("yubaba"),
1341                Some(user.clone()),
1342            )
1343            .unwrap();
1344            authority.ownership.insert(&svc, 500).await.unwrap();
1345
1346            let doc = NewOwnership::new(
1347                PrincipalId::camp(camp_id),
1348                "arch_doc",
1349                "doc-1",
1350                "owns",
1351                PrincipalId::service("yubaba"),
1352                Some(user.clone()),
1353            )
1354            .unwrap();
1355            authority.ownership.insert(&doc, 500).await.unwrap();
1356
1357            let minted = authority
1358                .mint_user_fresh(
1359                    user.clone(),
1360                    Some(Actor::new(PrincipalId::service("agent-claude"))),
1361                    Some(camp_id.to_owned()),
1362                    aud,
1363                    1_000,
1364                )
1365                .await
1366                .unwrap();
1367
1368            assert_eq!(
1369                minted.claims.act.as_ref().unwrap().sub,
1370                PrincipalId::service("agent-claude")
1371            );
1372            assert_eq!(minted.claims.camp_id.as_deref(), Some(camp_id));
1373            assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
1374            assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
1375
1376            // Edge accepts it.
1377            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1378            assert_eq!(back, minted.claims);
1379        });
1380    }
1381
1382    #[test]
1383    fn mint_user_fresh_omits_owns_when_camp_owns_nothing() {
1384        // No ownership rows + camp_id present → owns is empty/omitted on the
1385        // wire. The claim skip_serializing_if guards this; the test pins it.
1386        let (authority, _verifier) = rig();
1387        let user = PrincipalId::user("dan");
1388        let aud = "https://aud.example";
1389        put_simple_grant(
1390            &authority,
1391            user.clone(),
1392            aud,
1393            vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
1394        );
1395
1396        pollster::block_on(async {
1397            let minted = authority
1398                .mint_user_fresh(user, None, Some("camp-empty".into()), aud, 1_000)
1399                .await
1400                .unwrap();
1401            assert!(minted.claims.owns.is_empty());
1402            let json = serde_json::to_string(&minted.claims).unwrap();
1403            assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
1404        });
1405    }
1406
1407    // ---- mint_user_fresh: rejection paths ----------------------------------
1408
1409    #[test]
1410    fn mint_user_fresh_rejects_unentitled_aud() {
1411        let (authority, _verifier) = rig();
1412        let user = PrincipalId::user("eve");
1413        // No grant placed for any aud.
1414        pollster::block_on(async {
1415            let err = authority
1416                .mint_user_fresh(user, None, None, "https://aud.example", 1_000)
1417                .await
1418                .unwrap_err();
1419            match err {
1420                McpMintError::AudNotEntitled { principal, aud } => {
1421                    assert_eq!(principal, "user:eve");
1422                    assert_eq!(aud, "https://aud.example");
1423                }
1424                other => panic!("expected AudNotEntitled, got {other:?}"),
1425            }
1426        });
1427    }
1428
1429    #[test]
1430    fn mint_user_fresh_rejects_non_user_principal() {
1431        let (authority, _verifier) = rig();
1432        pollster::block_on(async {
1433            let err = authority
1434                .mint_user_fresh(
1435                    PrincipalId::service("yubaba"),
1436                    None,
1437                    None,
1438                    "https://aud.example",
1439                    1_000,
1440                )
1441                .await
1442                .unwrap_err();
1443            match err {
1444                McpMintError::WrongPrincipalKind { expected, got } => {
1445                    assert_eq!(expected, PrincipalKind::User);
1446                    assert_eq!(got, PrincipalKind::Service);
1447                }
1448                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1449            }
1450        });
1451    }
1452
1453    // ---- mint_api_token (R728-F1) ------------------------------------------
1454
1455    #[test]
1456    fn mint_api_token_defaults_to_ninety_days_and_the_full_effective_scope_set() {
1457        let (authority, verifier) = rig();
1458        let user = PrincipalId::user("alice");
1459        let aud = "https://kamaji.example";
1460        put_simple_grant(
1461            &authority,
1462            user.clone(),
1463            aud,
1464            vec![
1465                ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
1466                ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
1467            ],
1468        );
1469
1470        pollster::block_on(async {
1471            let minted = authority
1472                .mint_api_token(user.clone(), aud, &[], None, 1_000)
1473                .await
1474                .unwrap();
1475            assert_eq!(
1476                minted.claims.exp,
1477                1_000 + McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
1478            );
1479            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
1480            assert_eq!(minted.claims.sub, user);
1481            // Empty `requested` = everything held for this aud, frozen now.
1482            // Derived grants come back in scope order, not seed order.
1483            assert_eq!(
1484                minted.claims.scope,
1485                vec![yah_scopes::CLOUD_DEPLOY, yah_scopes::CLOUD_READ]
1486            );
1487            // Same signing key, same envelope — it verifies at the ordinary
1488            // edge with no new verification path.
1489            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1490            assert_eq!(back, minted.claims);
1491        });
1492    }
1493
1494    /// The whole point: a PAT is never wider than its minter. Asking for a
1495    /// subset yields exactly that subset.
1496    #[test]
1497    fn mint_api_token_attenuates_to_the_requested_subset() {
1498        let (authority, _verifier) = rig();
1499        let user = PrincipalId::user("alice");
1500        let aud = "https://kamaji.example";
1501        put_simple_grant(
1502            &authority,
1503            user.clone(),
1504            aud,
1505            vec![
1506                ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
1507                ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
1508                ScopeOrBundle::Scope(yah_scopes::CLOUD_DESTROY),
1509            ],
1510        );
1511
1512        pollster::block_on(async {
1513            let minted = authority
1514                .mint_api_token(user, aud, &[yah_scopes::CLOUD_READ], None, 1_000)
1515                .await
1516                .unwrap();
1517            assert_eq!(minted.claims.scope, vec![yah_scopes::CLOUD_READ]);
1518        });
1519    }
1520
1521    /// A scope the caller does not hold is NAMED, not silently dropped. A
1522    /// token quietly weaker than the one asked for fails days later somewhere
1523    /// else as an unexplained 403.
1524    #[test]
1525    fn mint_api_token_names_every_unentitled_scope_instead_of_dropping_it() {
1526        let (authority, _verifier) = rig();
1527        let user = PrincipalId::user("alice");
1528        let aud = "https://kamaji.example";
1529        put_simple_grant(
1530            &authority,
1531            user.clone(),
1532            aud,
1533            vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
1534        );
1535
1536        pollster::block_on(async {
1537            let err = authority
1538                .mint_api_token(
1539                    user,
1540                    aud,
1541                    &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DESTROY, yah_scopes::CAMP_ADMIN],
1542                    None,
1543                    1_000,
1544                )
1545                .await
1546                .unwrap_err();
1547            match err {
1548                McpMintError::UnentitledScopes { ref scopes, .. } => {
1549                    assert_eq!(scopes, &vec![yah_scopes::CLOUD_DESTROY, yah_scopes::CAMP_ADMIN]);
1550                    let msg = err.to_string();
1551                    assert!(msg.contains("cloud:destroy"), "{msg}");
1552                    assert!(msg.contains("camp:admin"), "{msg}");
1553                }
1554                other => panic!("expected UnentitledScopes, got {other:?}"),
1555            }
1556        });
1557    }
1558
1559    /// Composition rule (4) defence in depth applies to this path too: a
1560    /// bundle smuggling a service-only scope into a user grant is caught
1561    /// BEFORE signing, exactly as on `mint_user_fresh`.
1562    #[test]
1563    fn mint_api_token_rejects_service_only_scope_smuggled_via_bundle() {
1564        let (authority, _verifier) = rig();
1565        let user = PrincipalId::user("mallory");
1566        let aud = "https://aud.example";
1567
1568        pollster::block_on(async {
1569            authority
1570                .bundles
1571                .put(
1572                    &BundleName::new("dangerous"),
1573                    &[yah_scopes::CAMP_READ, yah_scopes::AUDIT_WRITE],
1574                )
1575                .await
1576                .unwrap();
1577            authority.grants.put_raw(
1578                user.clone(),
1579                aud,
1580                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1581            );
1582            match authority.mint_api_token(user, aud, &[], None, 1_000).await {
1583                Err(McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope {
1584                    scope,
1585                    kind,
1586                })) => {
1587                    assert_eq!(scope, yah_scopes::AUDIT_WRITE);
1588                    assert_eq!(kind, PrincipalKind::User);
1589                }
1590                other => panic!("expected GrantMisconfigured, got {other:?}"),
1591            }
1592        });
1593    }
1594
1595    #[test]
1596    fn mint_api_token_rejects_an_out_of_range_ttl_rather_than_clamping() {
1597        let (authority, _verifier) = rig();
1598        let user = PrincipalId::user("alice");
1599        let aud = "https://kamaji.example";
1600        put_simple_grant(
1601            &authority,
1602            user.clone(),
1603            aud,
1604            vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
1605        );
1606
1607        pollster::block_on(async {
1608            for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
1609                match authority
1610                    .mint_api_token(user.clone(), aud, &[], Some(bad), 1_000)
1611                    .await
1612                {
1613                    Err(McpMintError::TtlOutOfRange { requested, max }) => {
1614                        assert_eq!(requested, bad);
1615                        assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
1616                    }
1617                    other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
1618                }
1619            }
1620            // The ceiling itself is accepted — the range is inclusive.
1621            let ok = authority
1622                .mint_api_token(
1623                    user,
1624                    aud,
1625                    &[],
1626                    Some(McpPolicy::MAX_API_TOKEN_TTL_SECONDS),
1627                    1_000,
1628                )
1629                .await
1630                .unwrap();
1631            assert_eq!(
1632                ok.claims.exp,
1633                1_000 + McpPolicy::MAX_API_TOKEN_TTL_SECONDS
1634            );
1635        });
1636    }
1637
1638    #[test]
1639    fn mint_api_token_rejects_an_unentitled_aud_before_signing() {
1640        let (authority, _verifier) = rig();
1641        let user = PrincipalId::user("alice");
1642        pollster::block_on(async {
1643            match authority
1644                .mint_api_token(user, "https://nothing-granted.example", &[], None, 1_000)
1645                .await
1646            {
1647                Err(McpMintError::AudNotEntitled { principal, aud }) => {
1648                    assert_eq!(principal, "user:alice");
1649                    assert_eq!(aud, "https://nothing-granted.example");
1650                }
1651                other => panic!("expected AudNotEntitled, got {other:?}"),
1652            }
1653        });
1654    }
1655
1656    /// `owns` is a mint-time snapshot whose staleness bound is the token's
1657    /// lifetime. At 10 minutes that is fine; at 90 days it would be a
1658    /// stale-authorization hole, so a PAT carries none — nor a `camp_id`
1659    /// (whose job is keying that lookup) nor an `act` (nobody is acting on
1660    /// the user's behalf; they minted it themselves).
1661    #[test]
1662    fn mint_api_token_carries_no_owns_no_camp_id_and_no_act() {
1663        let (authority, _verifier) = rig();
1664        let user = PrincipalId::user("alice");
1665        let aud = "https://kamaji.example";
1666        put_simple_grant(
1667            &authority,
1668            user.clone(),
1669            aud,
1670            vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
1671        );
1672
1673        pollster::block_on(async {
1674            let minted = authority
1675                .mint_api_token(user, aud, &[], None, 1_000)
1676                .await
1677                .unwrap();
1678            assert!(minted.claims.owns.is_empty());
1679            assert_eq!(minted.claims.camp_id, None);
1680            assert_eq!(minted.claims.act, None);
1681        });
1682    }
1683
1684    #[test]
1685    fn mint_api_token_rejects_a_non_user_principal() {
1686        let (authority, _verifier) = rig();
1687        pollster::block_on(async {
1688            match authority
1689                .mint_api_token(PrincipalId::camp("c1"), "https://aud.example", &[], None, 1)
1690                .await
1691            {
1692                Err(McpMintError::WrongPrincipalKind { expected, got }) => {
1693                    assert_eq!(expected, PrincipalKind::User);
1694                    assert_eq!(got, PrincipalKind::Camp);
1695                }
1696                other => panic!("expected WrongPrincipalKind, got {other:?}"),
1697            }
1698        });
1699    }
1700
1701    // ---- rotate_api_token (R728-F2) ----------------------------------------
1702
1703    /// The whole point of the rotate path: the ceiling is the presented
1704    /// token's own scopes, so an EMPTY grant table still rolls a token.
1705    ///
1706    /// Stated as a contrast, because the contrast IS the property — the same
1707    /// inputs through `mint_api_token` are an `AudNotEntitled` 403. If this
1708    /// test ever starts needing a grant, someone has put a `grants.list_for`
1709    /// back on the rotate path and a frozen token can silently re-widen
1710    /// itself to whatever the grant table says today.
1711    #[test]
1712    fn rotate_api_token_mints_from_the_presented_scopes_without_reading_grants() {
1713        let (authority, verifier) = rig();
1714        let user = PrincipalId::user("alice");
1715        let aud = "https://kamaji.example";
1716        // No grant, deliberately.
1717
1718        let minted = authority
1719            .rotate_api_token(
1720                user.clone(),
1721                aud,
1722                &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
1723                &[],
1724                None,
1725                1_000,
1726            )
1727            .unwrap();
1728        assert_eq!(minted.claims.scope, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]);
1729        assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
1730        assert_eq!(minted.claims.sub, user.clone());
1731        assert_eq!(minted.claims.aud, aud);
1732        assert!(minted.claims.owns.is_empty());
1733        // A real token on the same stack, verifiable by the ordinary verifier.
1734        let claims = verifier.verify_mcp_at(&minted.token, 1_001, RIG_KID).unwrap();
1735        assert_eq!(claims.jti, minted.claims.jti);
1736
1737        // And the contrast: the same call through the create path is refused.
1738        pollster::block_on(async {
1739            assert!(matches!(
1740                authority.mint_api_token(user, aud, &[], None, 1_000).await,
1741                Err(McpMintError::AudNotEntitled { .. })
1742            ));
1743        });
1744    }
1745
1746    #[test]
1747    fn rotate_api_token_can_narrow_but_never_widen() {
1748        let (authority, _verifier) = rig();
1749        let user = PrincipalId::user("alice");
1750        let aud = "https://kamaji.example";
1751        // A wide grant table is present and must be ignored.
1752        put_simple_grant(
1753            &authority,
1754            user.clone(),
1755            aud,
1756            vec![
1757                ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
1758                ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
1759                ScopeOrBundle::Scope(yah_scopes::CLOUD_DESTROY),
1760            ],
1761        );
1762
1763        // Narrowing works.
1764        let narrowed = authority
1765            .rotate_api_token(
1766                user.clone(),
1767                aud,
1768                &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
1769                &[yah_scopes::CLOUD_READ],
1770                None,
1771                1_000,
1772            )
1773            .unwrap();
1774        assert_eq!(narrowed.claims.scope, vec![yah_scopes::CLOUD_READ]);
1775
1776        // Widening — to a scope the GRANT holds but the token does not — is
1777        // refused by name.
1778        match authority.rotate_api_token(
1779            user,
1780            aud,
1781            &[yah_scopes::CLOUD_READ],
1782            &[yah_scopes::CLOUD_DESTROY],
1783            None,
1784            1_000,
1785        ) {
1786            Err(McpMintError::UnentitledScopes { scopes, .. }) => {
1787                assert_eq!(scopes, vec![yah_scopes::CLOUD_DESTROY]);
1788            }
1789            other => panic!("expected UnentitledScopes, got {other:?}"),
1790        }
1791    }
1792
1793    /// Rotation refreshes the clock — that is what keeps a long-lived
1794    /// credential alive — but under the SAME ceiling a first mint is held to,
1795    /// so repeated hops cannot extend authority indefinitely.
1796    #[test]
1797    fn rotate_api_token_gets_a_fresh_ttl_under_the_same_ceiling() {
1798        let (authority, _verifier) = rig();
1799        let user = PrincipalId::user("alice");
1800        let aud = "https://kamaji.example";
1801
1802        let default_ttl = authority
1803            .rotate_api_token(user.clone(), aud, &[yah_scopes::CLOUD_READ], &[], None, 5_000)
1804            .unwrap();
1805        assert_eq!(
1806            default_ttl.claims.exp - default_ttl.claims.iat,
1807            McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
1808        );
1809        assert_eq!(default_ttl.claims.iat, 5_000, "the clock restarts at `now`");
1810
1811        for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
1812            match authority.rotate_api_token(
1813                user.clone(),
1814                aud,
1815                &[yah_scopes::CLOUD_READ],
1816                &[],
1817                Some(bad),
1818                5_000,
1819            ) {
1820                Err(McpMintError::TtlOutOfRange { requested, max }) => {
1821                    assert_eq!(requested, bad);
1822                    assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
1823                }
1824                other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
1825            }
1826        }
1827    }
1828
1829    /// Composition rule (4) is not laundered by a re-sign: a service-only
1830    /// scope sitting in a user's token is refused on the way out, wherever it
1831    /// came from.
1832    #[test]
1833    fn rotate_api_token_rejects_a_service_only_scope_in_the_presented_token() {
1834        let (authority, _verifier) = rig();
1835        assert!(matches!(
1836            authority.rotate_api_token(
1837                PrincipalId::user("alice"),
1838                "https://kamaji.example",
1839                &[yah_scopes::AUDIT_WRITE],
1840                &[],
1841                None,
1842                1_000,
1843            ),
1844            Err(McpMintError::GrantMisconfigured(_))
1845        ));
1846    }
1847
1848    #[test]
1849    fn rotate_api_token_rejects_a_non_user_principal() {
1850        let (authority, _verifier) = rig();
1851        match authority.rotate_api_token(
1852            PrincipalId::camp("c1"),
1853            "https://aud.example",
1854            &[yah_scopes::CLOUD_READ],
1855            &[],
1856            None,
1857            1,
1858        ) {
1859            Err(McpMintError::WrongPrincipalKind { expected, got }) => {
1860                assert_eq!(expected, PrincipalKind::User);
1861                assert_eq!(got, PrincipalKind::Camp);
1862            }
1863            other => panic!("expected WrongPrincipalKind, got {other:?}"),
1864        }
1865    }
1866
1867    #[test]
1868    fn mint_user_fresh_rejects_scope_missing_from_registry() {
1869        // R731-F2: a grant row naming a well-formed scope the deployment's
1870        // registry does not declare is refused at mint, before signing.
1871        let (authority, _verifier) = rig();
1872        let user = PrincipalId::user("alice");
1873        let aud = "https://aud.example";
1874        let unknown: Scope = "cloud:nuke".parse().unwrap();
1875        authority.grants.put_raw(
1876            user.clone(),
1877            aud,
1878            vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ), ScopeOrBundle::Scope(unknown.clone())],
1879        );
1880        let err = pollster::block_on(authority.mint_user_fresh(user, None, None, aud, 1_000))
1881            .unwrap_err();
1882        match err {
1883            McpMintError::GrantMisconfigured(GrantError::UnknownScope { scope }) => {
1884                assert_eq!(scope, unknown);
1885            }
1886            other => panic!("expected UnknownScope, got {other:?}"),
1887        }
1888    }
1889
1890    #[test]
1891    fn mint_user_fresh_rejects_service_only_scope_smuggled_via_bundle() {
1892        // Defence in depth for composition rule (4): if a bundle granted to
1893        // a User principal contains audit:write, the mint path catches
1894        // it via validate_grant BEFORE signing — the misconfigured bundle
1895        // never becomes a mintable token.
1896        let (authority, _verifier) = rig();
1897        let user = PrincipalId::user("mallory");
1898        let aud = "https://aud.example";
1899
1900        pollster::block_on(async {
1901            authority
1902                .bundles
1903                .put(
1904                    &BundleName::new("dangerous"),
1905                    &[yah_scopes::CAMP_READ, yah_scopes::AUDIT_WRITE],
1906                )
1907                .await
1908                .unwrap();
1909            authority.grants.put_raw(
1910                user.clone(),
1911                aud,
1912                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1913            );
1914
1915            let err = authority
1916                .mint_user_fresh(user, None, None, aud, 1_000)
1917                .await
1918                .unwrap_err();
1919            match err {
1920                McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1921                    assert_eq!(scope, yah_scopes::AUDIT_WRITE);
1922                    assert_eq!(kind, PrincipalKind::User);
1923                }
1924                other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1925            }
1926        });
1927    }
1928
1929    // ---- mint_bootstrap: success paths -------------------------------------
1930
1931    #[test]
1932    fn mint_bootstrap_signs_token_verifiable_at_edge() {
1933        let (authority, verifier) = rig();
1934        let camp = PrincipalId::camp("c-xyz");
1935        let aud = "https://kamaji.camp.example";
1936        put_simple_grant(
1937            &authority,
1938            camp.clone(),
1939            aud,
1940            vec![
1941                ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
1942                ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
1943            ],
1944        );
1945
1946        pollster::block_on(async {
1947            let minted = authority
1948                .mint_bootstrap(camp.clone(), aud, 1_000)
1949                .await
1950                .unwrap();
1951
1952            // Same v4.public envelope as mint path 1.
1953            assert!(minted.token.starts_with("v4.public."));
1954            // auth_strength is bootstrap on this path.
1955            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::Bootstrap));
1956            // sub is the camp principal; camp_id is the bare id (no prefix) so
1957            // a consumer doesn't have to re-parse sub.
1958            assert_eq!(minted.claims.sub, camp);
1959            assert_eq!(minted.claims.camp_id.as_deref(), Some("c-xyz"));
1960            // No act claim — bootstrap is the camp acting as itself, not a
1961            // user acted-on-by an agent.
1962            assert!(minted.claims.act.is_none());
1963            assert!(!minted.claims.jti.is_empty());
1964            assert_eq!(minted.claims.iat, 1_000);
1965            assert_eq!(
1966                minted.claims.exp,
1967                1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1968            );
1969
1970            // Edge verifies the freshly minted token under the public key —
1971            // no per-call cheers round trip.
1972            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1973            assert_eq!(back, minted.claims);
1974            assert_eq!(back.scope, vec![yah_scopes::CLOUD_DEPLOY, yah_scopes::CLOUD_READ]);
1975        });
1976    }
1977
1978    #[test]
1979    fn mint_bootstrap_owns_reflects_ownership_table_state() {
1980        let (authority, _verifier) = rig();
1981        let camp = PrincipalId::camp("c-with-owns");
1982        let user = PrincipalId::user("u-on-behalf");
1983        let aud = "https://aud.example";
1984        put_simple_grant(
1985            &authority,
1986            camp.clone(),
1987            aud,
1988            vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
1989        );
1990
1991        pollster::block_on(async {
1992            // Seed two owned resources under the camp principal — the camp is
1993            // the holder, the user is the human on whose behalf the grant
1994            // happened (W159 §audit / on_behalf_of).
1995            let svc = NewOwnership::new(
1996                camp.clone(),
1997                "service",
1998                "svc-a",
1999                "owns",
2000                PrincipalId::service("yubaba"),
2001                Some(user.clone()),
2002            )
2003            .unwrap();
2004            authority.ownership.insert(&svc, 500).await.unwrap();
2005
2006            let doc = NewOwnership::new(
2007                camp.clone(),
2008                "arch_doc",
2009                "doc-1",
2010                "owns",
2011                PrincipalId::service("yubaba"),
2012                Some(user.clone()),
2013            )
2014            .unwrap();
2015            authority.ownership.insert(&doc, 500).await.unwrap();
2016
2017            let minted = authority
2018                .mint_bootstrap(camp.clone(), aud, 1_000)
2019                .await
2020                .unwrap();
2021
2022            assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
2023            assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
2024        });
2025    }
2026
2027    #[test]
2028    fn rows_to_owns_dedups_duplicate_resource_ids() {
2029        // Historical duplicate rows (pre-idempotent-create stores, or the
2030        // residual concurrent-write race) must collapse to one entry per
2031        // (kind, resource_id) — the owns claim is set-membership.
2032        let camp = PrincipalId::camp("c-dup");
2033        let svc = PrincipalId::service("yubaba");
2034        let mk = |id: &str, kind: &str, rid: &str| {
2035            OwnershipRow::new(
2036                id.to_owned(),
2037                camp.clone().into(),
2038                kind.to_owned(),
2039                rid.to_owned(),
2040                "owns".to_owned(),
2041                svc.clone(),
2042                None,
2043                500,
2044                None,
2045            )
2046        };
2047        let rows = vec![
2048            mk("row-1", "service", "svc-a"),
2049            mk("row-2", "service", "svc-a"), // duplicate
2050            mk("row-3", "service", "svc-b"),
2051            mk("row-4", "node", "aa11"),
2052            mk("row-5", "node", "aa11"), // duplicate
2053            mk("row-6", "pond", "p-1"),
2054            mk("row-7", "pond", "p-1"), // duplicate in the extra spill
2055        ];
2056
2057        let owns = rows_to_owns(&rows);
2058        assert_eq!(owns.service, vec!["svc-a".to_string(), "svc-b".to_string()]);
2059        assert_eq!(owns.node, vec!["aa11".to_string()]);
2060        assert_eq!(
2061            owns.extra.get("pond"),
2062            Some(&vec!["p-1".to_string()])
2063        );
2064    }
2065
2066    #[test]
2067    fn mint_bootstrap_omits_owns_when_camp_owns_nothing() {
2068        // No ownership rows → owns is empty and omitted on the wire. Same
2069        // skip_serializing_if guard as mint path 1, pinned independently here
2070        // because the bootstrap mint path always sets camp_id (a token whose
2071        // sub is `camp:<C>` is always scoped to that camp).
2072        let (authority, _verifier) = rig();
2073        let camp = PrincipalId::camp("c-empty");
2074        let aud = "https://aud.example";
2075        put_simple_grant(
2076            &authority,
2077            camp.clone(),
2078            aud,
2079            vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
2080        );
2081
2082        pollster::block_on(async {
2083            let minted = authority
2084                .mint_bootstrap(camp, aud, 1_000)
2085                .await
2086                .unwrap();
2087            assert!(minted.claims.owns.is_empty());
2088            let json = serde_json::to_string(&minted.claims).unwrap();
2089            assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
2090            // camp_id is still present even when owns is empty.
2091            assert!(json.contains("\"camp_id\":\"c-empty\""), "camp_id must remain: {json}");
2092        });
2093    }
2094
2095    #[test]
2096    fn mint_bootstrap_expands_bundles_at_mint_time() {
2097        // Same propagation property as F5/F6: a bundle edit shows up on the
2098        // next mint without rewriting the camp's grant.
2099        let (authority, _verifier) = rig();
2100        let camp = PrincipalId::camp("c-deploy");
2101        let aud = "https://aud.example";
2102        put_simple_grant(
2103            &authority,
2104            camp.clone(),
2105            aud,
2106            vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
2107        );
2108
2109        pollster::block_on(async {
2110            let first = authority
2111                .mint_bootstrap(camp.clone(), aud, 1_000)
2112                .await
2113                .unwrap();
2114            assert!(first.claims.scope.contains(&yah_scopes::CLOUD_DESTROY));
2115
2116            authority
2117                .bundles
2118                .put(
2119                    &BundleName::new("deploy-admin"),
2120                    &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
2121                )
2122                .await
2123                .unwrap();
2124
2125            let second = authority
2126                .mint_bootstrap(camp, aud, 1_100)
2127                .await
2128                .unwrap();
2129            assert!(!second.claims.scope.contains(&yah_scopes::CLOUD_DESTROY));
2130            assert_eq!(second.claims.scope, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]);
2131        });
2132    }
2133
2134    // ---- mint_bootstrap: rejection paths -----------------------------------
2135
2136    #[test]
2137    fn mint_bootstrap_rejects_non_camp_principal() {
2138        let (authority, _verifier) = rig();
2139        pollster::block_on(async {
2140            // A user principal handed to the bootstrap path is a programmer
2141            // error — surfaces WrongPrincipalKind, not a 403.
2142            let err = authority
2143                .mint_bootstrap(
2144                    PrincipalId::user("alice"),
2145                    "https://aud.example",
2146                    1_000,
2147                )
2148                .await
2149                .unwrap_err();
2150            match err {
2151                McpMintError::WrongPrincipalKind { expected, got } => {
2152                    assert_eq!(expected, PrincipalKind::Camp);
2153                    assert_eq!(got, PrincipalKind::User);
2154                }
2155                other => panic!("expected WrongPrincipalKind, got {other:?}"),
2156            }
2157
2158            // Same for a service principal.
2159            let err = authority
2160                .mint_bootstrap(
2161                    PrincipalId::service("yubaba"),
2162                    "https://aud.example",
2163                    1_000,
2164                )
2165                .await
2166                .unwrap_err();
2167            match err {
2168                McpMintError::WrongPrincipalKind { expected, got } => {
2169                    assert_eq!(expected, PrincipalKind::Camp);
2170                    assert_eq!(got, PrincipalKind::Service);
2171                }
2172                other => panic!("expected WrongPrincipalKind, got {other:?}"),
2173            }
2174        });
2175    }
2176
2177    #[test]
2178    fn mint_bootstrap_rejects_unentitled_aud() {
2179        let (authority, _verifier) = rig();
2180        let camp = PrincipalId::camp("c-no-grants");
2181        // No grant placed for any aud — composition rule (5).
2182        pollster::block_on(async {
2183            let err = authority
2184                .mint_bootstrap(camp, "https://aud.example", 1_000)
2185                .await
2186                .unwrap_err();
2187            match err {
2188                McpMintError::AudNotEntitled { principal, aud } => {
2189                    assert_eq!(principal, "camp:c-no-grants");
2190                    assert_eq!(aud, "https://aud.example");
2191                }
2192                other => panic!("expected AudNotEntitled, got {other:?}"),
2193            }
2194        });
2195    }
2196
2197    #[test]
2198    fn mint_bootstrap_rejects_service_only_scope_smuggled_via_bundle() {
2199        // Composition rule (4) — `audit:write` is
2200        // grantable to kind=service ONLY. A bundle granted to a camp
2201        // principal that expands to a service-only scope is rejected by
2202        // validate_grant BEFORE signing, mirroring F6's defence in depth on
2203        // the user path.
2204        let (authority, _verifier) = rig();
2205        let camp = PrincipalId::camp("c-sneaky");
2206        let aud = "https://aud.example";
2207
2208        pollster::block_on(async {
2209            authority
2210                .bundles
2211                .put(
2212                    &BundleName::new("dangerous"),
2213                    &[yah_scopes::CAMP_READ, yah_scopes::AUDIT_WRITE],
2214                )
2215                .await
2216                .unwrap();
2217            authority.grants.put_raw(
2218                camp.clone(),
2219                aud,
2220                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
2221            );
2222
2223            let err = authority
2224                .mint_bootstrap(camp, aud, 1_000)
2225                .await
2226                .unwrap_err();
2227            match err {
2228                McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
2229                    assert_eq!(scope, yah_scopes::AUDIT_WRITE);
2230                    assert_eq!(kind, PrincipalKind::Camp);
2231                }
2232                other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
2233            }
2234        });
2235    }
2236
2237    // ---- mint_token_exchange: success paths --------------------------------
2238
2239    #[test]
2240    fn mint_token_exchange_attributes_to_user_with_camp_as_context() {
2241        let (authority, verifier) = rig();
2242        let user = PrincipalId::user("alice");
2243        let camp = PrincipalId::camp("c-multi");
2244        let aud = "https://kamaji.camp.example";
2245
2246        // User holds CloudDeploy + CloudRead for this aud; camp holds the
2247        // same. Requested scope is a subset.
2248        put_simple_grant(
2249            &authority,
2250            user.clone(),
2251            aud,
2252            vec![
2253                ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
2254                ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
2255            ],
2256        );
2257        put_simple_grant(
2258            &authority,
2259            camp.clone(),
2260            aud,
2261            vec![
2262                ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
2263                ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
2264            ],
2265        );
2266
2267        // Camp owns a service — owns claim must come from the camp.
2268        pollster::block_on(async {
2269            let svc = NewOwnership::new(
2270                camp.clone(),
2271                "service",
2272                "svc-prod",
2273                "owns",
2274                PrincipalId::service("yubaba"),
2275                Some(user.clone()),
2276            )
2277            .unwrap();
2278            authority.ownership.insert(&svc, 500).await.unwrap();
2279
2280            let minted = authority
2281                .mint_token_exchange(
2282                    user.clone(),
2283                    camp.clone(),
2284                    Some(Actor::new(PrincipalId::service("agent-claude"))),
2285                    aud,
2286                    vec![yah_scopes::CLOUD_DEPLOY],
2287                    1_000,
2288                )
2289                .await
2290                .unwrap();
2291
2292            assert!(minted.token.starts_with("v4.public."));
2293            // sub is the USER, not the camp — exchange attributes to the
2294            // human even though the camp is the bearer at the HTTP layer.
2295            assert_eq!(minted.claims.sub, user);
2296            // camp_id carries the camp context (bare id, no prefix).
2297            assert_eq!(minted.claims.camp_id.as_deref(), Some("c-multi"));
2298            // act carries the agent variant.
2299            assert_eq!(
2300                minted.claims.act.as_ref().unwrap().sub,
2301                PrincipalId::service("agent-claude"),
2302            );
2303            // user-fresh — the user authenticated locally.
2304            assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
2305            // Result scope is the requested scope (verified in intersection).
2306            assert_eq!(minted.claims.scope, vec![yah_scopes::CLOUD_DEPLOY]);
2307            // owns comes from the CAMP, not the user.
2308            assert_eq!(minted.claims.owns.service, vec!["svc-prod".to_string()]);
2309            // jti present, iat/exp respect policy.
2310            assert!(!minted.claims.jti.is_empty());
2311            assert_eq!(minted.claims.iat, 1_000);
2312            assert_eq!(
2313                minted.claims.exp,
2314                1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
2315            );
2316            // Edge verifies.
2317            let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
2318            assert_eq!(back, minted.claims);
2319        });
2320    }
2321
2322    #[test]
2323    fn mint_token_exchange_intersection_drops_scope_one_side_lacks() {
2324        // Verify intersection by removing a scope from one side and observing
2325        // it's rejected at the requested-scope check — proves the camp's
2326        // grants actually narrow the user's, not just rubber-stamp the
2327        // request.
2328        let (authority, _verifier) = rig();
2329        let user = PrincipalId::user("bob");
2330        let camp = PrincipalId::camp("c-narrow");
2331        let aud = "https://aud.example";
2332
2333        // User has both; camp has only CloudRead.
2334        put_simple_grant(
2335            &authority,
2336            user.clone(),
2337            aud,
2338            vec![
2339                ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
2340                ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
2341            ],
2342        );
2343        put_simple_grant(
2344            &authority,
2345            camp.clone(),
2346            aud,
2347            vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
2348        );
2349
2350        pollster::block_on(async {
2351            // Requesting CloudRead alone succeeds — in both.
2352            let minted = authority
2353                .mint_token_exchange(
2354                    user.clone(),
2355                    camp.clone(),
2356                    None,
2357                    aud,
2358                    vec![yah_scopes::CLOUD_READ],
2359                    1_000,
2360                )
2361                .await
2362                .unwrap();
2363            assert_eq!(minted.claims.scope, vec![yah_scopes::CLOUD_READ]);
2364
2365            // Requesting CloudDeploy fails — camp lacks it. The whole
2366            // exchange rejects (not a partial token).
2367            let err = authority
2368                .mint_token_exchange(
2369                    user,
2370                    camp,
2371                    None,
2372                    aud,
2373                    vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
2374                    1_000,
2375                )
2376                .await
2377                .unwrap_err();
2378            match err {
2379                McpMintError::InvalidScope { scope, aud: a } => {
2380                    assert_eq!(scope, yah_scopes::CLOUD_DEPLOY);
2381                    assert_eq!(a, aud);
2382                }
2383                other => panic!("expected InvalidScope, got {other:?}"),
2384            }
2385        });
2386    }
2387
2388    #[test]
2389    fn mint_token_exchange_rejects_scope_user_lacks() {
2390        // Mirror case: camp has it, user doesn't. Same all-or-nothing reject.
2391        let (authority, _verifier) = rig();
2392        let user = PrincipalId::user("carol");
2393        let camp = PrincipalId::camp("c-wide");
2394        let aud = "https://aud.example";
2395
2396        put_simple_grant(
2397            &authority,
2398            user.clone(),
2399            aud,
2400            vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
2401        );
2402        put_simple_grant(
2403            &authority,
2404            camp.clone(),
2405            aud,
2406            vec![
2407                ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
2408                ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
2409            ],
2410        );
2411
2412        pollster::block_on(async {
2413            let err = authority
2414                .mint_token_exchange(
2415                    user,
2416                    camp,
2417                    None,
2418                    aud,
2419                    vec![yah_scopes::CLOUD_DEPLOY],
2420                    1_000,
2421                )
2422                .await
2423                .unwrap_err();
2424            match err {
2425                McpMintError::InvalidScope { scope, aud: _ } => {
2426                    assert_eq!(scope, yah_scopes::CLOUD_DEPLOY);
2427                }
2428                other => panic!("expected InvalidScope, got {other:?}"),
2429            }
2430        });
2431    }
2432
2433    // ---- mint_token_exchange: rejection paths ------------------------------
2434
2435    #[test]
2436    fn mint_token_exchange_rejects_wrong_user_kind() {
2437        let (authority, _verifier) = rig();
2438        pollster::block_on(async {
2439            let err = authority
2440                .mint_token_exchange(
2441                    PrincipalId::camp("c-as-user"),
2442                    PrincipalId::camp("c"),
2443                    None,
2444                    "https://aud.example",
2445                    vec![],
2446                    1_000,
2447                )
2448                .await
2449                .unwrap_err();
2450            match err {
2451                McpMintError::WrongPrincipalKind { expected, got } => {
2452                    assert_eq!(expected, PrincipalKind::User);
2453                    assert_eq!(got, PrincipalKind::Camp);
2454                }
2455                other => panic!("expected WrongPrincipalKind, got {other:?}"),
2456            }
2457        });
2458    }
2459
2460    #[test]
2461    fn mint_token_exchange_rejects_wrong_camp_kind() {
2462        let (authority, _verifier) = rig();
2463        pollster::block_on(async {
2464            let err = authority
2465                .mint_token_exchange(
2466                    PrincipalId::user("alice"),
2467                    PrincipalId::service("yubaba"),
2468                    None,
2469                    "https://aud.example",
2470                    vec![],
2471                    1_000,
2472                )
2473                .await
2474                .unwrap_err();
2475            match err {
2476                McpMintError::WrongPrincipalKind { expected, got } => {
2477                    assert_eq!(expected, PrincipalKind::Camp);
2478                    assert_eq!(got, PrincipalKind::Service);
2479                }
2480                other => panic!("expected WrongPrincipalKind, got {other:?}"),
2481            }
2482        });
2483    }
2484
2485    #[test]
2486    fn mint_token_exchange_rejects_unentitled_user() {
2487        let (authority, _verifier) = rig();
2488        let user = PrincipalId::user("eve");
2489        let camp = PrincipalId::camp("c");
2490        let aud = "https://aud.example";
2491        // Camp has a grant, user doesn't — must reject on the user side.
2492        put_simple_grant(
2493            &authority,
2494            camp.clone(),
2495            aud,
2496            vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
2497        );
2498
2499        pollster::block_on(async {
2500            let err = authority
2501                .mint_token_exchange(user, camp, None, aud, vec![yah_scopes::CLOUD_READ], 1_000)
2502                .await
2503                .unwrap_err();
2504            match err {
2505                McpMintError::AudNotEntitled { principal, aud: _ } => {
2506                    assert_eq!(principal, "user:eve");
2507                }
2508                other => panic!("expected AudNotEntitled, got {other:?}"),
2509            }
2510        });
2511    }
2512
2513    #[test]
2514    fn mint_token_exchange_rejects_unentitled_camp() {
2515        // Mirror: user has a grant, camp doesn't. Composition rule (5)
2516        // applies per side, so the camp's empty grant list rejects.
2517        let (authority, _verifier) = rig();
2518        let user = PrincipalId::user("frank");
2519        let camp = PrincipalId::camp("c-no-grants");
2520        let aud = "https://aud.example";
2521        put_simple_grant(
2522            &authority,
2523            user.clone(),
2524            aud,
2525            vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
2526        );
2527
2528        pollster::block_on(async {
2529            let err = authority
2530                .mint_token_exchange(user, camp, None, aud, vec![yah_scopes::CLOUD_READ], 1_000)
2531                .await
2532                .unwrap_err();
2533            match err {
2534                McpMintError::AudNotEntitled { principal, aud: _ } => {
2535                    assert_eq!(principal, "camp:c-no-grants");
2536                }
2537                other => panic!("expected AudNotEntitled, got {other:?}"),
2538            }
2539        });
2540    }
2541
2542    #[test]
2543    fn mint_token_exchange_catches_service_only_scope_smuggled_via_camp_bundle() {
2544        // Defence in depth — composition rule (4) — even on the exchange
2545        // path: a camp bundle that expands to OwnershipWrite is caught by
2546        // validate_grant(Camp, scope) BEFORE intersection is computed.
2547        let (authority, _verifier) = rig();
2548        let user = PrincipalId::user("g");
2549        let camp = PrincipalId::camp("c-sneaky");
2550        let aud = "https://aud.example";
2551
2552        pollster::block_on(async {
2553            authority
2554                .bundles
2555                .put(
2556                    &BundleName::new("dangerous"),
2557                    &[yah_scopes::CAMP_READ, yah_scopes::AUDIT_WRITE],
2558                )
2559                .await
2560                .unwrap();
2561            put_simple_grant(
2562                &authority,
2563                user.clone(),
2564                aud,
2565                vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
2566            );
2567            put_simple_grant(
2568                &authority,
2569                camp,
2570                aud,
2571                vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
2572            );
2573
2574            let err = authority
2575                .mint_token_exchange(
2576                    user,
2577                    PrincipalId::camp("c-sneaky"),
2578                    None,
2579                    aud,
2580                    vec![yah_scopes::CAMP_READ],
2581                    1_000,
2582                )
2583                .await
2584                .unwrap_err();
2585            match err {
2586                McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
2587                    assert_eq!(scope, yah_scopes::AUDIT_WRITE);
2588                    assert_eq!(kind, PrincipalKind::Camp);
2589                }
2590                other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
2591            }
2592        });
2593    }
2594
2595    // ---- McpMintError → Error conversion -----------------------------------
2596
2597    #[test]
2598    fn mcp_mint_error_converts_to_umbrella_error() {
2599        let e: Error = McpMintError::AudNotEntitled {
2600            principal: "user:x".into(),
2601            aud: "a".into(),
2602        }
2603        .into();
2604        match e {
2605            Error::InvalidInput(msg) => assert!(msg.contains("aud")),
2606            other => panic!("expected InvalidInput, got {other:?}"),
2607        }
2608
2609        let e: Error = McpMintError::Store(StoreError::Conflict).into();
2610        assert!(matches!(e, Error::Store(StoreError::Conflict)));
2611
2612        let e: Error = McpMintError::Codec(CodecError::Malformed).into();
2613        assert!(matches!(e, Error::Codec(CodecError::Malformed)));
2614    }
2615
2616    // ---- mint_service (R731-F4) ---------------------------------------------
2617
2618    use crate::service_principal::{
2619        MemoryServicePrincipalStore, NewServicePrincipal, ProvisionedKey, ServicePrincipalAuthority,
2620    };
2621    use cheers_core::{ClientAssertion, MemoryUsedJtiStore};
2622
2623    const TOKEN_ENDPOINT: &str = "https://cheers.example/token";
2624    const SVC_AUD: &str = "https://kamaji.example";
2625
2626    struct SvcRig {
2627        authority: McpAuthority<MemoryBundleStore, TestGrants, MemoryOwnershipStore>,
2628        verifier: PasetoV4PublicVerifier,
2629        sps: ServicePrincipalAuthority<MemoryServicePrincipalStore>,
2630        key: ProvisionedKey,
2631    }
2632
2633    fn svc_rig() -> SvcRig {
2634        let (authority, verifier) = rig();
2635        let store = MemoryServicePrincipalStore::new();
2636        let authority = authority.with_service_assertions(
2637            Arc::new(store.clone()),
2638            Arc::new(MemoryUsedJtiStore::new()),
2639        );
2640        let sps = ServicePrincipalAuthority::new(store);
2641        let key = pollster::block_on(sps.provision(NewServicePrincipal::new("yubaba"), 1)).unwrap();
2642        SvcRig { authority, verifier, sps, key }
2643    }
2644
2645    fn sign_assertion(key: &ProvisionedKey, claims: &ClientAssertion) -> String {
2646        let sk = pasetors::keys::AsymmetricSecretKey::<V4>::from(&key.secret_key[..]).unwrap();
2647        let footer = serde_json::to_vec(&serde_json::json!({ "kid": key.signing_key.kid })).unwrap();
2648        let payload = serde_json::to_vec(claims).unwrap();
2649        PublicToken::sign(&sk, &payload, Some(&footer), None).unwrap()
2650    }
2651
2652    fn good_assertion(jti: &str) -> ClientAssertion {
2653        ClientAssertion::new(PrincipalId::service("yubaba"), TOKEN_ENDPOINT, jti, 1000, 1060)
2654    }
2655
2656    fn seed_svc_scope(r: &SvcRig, scope: Scope) {
2657        put_simple_grant(
2658            &r.authority,
2659            PrincipalId::service("yubaba"),
2660            SVC_AUD,
2661            vec![ScopeOrBundle::Scope(scope)],
2662        );
2663    }
2664
2665    fn mint(r: &SvcRig, a: &ClientAssertion) -> Result<MintedMcpToken, McpMintError> {
2666        let t = sign_assertion(&r.key, a);
2667        pollster::block_on(r.authority.mint_service(&t, SVC_AUD, &[], 1010))
2668    }
2669
2670    #[test]
2671    fn mint_service_happy_path_scopes_from_relationship() {
2672        let r = svc_rig();
2673        seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2674        let minted = mint(&r, &good_assertion("j-ok")).unwrap();
2675        let claims = r.verifier.verify_mcp_at(&minted.token, 1010, RIG_KID).unwrap();
2676        assert_eq!(claims.sub, PrincipalId::service("yubaba"));
2677        assert_eq!(claims.aud, SVC_AUD);
2678        assert_eq!(claims.scope, vec![yah_scopes::CLOUD_READ]);
2679        assert!(claims.exp - claims.iat <= 3600);
2680        assert!(claims.ceiling.is_none());
2681    }
2682
2683    #[test]
2684    fn mint_service_intersects_requested_and_rejects_empty() {
2685        let r = svc_rig();
2686        seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2687        let t = sign_assertion(&r.key, &good_assertion("j-a"));
2688        let other = [yah_scopes::CLOUD_DEPLOY];
2689        let err = pollster::block_on(r.authority.mint_service(&t, SVC_AUD, &other, 1010)).unwrap_err();
2690        assert!(matches!(err, McpMintError::NoGrantedScopes { .. }), "{err:?}");
2691        // No relationship at all: also rejected.
2692        let r = svc_rig();
2693        let err = mint(&r, &good_assertion("j-b")).unwrap_err();
2694        assert!(matches!(err, McpMintError::NoGrantedScopes { .. }), "{err:?}");
2695    }
2696
2697    #[test]
2698    fn mint_service_rejects_unknown_kid() {
2699        let r = svc_rig();
2700        seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2701        let other = pollster::block_on(
2702            ServicePrincipalAuthority::new(MemoryServicePrincipalStore::new())
2703                .provision(NewServicePrincipal::new("yubaba"), 1),
2704        )
2705        .unwrap();
2706        let t = sign_assertion(&other, &good_assertion("j"));
2707        let err = pollster::block_on(r.authority.mint_service(&t, SVC_AUD, &[], 1010)).unwrap_err();
2708        assert!(matches!(err, McpMintError::AssertionUnknownKid(_)), "{err:?}");
2709    }
2710
2711    #[test]
2712    fn mint_service_rejects_retired_key() {
2713        let r = svc_rig();
2714        seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2715        pollster::block_on(r.sps.rotate(&PrincipalId::service("yubaba"), 2)).unwrap();
2716        let err = mint(&r, &good_assertion("j")).unwrap_err();
2717        assert!(matches!(err, McpMintError::AssertionRetiredKey(_)), "{err:?}");
2718    }
2719
2720    #[test]
2721    fn mint_service_rejects_principal_mismatch() {
2722        let r = svc_rig();
2723        seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2724        let mut a = good_assertion("j1");
2725        a.sub = PrincipalId::service("someone-else");
2726        let err = mint(&r, &a).unwrap_err();
2727        assert!(matches!(err, McpMintError::AssertionPrincipalMismatch { .. }), "{err:?}");
2728        let mut a = good_assertion("j2");
2729        a.iss = PrincipalId::service("someone-else");
2730        let err = mint(&r, &a).unwrap_err();
2731        assert!(matches!(err, McpMintError::AssertionPrincipalMismatch { .. }), "{err:?}");
2732    }
2733
2734    #[test]
2735    fn mint_service_rejects_bad_aud() {
2736        let r = svc_rig();
2737        seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2738        let mut a = good_assertion("j");
2739        a.aud = SVC_AUD.into();
2740        let err = mint(&r, &a).unwrap_err();
2741        assert!(matches!(err, McpMintError::AssertionBadAudience { .. }), "{err:?}");
2742    }
2743
2744    #[test]
2745    fn mint_service_rejects_expired_and_too_long_assertions() {
2746        let r = svc_rig();
2747        seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2748        let mut a = good_assertion("j1");
2749        a.exp = 1010;
2750        let err = mint(&r, &a).unwrap_err();
2751        assert!(matches!(err, McpMintError::AssertionExpired { .. }), "{err:?}");
2752        let mut a = good_assertion("j2");
2753        a.exp = a.iat + ClientAssertion::MAX_LIFETIME_SECONDS + 1;
2754        let err = mint(&r, &a).unwrap_err();
2755        assert!(matches!(err, McpMintError::AssertionLifetimeTooLong { .. }), "{err:?}");
2756        // Exactly the maximum is fine.
2757        let mut a = good_assertion("j3");
2758        a.exp = a.iat + ClientAssertion::MAX_LIFETIME_SECONDS;
2759        mint(&r, &a).unwrap();
2760    }
2761
2762    #[test]
2763    fn mint_service_rejects_replay() {
2764        let r = svc_rig();
2765        seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2766        let a = good_assertion("j-once");
2767        mint(&r, &a).unwrap();
2768        let err = mint(&r, &a).unwrap_err();
2769        assert!(matches!(err, McpMintError::AssertionReplayed(_)), "{err:?}");
2770    }
2771
2772    #[test]
2773    fn mint_service_requires_configuration_and_wellformed_token() {
2774        let (authority, _) = rig();
2775        let err = pollster::block_on(authority.mint_service("x", SVC_AUD, &[], 0)).unwrap_err();
2776        assert!(matches!(err, McpMintError::ServiceAssertionsUnconfigured));
2777        let r = svc_rig();
2778        let err = pollster::block_on(r.authority.mint_service("v4.public.garbage", SVC_AUD, &[], 0)).unwrap_err();
2779        assert!(matches!(err, McpMintError::AssertionMalformed(_)), "{err:?}");
2780    }
2781}