use cheers_core::{
validate_grant, Actor, AuthStrength, CodecError, Error, GrantError, McpClaims, Owns,
PrincipalId, PrincipalKind, Scope, StoreError,
};
use crate::bundles::{expand_scopes, BundleExpansionError, BundleStore};
use crate::codec::PasetoV4SecretMinter;
use crate::grants::GrantStore;
use crate::ownership::{OwnershipRow, OwnershipStore};
use crate::session::generate_jti;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct McpPolicy {
pub access_ttl_seconds: i64,
pub api_token_default_ttl_seconds: i64,
pub api_token_max_ttl_seconds: i64,
}
impl McpPolicy {
pub const DEFAULT_ACCESS_TTL_SECONDS: i64 = 10 * 60;
pub const DEFAULT_API_TOKEN_TTL_SECONDS: i64 = 90 * 24 * 60 * 60;
pub const MAX_API_TOKEN_TTL_SECONDS: i64 = 365 * 24 * 60 * 60;
pub fn new(access_ttl_seconds: i64) -> Self {
Self {
access_ttl_seconds,
..Self::default()
}
}
pub fn with_access_ttl(mut self, seconds: i64) -> Self {
self.access_ttl_seconds = seconds;
self
}
pub fn with_api_token_ttls(mut self, default: i64, max: i64) -> Self {
self.api_token_default_ttl_seconds = default;
self.api_token_max_ttl_seconds = max;
self
}
}
impl Default for McpPolicy {
fn default() -> Self {
Self {
access_ttl_seconds: Self::DEFAULT_ACCESS_TTL_SECONDS,
api_token_default_ttl_seconds: Self::DEFAULT_API_TOKEN_TTL_SECONDS,
api_token_max_ttl_seconds: Self::MAX_API_TOKEN_TTL_SECONDS,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct MintedMcpToken {
pub token: String,
pub claims: McpClaims,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum McpMintError {
#[error("no entitlement: principal '{principal}' has no grant for aud '{aud}'")]
AudNotEntitled { principal: String, aud: String },
#[error("requested scope '{scope}' is not granted by both principals for aud '{aud}'")]
InvalidScope { scope: Scope, aud: String },
#[error("mint path expects {expected} principal; got {got}")]
WrongPrincipalKind {
expected: PrincipalKind,
got: PrincipalKind,
},
#[error(
"principal '{principal}' does not hold {} for aud '{aud}'",
.scopes.iter().map(|s| s.as_wire()).collect::<Vec<_>>().join(", ")
)]
UnentitledScopes {
principal: String,
scopes: Vec<Scope>,
aud: String,
},
#[error("requested ttl {requested}s is outside the allowed range 1..={max}s")]
TtlOutOfRange { requested: i64, max: i64 },
#[error(transparent)]
GrantMisconfigured(#[from] GrantError),
#[error(transparent)]
BundleExpansion(#[from] BundleExpansionError),
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Codec(#[from] CodecError),
}
impl From<McpMintError> for Error {
fn from(e: McpMintError) -> Self {
match e {
McpMintError::Store(s) => Error::Store(s),
McpMintError::Codec(c) => Error::Codec(c),
McpMintError::BundleExpansion(BundleExpansionError::Store(s)) => Error::Store(s),
other => Error::InvalidInput(other.to_string()),
}
}
}
pub struct McpAuthority<B, G, O> {
minter: PasetoV4SecretMinter,
bundles: B,
grants: G,
ownership: O,
iss: String,
kid: String,
policy: McpPolicy,
}
impl<B, G, O> McpAuthority<B, G, O>
where
B: BundleStore,
G: GrantStore,
O: OwnershipStore,
{
pub fn new(
minter: PasetoV4SecretMinter,
bundles: B,
grants: G,
ownership: O,
iss: impl Into<String>,
kid: impl Into<String>,
) -> Self {
Self {
minter,
bundles,
grants,
ownership,
iss: iss.into(),
kid: kid.into(),
policy: McpPolicy::default(),
}
}
pub fn with_policy(mut self, policy: McpPolicy) -> Self {
self.policy = policy;
self
}
pub fn policy(&self) -> &McpPolicy {
&self.policy
}
pub fn issuer(&self) -> &str {
&self.iss
}
pub async fn mint_user_fresh(
&self,
user: PrincipalId,
actor: Option<Actor>,
camp_id: Option<String>,
aud: impl Into<String>,
now: i64,
) -> Result<MintedMcpToken, McpMintError> {
if user.kind != PrincipalKind::User {
return Err(McpMintError::WrongPrincipalKind {
expected: PrincipalKind::User,
got: user.kind,
});
}
let aud = aud.into();
let entries = self.grants.list_for(&user, &aud).await?;
if entries.is_empty() {
return Err(McpMintError::AudNotEntitled {
principal: user.to_string(),
aud,
});
}
let scopes = expand_scopes(&self.bundles, &entries).await?;
for s in &scopes {
validate_grant(user.kind, *s)?;
}
let owns = match &camp_id {
Some(id) => {
let camp_principal = PrincipalId::camp(id);
let rows = self.ownership.list_for_principal(&camp_principal).await?;
rows_to_owns(&rows)
}
None => Owns::default(),
};
let mut claims = McpClaims::new(
self.iss.clone(),
aud,
user,
now,
now + self.policy.access_ttl_seconds,
generate_jti(),
scopes,
)
.with_auth_strength(AuthStrength::UserFresh);
if let Some(a) = actor {
claims = claims.with_act(a);
}
if let Some(c) = camp_id {
claims = claims.with_camp_id(c);
}
if !owns.is_empty() {
claims = claims.with_owns(owns);
}
let token = self.minter.mint_mcp(&claims, &self.kid)?;
Ok(MintedMcpToken { token, claims })
}
pub async fn mint_api_token(
&self,
user: PrincipalId,
aud: impl Into<String>,
requested: &[Scope],
ttl_seconds: Option<i64>,
now: i64,
) -> Result<MintedMcpToken, McpMintError> {
if user.kind != PrincipalKind::User {
return Err(McpMintError::WrongPrincipalKind {
expected: PrincipalKind::User,
got: user.kind,
});
}
let aud = aud.into();
let ttl = self.api_token_ttl(ttl_seconds)?;
let entries = self.grants.list_for(&user, &aud).await?;
if entries.is_empty() {
return Err(McpMintError::AudNotEntitled {
principal: user.to_string(),
aud,
});
}
let effective = expand_scopes(&self.bundles, &entries).await?;
for s in &effective {
validate_grant(user.kind, *s)?;
}
let scopes = attenuate(requested, effective, &user, &aud)?;
self.sign_api_token(user, aud, scopes, ttl, now)
}
pub fn rotate_api_token(
&self,
user: PrincipalId,
aud: impl Into<String>,
held: &[Scope],
requested: &[Scope],
ttl_seconds: Option<i64>,
now: i64,
) -> Result<MintedMcpToken, McpMintError> {
if user.kind != PrincipalKind::User {
return Err(McpMintError::WrongPrincipalKind {
expected: PrincipalKind::User,
got: user.kind,
});
}
let aud = aud.into();
let ttl = self.api_token_ttl(ttl_seconds)?;
let mut ceiling: Vec<Scope> = Vec::with_capacity(held.len());
for s in held {
validate_grant(user.kind, *s)?;
if !ceiling.contains(s) {
ceiling.push(*s);
}
}
let scopes = attenuate(requested, ceiling, &user, &aud)?;
self.sign_api_token(user, aud, scopes, ttl, now)
}
fn api_token_ttl(&self, ttl_seconds: Option<i64>) -> Result<i64, McpMintError> {
match ttl_seconds {
None => Ok(self.policy.api_token_default_ttl_seconds),
Some(n) if n >= 1 && n <= self.policy.api_token_max_ttl_seconds => Ok(n),
Some(n) => Err(McpMintError::TtlOutOfRange {
requested: n,
max: self.policy.api_token_max_ttl_seconds,
}),
}
}
fn sign_api_token(
&self,
user: PrincipalId,
aud: String,
scopes: Vec<Scope>,
ttl: i64,
now: i64,
) -> Result<MintedMcpToken, McpMintError> {
let claims = McpClaims::new(
self.iss.clone(),
aud,
user,
now,
now + ttl,
generate_jti(),
scopes,
)
.with_auth_strength(AuthStrength::ApiToken);
let token = self.minter.mint_mcp(&claims, &self.kid)?;
Ok(MintedMcpToken { token, claims })
}
pub async fn mint_bootstrap(
&self,
camp: PrincipalId,
aud: impl Into<String>,
now: i64,
) -> Result<MintedMcpToken, McpMintError> {
if camp.kind != PrincipalKind::Camp {
return Err(McpMintError::WrongPrincipalKind {
expected: PrincipalKind::Camp,
got: camp.kind,
});
}
let aud = aud.into();
let entries = self.grants.list_for(&camp, &aud).await?;
if entries.is_empty() {
return Err(McpMintError::AudNotEntitled {
principal: camp.to_string(),
aud,
});
}
let scopes = expand_scopes(&self.bundles, &entries).await?;
for s in &scopes {
validate_grant(camp.kind, *s)?;
}
let rows = self.ownership.list_for_principal(&camp).await?;
let owns = rows_to_owns(&rows);
let camp_id = camp.id.clone();
let mut claims = McpClaims::new(
self.iss.clone(),
aud,
camp,
now,
now + self.policy.access_ttl_seconds,
generate_jti(),
scopes,
)
.with_camp_id(camp_id)
.with_auth_strength(AuthStrength::Bootstrap);
if !owns.is_empty() {
claims = claims.with_owns(owns);
}
let token = self.minter.mint_mcp(&claims, &self.kid)?;
Ok(MintedMcpToken { token, claims })
}
pub async fn mint_token_exchange(
&self,
user: PrincipalId,
camp: PrincipalId,
actor: Option<Actor>,
aud: impl Into<String>,
requested_scope: Vec<Scope>,
now: i64,
) -> Result<MintedMcpToken, McpMintError> {
if user.kind != PrincipalKind::User {
return Err(McpMintError::WrongPrincipalKind {
expected: PrincipalKind::User,
got: user.kind,
});
}
if camp.kind != PrincipalKind::Camp {
return Err(McpMintError::WrongPrincipalKind {
expected: PrincipalKind::Camp,
got: camp.kind,
});
}
let aud = aud.into();
let user_entries = self.grants.list_for(&user, &aud).await?;
if user_entries.is_empty() {
return Err(McpMintError::AudNotEntitled {
principal: user.to_string(),
aud,
});
}
let user_scopes = expand_scopes(&self.bundles, &user_entries).await?;
for s in &user_scopes {
validate_grant(user.kind, *s)?;
}
let camp_entries = self.grants.list_for(&camp, &aud).await?;
if camp_entries.is_empty() {
return Err(McpMintError::AudNotEntitled {
principal: camp.to_string(),
aud,
});
}
let camp_scopes = expand_scopes(&self.bundles, &camp_entries).await?;
for s in &camp_scopes {
validate_grant(camp.kind, *s)?;
}
for s in &requested_scope {
if !user_scopes.contains(s) || !camp_scopes.contains(s) {
return Err(McpMintError::InvalidScope {
scope: *s,
aud: aud.clone(),
});
}
}
let rows = self.ownership.list_for_principal(&camp).await?;
let owns = rows_to_owns(&rows);
let camp_id = camp.id.clone();
let mut claims = McpClaims::new(
self.iss.clone(),
aud,
user,
now,
now + self.policy.access_ttl_seconds,
generate_jti(),
requested_scope,
)
.with_camp_id(camp_id)
.with_auth_strength(AuthStrength::UserFresh);
if let Some(a) = actor {
claims = claims.with_act(a);
}
if !owns.is_empty() {
claims = claims.with_owns(owns);
}
let token = self.minter.mint_mcp(&claims, &self.kid)?;
Ok(MintedMcpToken { token, claims })
}
}
fn attenuate(
requested: &[Scope],
ceiling: Vec<Scope>,
user: &PrincipalId,
aud: &str,
) -> Result<Vec<Scope>, McpMintError> {
if requested.is_empty() {
return Ok(ceiling);
}
let unentitled: Vec<Scope> = requested
.iter()
.filter(|s| !ceiling.contains(s))
.copied()
.collect();
if !unentitled.is_empty() {
return Err(McpMintError::UnentitledScopes {
principal: user.to_string(),
scopes: unentitled,
aud: aud.to_owned(),
});
}
let mut out: Vec<Scope> = Vec::with_capacity(requested.len());
for s in requested {
if !out.contains(s) {
out.push(*s);
}
}
Ok(out)
}
fn rows_to_owns(rows: &[OwnershipRow]) -> Owns {
fn push_unique(list: &mut Vec<String>, id: &str) {
if !list.iter().any(|existing| existing == id) {
list.push(id.to_owned());
}
}
let mut o = Owns::default();
for r in rows {
if r.is_revoked() {
continue;
}
match r.resource_kind.as_str() {
"service" => push_unique(&mut o.service, &r.resource_id),
"arch_doc" => push_unique(&mut o.arch_doc, &r.resource_id),
"node" => push_unique(&mut o.node, &r.resource_id),
other => push_unique(
o.extra.entry(other.to_owned()).or_default(),
&r.resource_id,
),
}
}
o
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bundles::{BundleName, MemoryBundleStore, ScopeOrBundle};
use crate::codec::PasetoV4SecretMinter;
use crate::grants::MemoryGrantStore;
use crate::ownership::{NewOwnership, OwnershipRow, OwnershipStore};
use async_trait::async_trait;
use cheers_core::{Scope, StoreError};
use cheers_verify::PasetoV4PublicVerifier;
use std::sync::Mutex;
#[derive(Default)]
struct MemOwnershipStore {
rows: Mutex<Vec<OwnershipRow>>,
next_id: Mutex<u64>,
}
impl MemOwnershipStore {
fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl OwnershipStore for MemOwnershipStore {
async fn insert(
&self,
ownership: &NewOwnership,
now: i64,
) -> Result<OwnershipRow, StoreError> {
let mut id_g = self.next_id.lock().unwrap();
*id_g += 1;
let id = format!("row-{}", *id_g);
drop(id_g);
let row = OwnershipRow::new(
id,
ownership.principal_id.clone(),
ownership.resource_kind.clone(),
ownership.resource_id.clone(),
ownership.relationship.clone(),
ownership.granted_by.clone(),
ownership.on_behalf_of.clone(),
now,
None,
);
self.rows.lock().unwrap().push(row.clone());
Ok(row)
}
async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError> {
Ok(self.rows.lock().unwrap().iter().find(|r| r.id == id).cloned())
}
async fn revoke_by_id(&self, id: &str, now: i64) -> Result<(), StoreError> {
let mut g = self.rows.lock().unwrap();
let row = g
.iter_mut()
.find(|r| r.id == id)
.ok_or(StoreError::NotFound)?;
if row.revoked_at.is_none() {
row.revoked_at = Some(now);
}
Ok(())
}
async fn revoke_by_on_behalf_of(
&self,
user: &PrincipalId,
now: i64,
) -> Result<u64, StoreError> {
let mut count = 0u64;
for r in self.rows.lock().unwrap().iter_mut() {
if r.revoked_at.is_none() && r.on_behalf_of.as_ref() == Some(user) {
r.revoked_at = Some(now);
count += 1;
}
}
Ok(count)
}
async fn list_for_principal(
&self,
principal: &PrincipalId,
) -> Result<Vec<OwnershipRow>, StoreError> {
Ok(self
.rows
.lock()
.unwrap()
.iter()
.filter(|r| r.revoked_at.is_none() && r.principal_id == *principal)
.cloned()
.collect())
}
async fn list_for_resource(
&self,
resource_kind: &str,
resource_id: &str,
) -> Result<Vec<OwnershipRow>, StoreError> {
Ok(self
.rows
.lock()
.unwrap()
.iter()
.filter(|r| {
r.revoked_at.is_none()
&& r.resource_kind == resource_kind
&& r.resource_id == resource_id
})
.cloned()
.collect())
}
}
const RIG_KID: &str = "mcp-authority-test-kid-1";
fn rig() -> (
McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>,
PasetoV4PublicVerifier,
) {
let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
let bundles = MemoryBundleStore::with_defaults();
let grants = MemoryGrantStore::new();
let ownership = MemOwnershipStore::new();
let authority = McpAuthority::new(
minter,
bundles,
grants,
ownership,
"https://cheers.example",
RIG_KID,
);
(authority, verifier)
}
fn put_simple_grant(authority: &McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>, principal: PrincipalId, aud: &str, entries: Vec<ScopeOrBundle>) {
authority.grants.put(principal, aud, entries);
}
#[test]
fn mcp_policy_default_is_ten_minutes() {
let p = McpPolicy::default();
assert_eq!(p.access_ttl_seconds, 10 * 60);
let p = McpPolicy::new(300);
assert_eq!(p.access_ttl_seconds, 300);
let p = McpPolicy::default().with_access_ttl(5 * 60);
assert_eq!(p.access_ttl_seconds, 5 * 60);
}
#[test]
fn mint_user_fresh_signs_token_verifiable_at_edge() {
let (authority, verifier) = rig();
let user = PrincipalId::user("alice");
let aud = "https://kamaji.camp.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CloudDeploy), ScopeOrBundle::Scope(Scope::CloudRead)],
);
pollster::block_on(async {
let minted = authority
.mint_user_fresh(user.clone(), None, None, aud, 1_000)
.await
.unwrap();
assert!(minted.token.starts_with("v4.public."));
assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
assert!(!minted.claims.jti.is_empty());
assert_eq!(minted.claims.iat, 1_000);
assert_eq!(
minted.claims.exp,
1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
);
let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
assert_eq!(back, minted.claims);
assert_eq!(back.sub, user);
assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
});
}
#[test]
fn mint_user_fresh_expands_bundles_at_mint_time() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("bob");
let aud = "https://kamaji.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
);
pollster::block_on(async {
let first = authority
.mint_user_fresh(user.clone(), None, None, aud, 1_000)
.await
.unwrap();
assert!(first.claims.scope.contains(&Scope::CloudDestroy));
authority
.bundles
.put(
&BundleName::new("deploy-admin"),
&[Scope::CloudRead, Scope::CloudDeploy],
)
.await
.unwrap();
let second = authority
.mint_user_fresh(user, None, None, aud, 1_100)
.await
.unwrap();
assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
});
}
#[test]
fn mint_user_fresh_populates_act_camp_id_and_owns() {
let (authority, verifier) = rig();
let user = PrincipalId::user("carol");
let camp_id = "camp-xyz";
let aud = "https://aud.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CampRead)],
);
pollster::block_on(async {
let svc = NewOwnership::new(
PrincipalId::camp(camp_id),
"service",
"svc-a",
"owns",
PrincipalId::service("yubaba"),
Some(user.clone()),
)
.unwrap();
authority.ownership.insert(&svc, 500).await.unwrap();
let doc = NewOwnership::new(
PrincipalId::camp(camp_id),
"arch_doc",
"doc-1",
"owns",
PrincipalId::service("yubaba"),
Some(user.clone()),
)
.unwrap();
authority.ownership.insert(&doc, 500).await.unwrap();
let minted = authority
.mint_user_fresh(
user.clone(),
Some(Actor::new(PrincipalId::service("agent-claude"))),
Some(camp_id.to_owned()),
aud,
1_000,
)
.await
.unwrap();
assert_eq!(
minted.claims.act.as_ref().unwrap().sub,
PrincipalId::service("agent-claude")
);
assert_eq!(minted.claims.camp_id.as_deref(), Some(camp_id));
assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
assert_eq!(back, minted.claims);
});
}
#[test]
fn mint_user_fresh_omits_owns_when_camp_owns_nothing() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("dan");
let aud = "https://aud.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CampRead)],
);
pollster::block_on(async {
let minted = authority
.mint_user_fresh(user, None, Some("camp-empty".into()), aud, 1_000)
.await
.unwrap();
assert!(minted.claims.owns.is_empty());
let json = serde_json::to_string(&minted.claims).unwrap();
assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
});
}
#[test]
fn mint_user_fresh_rejects_unentitled_aud() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("eve");
pollster::block_on(async {
let err = authority
.mint_user_fresh(user, None, None, "https://aud.example", 1_000)
.await
.unwrap_err();
match err {
McpMintError::AudNotEntitled { principal, aud } => {
assert_eq!(principal, "user:eve");
assert_eq!(aud, "https://aud.example");
}
other => panic!("expected AudNotEntitled, got {other:?}"),
}
});
}
#[test]
fn mint_user_fresh_rejects_non_user_principal() {
let (authority, _verifier) = rig();
pollster::block_on(async {
let err = authority
.mint_user_fresh(
PrincipalId::service("yubaba"),
None,
None,
"https://aud.example",
1_000,
)
.await
.unwrap_err();
match err {
McpMintError::WrongPrincipalKind { expected, got } => {
assert_eq!(expected, PrincipalKind::User);
assert_eq!(got, PrincipalKind::Service);
}
other => panic!("expected WrongPrincipalKind, got {other:?}"),
}
});
}
#[test]
fn mint_api_token_defaults_to_ninety_days_and_the_full_effective_scope_set() {
let (authority, verifier) = rig();
let user = PrincipalId::user("alice");
let aud = "https://kamaji.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![
ScopeOrBundle::Scope(Scope::CloudRead),
ScopeOrBundle::Scope(Scope::CloudDeploy),
],
);
pollster::block_on(async {
let minted = authority
.mint_api_token(user.clone(), aud, &[], None, 1_000)
.await
.unwrap();
assert_eq!(
minted.claims.exp,
1_000 + McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
);
assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
assert_eq!(minted.claims.sub, user);
assert_eq!(
minted.claims.scope,
vec![Scope::CloudRead, Scope::CloudDeploy]
);
let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
assert_eq!(back, minted.claims);
});
}
#[test]
fn mint_api_token_attenuates_to_the_requested_subset() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("alice");
let aud = "https://kamaji.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![
ScopeOrBundle::Scope(Scope::CloudRead),
ScopeOrBundle::Scope(Scope::CloudDeploy),
ScopeOrBundle::Scope(Scope::CloudDestroy),
],
);
pollster::block_on(async {
let minted = authority
.mint_api_token(user, aud, &[Scope::CloudRead], None, 1_000)
.await
.unwrap();
assert_eq!(minted.claims.scope, vec![Scope::CloudRead]);
});
}
#[test]
fn mint_api_token_names_every_unentitled_scope_instead_of_dropping_it() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("alice");
let aud = "https://kamaji.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CloudRead)],
);
pollster::block_on(async {
let err = authority
.mint_api_token(
user,
aud,
&[Scope::CloudRead, Scope::CloudDestroy, Scope::CampAdmin],
None,
1_000,
)
.await
.unwrap_err();
match err {
McpMintError::UnentitledScopes { ref scopes, .. } => {
assert_eq!(scopes, &vec![Scope::CloudDestroy, Scope::CampAdmin]);
let msg = err.to_string();
assert!(msg.contains("cloud:destroy"), "{msg}");
assert!(msg.contains("camp:admin"), "{msg}");
}
other => panic!("expected UnentitledScopes, got {other:?}"),
}
});
}
#[test]
fn mint_api_token_rejects_service_only_scope_smuggled_via_bundle() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("mallory");
let aud = "https://aud.example";
pollster::block_on(async {
authority
.bundles
.put(
&BundleName::new("dangerous"),
&[Scope::CampRead, Scope::OwnershipWrite],
)
.await
.unwrap();
authority.grants.put(
user.clone(),
aud,
vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
);
match authority.mint_api_token(user, aud, &[], None, 1_000).await {
Err(McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope {
scope,
kind,
})) => {
assert_eq!(scope, Scope::OwnershipWrite);
assert_eq!(kind, PrincipalKind::User);
}
other => panic!("expected GrantMisconfigured, got {other:?}"),
}
});
}
#[test]
fn mint_api_token_rejects_an_out_of_range_ttl_rather_than_clamping() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("alice");
let aud = "https://kamaji.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CloudRead)],
);
pollster::block_on(async {
for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
match authority
.mint_api_token(user.clone(), aud, &[], Some(bad), 1_000)
.await
{
Err(McpMintError::TtlOutOfRange { requested, max }) => {
assert_eq!(requested, bad);
assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
}
other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
}
}
let ok = authority
.mint_api_token(
user,
aud,
&[],
Some(McpPolicy::MAX_API_TOKEN_TTL_SECONDS),
1_000,
)
.await
.unwrap();
assert_eq!(
ok.claims.exp,
1_000 + McpPolicy::MAX_API_TOKEN_TTL_SECONDS
);
});
}
#[test]
fn mint_api_token_rejects_an_unentitled_aud_before_signing() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("alice");
pollster::block_on(async {
match authority
.mint_api_token(user, "https://nothing-granted.example", &[], None, 1_000)
.await
{
Err(McpMintError::AudNotEntitled { principal, aud }) => {
assert_eq!(principal, "user:alice");
assert_eq!(aud, "https://nothing-granted.example");
}
other => panic!("expected AudNotEntitled, got {other:?}"),
}
});
}
#[test]
fn mint_api_token_carries_no_owns_no_camp_id_and_no_act() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("alice");
let aud = "https://kamaji.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CloudRead)],
);
pollster::block_on(async {
let minted = authority
.mint_api_token(user, aud, &[], None, 1_000)
.await
.unwrap();
assert!(minted.claims.owns.is_empty());
assert_eq!(minted.claims.camp_id, None);
assert_eq!(minted.claims.act, None);
});
}
#[test]
fn mint_api_token_rejects_a_non_user_principal() {
let (authority, _verifier) = rig();
pollster::block_on(async {
match authority
.mint_api_token(PrincipalId::camp("c1"), "https://aud.example", &[], None, 1)
.await
{
Err(McpMintError::WrongPrincipalKind { expected, got }) => {
assert_eq!(expected, PrincipalKind::User);
assert_eq!(got, PrincipalKind::Camp);
}
other => panic!("expected WrongPrincipalKind, got {other:?}"),
}
});
}
#[test]
fn rotate_api_token_mints_from_the_presented_scopes_without_reading_grants() {
let (authority, verifier) = rig();
let user = PrincipalId::user("alice");
let aud = "https://kamaji.example";
let minted = authority
.rotate_api_token(
user.clone(),
aud,
&[Scope::CloudRead, Scope::CloudDeploy],
&[],
None,
1_000,
)
.unwrap();
assert_eq!(minted.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
assert_eq!(minted.claims.sub, user.clone());
assert_eq!(minted.claims.aud, aud);
assert!(minted.claims.owns.is_empty());
let claims = verifier.verify_mcp_at(&minted.token, 1_001, RIG_KID).unwrap();
assert_eq!(claims.jti, minted.claims.jti);
pollster::block_on(async {
assert!(matches!(
authority.mint_api_token(user, aud, &[], None, 1_000).await,
Err(McpMintError::AudNotEntitled { .. })
));
});
}
#[test]
fn rotate_api_token_can_narrow_but_never_widen() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("alice");
let aud = "https://kamaji.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![
ScopeOrBundle::Scope(Scope::CloudRead),
ScopeOrBundle::Scope(Scope::CloudDeploy),
ScopeOrBundle::Scope(Scope::CloudDestroy),
],
);
let narrowed = authority
.rotate_api_token(
user.clone(),
aud,
&[Scope::CloudRead, Scope::CloudDeploy],
&[Scope::CloudRead],
None,
1_000,
)
.unwrap();
assert_eq!(narrowed.claims.scope, vec![Scope::CloudRead]);
match authority.rotate_api_token(
user,
aud,
&[Scope::CloudRead],
&[Scope::CloudDestroy],
None,
1_000,
) {
Err(McpMintError::UnentitledScopes { scopes, .. }) => {
assert_eq!(scopes, vec![Scope::CloudDestroy]);
}
other => panic!("expected UnentitledScopes, got {other:?}"),
}
}
#[test]
fn rotate_api_token_gets_a_fresh_ttl_under_the_same_ceiling() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("alice");
let aud = "https://kamaji.example";
let default_ttl = authority
.rotate_api_token(user.clone(), aud, &[Scope::CloudRead], &[], None, 5_000)
.unwrap();
assert_eq!(
default_ttl.claims.exp - default_ttl.claims.iat,
McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
);
assert_eq!(default_ttl.claims.iat, 5_000, "the clock restarts at `now`");
for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
match authority.rotate_api_token(
user.clone(),
aud,
&[Scope::CloudRead],
&[],
Some(bad),
5_000,
) {
Err(McpMintError::TtlOutOfRange { requested, max }) => {
assert_eq!(requested, bad);
assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
}
other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
}
}
}
#[test]
fn rotate_api_token_rejects_a_service_only_scope_in_the_presented_token() {
let (authority, _verifier) = rig();
assert!(matches!(
authority.rotate_api_token(
PrincipalId::user("alice"),
"https://kamaji.example",
&[Scope::OwnershipWrite],
&[],
None,
1_000,
),
Err(McpMintError::GrantMisconfigured(_))
));
}
#[test]
fn rotate_api_token_rejects_a_non_user_principal() {
let (authority, _verifier) = rig();
match authority.rotate_api_token(
PrincipalId::camp("c1"),
"https://aud.example",
&[Scope::CloudRead],
&[],
None,
1,
) {
Err(McpMintError::WrongPrincipalKind { expected, got }) => {
assert_eq!(expected, PrincipalKind::User);
assert_eq!(got, PrincipalKind::Camp);
}
other => panic!("expected WrongPrincipalKind, got {other:?}"),
}
}
#[test]
fn mint_user_fresh_rejects_service_only_scope_smuggled_via_bundle() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("mallory");
let aud = "https://aud.example";
pollster::block_on(async {
authority
.bundles
.put(
&BundleName::new("dangerous"),
&[Scope::CampRead, Scope::OwnershipWrite],
)
.await
.unwrap();
authority.grants.put(
user.clone(),
aud,
vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
);
let err = authority
.mint_user_fresh(user, None, None, aud, 1_000)
.await
.unwrap_err();
match err {
McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
assert_eq!(scope, Scope::OwnershipWrite);
assert_eq!(kind, PrincipalKind::User);
}
other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
}
});
}
#[test]
fn mint_bootstrap_signs_token_verifiable_at_edge() {
let (authority, verifier) = rig();
let camp = PrincipalId::camp("c-xyz");
let aud = "https://kamaji.camp.example";
put_simple_grant(
&authority,
camp.clone(),
aud,
vec![
ScopeOrBundle::Scope(Scope::CloudDeploy),
ScopeOrBundle::Scope(Scope::CloudRead),
],
);
pollster::block_on(async {
let minted = authority
.mint_bootstrap(camp.clone(), aud, 1_000)
.await
.unwrap();
assert!(minted.token.starts_with("v4.public."));
assert_eq!(minted.claims.auth_strength, Some(AuthStrength::Bootstrap));
assert_eq!(minted.claims.sub, camp);
assert_eq!(minted.claims.camp_id.as_deref(), Some("c-xyz"));
assert!(minted.claims.act.is_none());
assert!(!minted.claims.jti.is_empty());
assert_eq!(minted.claims.iat, 1_000);
assert_eq!(
minted.claims.exp,
1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
);
let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
assert_eq!(back, minted.claims);
assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
});
}
#[test]
fn mint_bootstrap_owns_reflects_ownership_table_state() {
let (authority, _verifier) = rig();
let camp = PrincipalId::camp("c-with-owns");
let user = PrincipalId::user("u-on-behalf");
let aud = "https://aud.example";
put_simple_grant(
&authority,
camp.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CampRead)],
);
pollster::block_on(async {
let svc = NewOwnership::new(
camp.clone(),
"service",
"svc-a",
"owns",
PrincipalId::service("yubaba"),
Some(user.clone()),
)
.unwrap();
authority.ownership.insert(&svc, 500).await.unwrap();
let doc = NewOwnership::new(
camp.clone(),
"arch_doc",
"doc-1",
"owns",
PrincipalId::service("yubaba"),
Some(user.clone()),
)
.unwrap();
authority.ownership.insert(&doc, 500).await.unwrap();
let minted = authority
.mint_bootstrap(camp.clone(), aud, 1_000)
.await
.unwrap();
assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
});
}
#[test]
fn rows_to_owns_dedups_duplicate_resource_ids() {
let camp = PrincipalId::camp("c-dup");
let svc = PrincipalId::service("yubaba");
let mk = |id: &str, kind: &str, rid: &str| {
OwnershipRow::new(
id.to_owned(),
camp.clone(),
kind.to_owned(),
rid.to_owned(),
"owns".to_owned(),
svc.clone(),
None,
500,
None,
)
};
let rows = vec![
mk("row-1", "service", "svc-a"),
mk("row-2", "service", "svc-a"), mk("row-3", "service", "svc-b"),
mk("row-4", "node", "aa11"),
mk("row-5", "node", "aa11"), mk("row-6", "pond", "p-1"),
mk("row-7", "pond", "p-1"), ];
let owns = rows_to_owns(&rows);
assert_eq!(owns.service, vec!["svc-a".to_string(), "svc-b".to_string()]);
assert_eq!(owns.node, vec!["aa11".to_string()]);
assert_eq!(
owns.extra.get("pond"),
Some(&vec!["p-1".to_string()])
);
}
#[test]
fn mint_bootstrap_omits_owns_when_camp_owns_nothing() {
let (authority, _verifier) = rig();
let camp = PrincipalId::camp("c-empty");
let aud = "https://aud.example";
put_simple_grant(
&authority,
camp.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CampRead)],
);
pollster::block_on(async {
let minted = authority
.mint_bootstrap(camp, aud, 1_000)
.await
.unwrap();
assert!(minted.claims.owns.is_empty());
let json = serde_json::to_string(&minted.claims).unwrap();
assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
assert!(json.contains("\"camp_id\":\"c-empty\""), "camp_id must remain: {json}");
});
}
#[test]
fn mint_bootstrap_expands_bundles_at_mint_time() {
let (authority, _verifier) = rig();
let camp = PrincipalId::camp("c-deploy");
let aud = "https://aud.example";
put_simple_grant(
&authority,
camp.clone(),
aud,
vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
);
pollster::block_on(async {
let first = authority
.mint_bootstrap(camp.clone(), aud, 1_000)
.await
.unwrap();
assert!(first.claims.scope.contains(&Scope::CloudDestroy));
authority
.bundles
.put(
&BundleName::new("deploy-admin"),
&[Scope::CloudRead, Scope::CloudDeploy],
)
.await
.unwrap();
let second = authority
.mint_bootstrap(camp, aud, 1_100)
.await
.unwrap();
assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
});
}
#[test]
fn mint_bootstrap_rejects_non_camp_principal() {
let (authority, _verifier) = rig();
pollster::block_on(async {
let err = authority
.mint_bootstrap(
PrincipalId::user("alice"),
"https://aud.example",
1_000,
)
.await
.unwrap_err();
match err {
McpMintError::WrongPrincipalKind { expected, got } => {
assert_eq!(expected, PrincipalKind::Camp);
assert_eq!(got, PrincipalKind::User);
}
other => panic!("expected WrongPrincipalKind, got {other:?}"),
}
let err = authority
.mint_bootstrap(
PrincipalId::service("yubaba"),
"https://aud.example",
1_000,
)
.await
.unwrap_err();
match err {
McpMintError::WrongPrincipalKind { expected, got } => {
assert_eq!(expected, PrincipalKind::Camp);
assert_eq!(got, PrincipalKind::Service);
}
other => panic!("expected WrongPrincipalKind, got {other:?}"),
}
});
}
#[test]
fn mint_bootstrap_rejects_unentitled_aud() {
let (authority, _verifier) = rig();
let camp = PrincipalId::camp("c-no-grants");
pollster::block_on(async {
let err = authority
.mint_bootstrap(camp, "https://aud.example", 1_000)
.await
.unwrap_err();
match err {
McpMintError::AudNotEntitled { principal, aud } => {
assert_eq!(principal, "camp:c-no-grants");
assert_eq!(aud, "https://aud.example");
}
other => panic!("expected AudNotEntitled, got {other:?}"),
}
});
}
#[test]
fn mint_bootstrap_rejects_service_only_scope_smuggled_via_bundle() {
let (authority, _verifier) = rig();
let camp = PrincipalId::camp("c-sneaky");
let aud = "https://aud.example";
pollster::block_on(async {
authority
.bundles
.put(
&BundleName::new("dangerous"),
&[Scope::CampRead, Scope::OwnershipWrite],
)
.await
.unwrap();
authority.grants.put(
camp.clone(),
aud,
vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
);
let err = authority
.mint_bootstrap(camp, aud, 1_000)
.await
.unwrap_err();
match err {
McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
assert_eq!(scope, Scope::OwnershipWrite);
assert_eq!(kind, PrincipalKind::Camp);
}
other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
}
});
}
#[test]
fn mint_token_exchange_attributes_to_user_with_camp_as_context() {
let (authority, verifier) = rig();
let user = PrincipalId::user("alice");
let camp = PrincipalId::camp("c-multi");
let aud = "https://kamaji.camp.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![
ScopeOrBundle::Scope(Scope::CloudDeploy),
ScopeOrBundle::Scope(Scope::CloudRead),
],
);
put_simple_grant(
&authority,
camp.clone(),
aud,
vec![
ScopeOrBundle::Scope(Scope::CloudDeploy),
ScopeOrBundle::Scope(Scope::CloudRead),
],
);
pollster::block_on(async {
let svc = NewOwnership::new(
camp.clone(),
"service",
"svc-prod",
"owns",
PrincipalId::service("yubaba"),
Some(user.clone()),
)
.unwrap();
authority.ownership.insert(&svc, 500).await.unwrap();
let minted = authority
.mint_token_exchange(
user.clone(),
camp.clone(),
Some(Actor::new(PrincipalId::service("agent-claude"))),
aud,
vec![Scope::CloudDeploy],
1_000,
)
.await
.unwrap();
assert!(minted.token.starts_with("v4.public."));
assert_eq!(minted.claims.sub, user);
assert_eq!(minted.claims.camp_id.as_deref(), Some("c-multi"));
assert_eq!(
minted.claims.act.as_ref().unwrap().sub,
PrincipalId::service("agent-claude"),
);
assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
assert_eq!(minted.claims.scope, vec![Scope::CloudDeploy]);
assert_eq!(minted.claims.owns.service, vec!["svc-prod".to_string()]);
assert!(!minted.claims.jti.is_empty());
assert_eq!(minted.claims.iat, 1_000);
assert_eq!(
minted.claims.exp,
1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
);
let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
assert_eq!(back, minted.claims);
});
}
#[test]
fn mint_token_exchange_intersection_drops_scope_one_side_lacks() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("bob");
let camp = PrincipalId::camp("c-narrow");
let aud = "https://aud.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![
ScopeOrBundle::Scope(Scope::CloudDeploy),
ScopeOrBundle::Scope(Scope::CloudRead),
],
);
put_simple_grant(
&authority,
camp.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CloudRead)],
);
pollster::block_on(async {
let minted = authority
.mint_token_exchange(
user.clone(),
camp.clone(),
None,
aud,
vec![Scope::CloudRead],
1_000,
)
.await
.unwrap();
assert_eq!(minted.claims.scope, vec![Scope::CloudRead]);
let err = authority
.mint_token_exchange(
user,
camp,
None,
aud,
vec![Scope::CloudRead, Scope::CloudDeploy],
1_000,
)
.await
.unwrap_err();
match err {
McpMintError::InvalidScope { scope, aud: a } => {
assert_eq!(scope, Scope::CloudDeploy);
assert_eq!(a, aud);
}
other => panic!("expected InvalidScope, got {other:?}"),
}
});
}
#[test]
fn mint_token_exchange_rejects_scope_user_lacks() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("carol");
let camp = PrincipalId::camp("c-wide");
let aud = "https://aud.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CloudRead)],
);
put_simple_grant(
&authority,
camp.clone(),
aud,
vec![
ScopeOrBundle::Scope(Scope::CloudRead),
ScopeOrBundle::Scope(Scope::CloudDeploy),
],
);
pollster::block_on(async {
let err = authority
.mint_token_exchange(
user,
camp,
None,
aud,
vec![Scope::CloudDeploy],
1_000,
)
.await
.unwrap_err();
match err {
McpMintError::InvalidScope { scope, aud: _ } => {
assert_eq!(scope, Scope::CloudDeploy);
}
other => panic!("expected InvalidScope, got {other:?}"),
}
});
}
#[test]
fn mint_token_exchange_rejects_wrong_user_kind() {
let (authority, _verifier) = rig();
pollster::block_on(async {
let err = authority
.mint_token_exchange(
PrincipalId::camp("c-as-user"),
PrincipalId::camp("c"),
None,
"https://aud.example",
vec![],
1_000,
)
.await
.unwrap_err();
match err {
McpMintError::WrongPrincipalKind { expected, got } => {
assert_eq!(expected, PrincipalKind::User);
assert_eq!(got, PrincipalKind::Camp);
}
other => panic!("expected WrongPrincipalKind, got {other:?}"),
}
});
}
#[test]
fn mint_token_exchange_rejects_wrong_camp_kind() {
let (authority, _verifier) = rig();
pollster::block_on(async {
let err = authority
.mint_token_exchange(
PrincipalId::user("alice"),
PrincipalId::service("yubaba"),
None,
"https://aud.example",
vec![],
1_000,
)
.await
.unwrap_err();
match err {
McpMintError::WrongPrincipalKind { expected, got } => {
assert_eq!(expected, PrincipalKind::Camp);
assert_eq!(got, PrincipalKind::Service);
}
other => panic!("expected WrongPrincipalKind, got {other:?}"),
}
});
}
#[test]
fn mint_token_exchange_rejects_unentitled_user() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("eve");
let camp = PrincipalId::camp("c");
let aud = "https://aud.example";
put_simple_grant(
&authority,
camp.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CloudRead)],
);
pollster::block_on(async {
let err = authority
.mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
.await
.unwrap_err();
match err {
McpMintError::AudNotEntitled { principal, aud: _ } => {
assert_eq!(principal, "user:eve");
}
other => panic!("expected AudNotEntitled, got {other:?}"),
}
});
}
#[test]
fn mint_token_exchange_rejects_unentitled_camp() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("frank");
let camp = PrincipalId::camp("c-no-grants");
let aud = "https://aud.example";
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CloudRead)],
);
pollster::block_on(async {
let err = authority
.mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
.await
.unwrap_err();
match err {
McpMintError::AudNotEntitled { principal, aud: _ } => {
assert_eq!(principal, "camp:c-no-grants");
}
other => panic!("expected AudNotEntitled, got {other:?}"),
}
});
}
#[test]
fn mint_token_exchange_catches_service_only_scope_smuggled_via_camp_bundle() {
let (authority, _verifier) = rig();
let user = PrincipalId::user("g");
let camp = PrincipalId::camp("c-sneaky");
let aud = "https://aud.example";
pollster::block_on(async {
authority
.bundles
.put(
&BundleName::new("dangerous"),
&[Scope::CampRead, Scope::OwnershipWrite],
)
.await
.unwrap();
put_simple_grant(
&authority,
user.clone(),
aud,
vec![ScopeOrBundle::Scope(Scope::CampRead)],
);
put_simple_grant(
&authority,
camp,
aud,
vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
);
let err = authority
.mint_token_exchange(
user,
PrincipalId::camp("c-sneaky"),
None,
aud,
vec![Scope::CampRead],
1_000,
)
.await
.unwrap_err();
match err {
McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
assert_eq!(scope, Scope::OwnershipWrite);
assert_eq!(kind, PrincipalKind::Camp);
}
other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
}
});
}
#[test]
fn mcp_mint_error_converts_to_umbrella_error() {
let e: Error = McpMintError::AudNotEntitled {
principal: "user:x".into(),
aud: "a".into(),
}
.into();
match e {
Error::InvalidInput(msg) => assert!(msg.contains("aud")),
other => panic!("expected InvalidInput, got {other:?}"),
}
let e: Error = McpMintError::Store(StoreError::Conflict).into();
assert!(matches!(e, Error::Store(StoreError::Conflict)));
let e: Error = McpMintError::Codec(CodecError::Malformed).into();
assert!(matches!(e, Error::Codec(CodecError::Malformed)));
}
}