1use cheers_core::{
29 validate_grant, Actor, AuthStrength, CodecError, Error, GrantError, McpClaims, Owns,
30 PrincipalId, PrincipalKind, Scope, StoreError,
31};
32
33use crate::bundles::{expand_scopes, BundleExpansionError, BundleStore};
34use crate::codec::PasetoV4SecretMinter;
35use crate::grants::GrantStore;
36use crate::ownership::{OwnershipRow, OwnershipStore};
37use crate::session::generate_jti;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub struct McpPolicy {
48 pub access_ttl_seconds: i64,
50 pub api_token_default_ttl_seconds: i64,
58 pub api_token_max_ttl_seconds: i64,
63}
64
65impl McpPolicy {
66 pub const DEFAULT_ACCESS_TTL_SECONDS: i64 = 10 * 60;
68 pub const DEFAULT_API_TOKEN_TTL_SECONDS: i64 = 90 * 24 * 60 * 60;
70 pub const MAX_API_TOKEN_TTL_SECONDS: i64 = 365 * 24 * 60 * 60;
72
73 pub fn new(access_ttl_seconds: i64) -> Self {
74 Self {
75 access_ttl_seconds,
76 ..Self::default()
77 }
78 }
79
80 pub fn with_access_ttl(mut self, seconds: i64) -> Self {
81 self.access_ttl_seconds = seconds;
82 self
83 }
84
85 pub fn with_api_token_ttls(mut self, default: i64, max: i64) -> Self {
89 self.api_token_default_ttl_seconds = default;
90 self.api_token_max_ttl_seconds = max;
91 self
92 }
93}
94
95impl Default for McpPolicy {
96 fn default() -> Self {
97 Self {
98 access_ttl_seconds: Self::DEFAULT_ACCESS_TTL_SECONDS,
99 api_token_default_ttl_seconds: Self::DEFAULT_API_TOKEN_TTL_SECONDS,
100 api_token_max_ttl_seconds: Self::MAX_API_TOKEN_TTL_SECONDS,
101 }
102 }
103}
104
105#[derive(Debug, Clone)]
107#[non_exhaustive]
108pub struct MintedMcpToken {
109 pub token: String,
111 pub claims: McpClaims,
113}
114
115#[derive(Debug, thiserror::Error)]
121#[non_exhaustive]
122pub enum McpMintError {
123 #[error("no entitlement: principal '{principal}' has no grant for aud '{aud}'")]
126 AudNotEntitled { principal: String, aud: String },
127 #[error("requested scope '{scope}' is not granted by both principals for aud '{aud}'")]
132 InvalidScope { scope: Scope, aud: String },
133 #[error("mint path expects {expected} principal; got {got}")]
137 WrongPrincipalKind {
138 expected: PrincipalKind,
139 got: PrincipalKind,
140 },
141 #[error(
147 "principal '{principal}' does not hold {} for aud '{aud}'",
148 .scopes.iter().map(|s| s.as_wire()).collect::<Vec<_>>().join(", ")
149 )]
150 UnentitledScopes {
151 principal: String,
152 scopes: Vec<Scope>,
153 aud: String,
154 },
155 #[error("requested ttl {requested}s is outside the allowed range 1..={max}s")]
159 TtlOutOfRange { requested: i64, max: i64 },
160 #[error(transparent)]
165 GrantMisconfigured(#[from] GrantError),
166 #[error(transparent)]
169 BundleExpansion(#[from] BundleExpansionError),
170 #[error(transparent)]
172 Store(#[from] StoreError),
173 #[error(transparent)]
175 Codec(#[from] CodecError),
176}
177
178impl From<McpMintError> for Error {
179 fn from(e: McpMintError) -> Self {
180 match e {
181 McpMintError::Store(s) => Error::Store(s),
182 McpMintError::Codec(c) => Error::Codec(c),
183 McpMintError::BundleExpansion(BundleExpansionError::Store(s)) => Error::Store(s),
184 other => Error::InvalidInput(other.to_string()),
190 }
191 }
192}
193
194pub struct McpAuthority<B, G, O> {
205 minter: PasetoV4SecretMinter,
206 bundles: B,
207 grants: G,
208 ownership: O,
209 iss: String,
210 kid: String,
214 policy: McpPolicy,
215}
216
217impl<B, G, O> McpAuthority<B, G, O>
218where
219 B: BundleStore,
220 G: GrantStore,
221 O: OwnershipStore,
222{
223 pub fn new(
229 minter: PasetoV4SecretMinter,
230 bundles: B,
231 grants: G,
232 ownership: O,
233 iss: impl Into<String>,
234 kid: impl Into<String>,
235 ) -> Self {
236 Self {
237 minter,
238 bundles,
239 grants,
240 ownership,
241 iss: iss.into(),
242 kid: kid.into(),
243 policy: McpPolicy::default(),
244 }
245 }
246
247 pub fn with_policy(mut self, policy: McpPolicy) -> Self {
249 self.policy = policy;
250 self
251 }
252
253 pub fn policy(&self) -> &McpPolicy {
254 &self.policy
255 }
256
257 pub fn issuer(&self) -> &str {
258 &self.iss
259 }
260
261 pub async fn mint_user_fresh(
283 &self,
284 user: PrincipalId,
285 actor: Option<Actor>,
286 camp_id: Option<String>,
287 aud: impl Into<String>,
288 now: i64,
289 ) -> Result<MintedMcpToken, McpMintError> {
290 if user.kind != PrincipalKind::User {
291 return Err(McpMintError::WrongPrincipalKind {
292 expected: PrincipalKind::User,
293 got: user.kind,
294 });
295 }
296 let aud = aud.into();
297
298 let entries = self.grants.list_for(&user, &aud).await?;
299 if entries.is_empty() {
300 return Err(McpMintError::AudNotEntitled {
301 principal: user.to_string(),
302 aud,
303 });
304 }
305
306 let scopes = expand_scopes(&self.bundles, &entries).await?;
307 for s in &scopes {
308 validate_grant(user.kind, *s)?;
309 }
310
311 let owns = match &camp_id {
312 Some(id) => {
313 let camp_principal = PrincipalId::camp(id);
314 let rows = self.ownership.list_for_principal(&camp_principal).await?;
315 rows_to_owns(&rows)
316 }
317 None => Owns::default(),
318 };
319
320 let mut claims = McpClaims::new(
321 self.iss.clone(),
322 aud,
323 user,
324 now,
325 now + self.policy.access_ttl_seconds,
326 generate_jti(),
327 scopes,
328 )
329 .with_auth_strength(AuthStrength::UserFresh);
330 if let Some(a) = actor {
331 claims = claims.with_act(a);
332 }
333 if let Some(c) = camp_id {
334 claims = claims.with_camp_id(c);
335 }
336 if !owns.is_empty() {
337 claims = claims.with_owns(owns);
338 }
339
340 let token = self.minter.mint_mcp(&claims, &self.kid)?;
341 Ok(MintedMcpToken { token, claims })
342 }
343
344 pub async fn mint_api_token(
396 &self,
397 user: PrincipalId,
398 aud: impl Into<String>,
399 requested: &[Scope],
400 ttl_seconds: Option<i64>,
401 now: i64,
402 ) -> Result<MintedMcpToken, McpMintError> {
403 if user.kind != PrincipalKind::User {
404 return Err(McpMintError::WrongPrincipalKind {
405 expected: PrincipalKind::User,
406 got: user.kind,
407 });
408 }
409 let aud = aud.into();
410
411 let ttl = self.api_token_ttl(ttl_seconds)?;
412
413 let entries = self.grants.list_for(&user, &aud).await?;
414 if entries.is_empty() {
415 return Err(McpMintError::AudNotEntitled {
416 principal: user.to_string(),
417 aud,
418 });
419 }
420
421 let effective = expand_scopes(&self.bundles, &entries).await?;
422 for s in &effective {
423 validate_grant(user.kind, *s)?;
424 }
425
426 let scopes = attenuate(requested, effective, &user, &aud)?;
427
428 self.sign_api_token(user, aud, scopes, ttl, now)
429 }
430
431 pub fn rotate_api_token(
473 &self,
474 user: PrincipalId,
475 aud: impl Into<String>,
476 held: &[Scope],
477 requested: &[Scope],
478 ttl_seconds: Option<i64>,
479 now: i64,
480 ) -> Result<MintedMcpToken, McpMintError> {
481 if user.kind != PrincipalKind::User {
482 return Err(McpMintError::WrongPrincipalKind {
483 expected: PrincipalKind::User,
484 got: user.kind,
485 });
486 }
487 let aud = aud.into();
488 let ttl = self.api_token_ttl(ttl_seconds)?;
489
490 let mut ceiling: Vec<Scope> = Vec::with_capacity(held.len());
491 for s in held {
492 validate_grant(user.kind, *s)?;
493 if !ceiling.contains(s) {
494 ceiling.push(*s);
495 }
496 }
497
498 let scopes = attenuate(requested, ceiling, &user, &aud)?;
499
500 self.sign_api_token(user, aud, scopes, ttl, now)
501 }
502
503 fn api_token_ttl(&self, ttl_seconds: Option<i64>) -> Result<i64, McpMintError> {
508 match ttl_seconds {
509 None => Ok(self.policy.api_token_default_ttl_seconds),
510 Some(n) if n >= 1 && n <= self.policy.api_token_max_ttl_seconds => Ok(n),
511 Some(n) => Err(McpMintError::TtlOutOfRange {
512 requested: n,
513 max: self.policy.api_token_max_ttl_seconds,
514 }),
515 }
516 }
517
518 fn sign_api_token(
523 &self,
524 user: PrincipalId,
525 aud: String,
526 scopes: Vec<Scope>,
527 ttl: i64,
528 now: i64,
529 ) -> Result<MintedMcpToken, McpMintError> {
530 let claims = McpClaims::new(
531 self.iss.clone(),
532 aud,
533 user,
534 now,
535 now + ttl,
536 generate_jti(),
537 scopes,
538 )
539 .with_auth_strength(AuthStrength::ApiToken);
540
541 let token = self.minter.mint_mcp(&claims, &self.kid)?;
542 Ok(MintedMcpToken { token, claims })
543 }
544
545 pub async fn mint_bootstrap(
571 &self,
572 camp: PrincipalId,
573 aud: impl Into<String>,
574 now: i64,
575 ) -> Result<MintedMcpToken, McpMintError> {
576 if camp.kind != PrincipalKind::Camp {
577 return Err(McpMintError::WrongPrincipalKind {
578 expected: PrincipalKind::Camp,
579 got: camp.kind,
580 });
581 }
582 let aud = aud.into();
583
584 let entries = self.grants.list_for(&camp, &aud).await?;
585 if entries.is_empty() {
586 return Err(McpMintError::AudNotEntitled {
587 principal: camp.to_string(),
588 aud,
589 });
590 }
591
592 let scopes = expand_scopes(&self.bundles, &entries).await?;
593 for s in &scopes {
594 validate_grant(camp.kind, *s)?;
595 }
596
597 let rows = self.ownership.list_for_principal(&camp).await?;
598 let owns = rows_to_owns(&rows);
599
600 let camp_id = camp.id.clone();
601 let mut claims = McpClaims::new(
602 self.iss.clone(),
603 aud,
604 camp,
605 now,
606 now + self.policy.access_ttl_seconds,
607 generate_jti(),
608 scopes,
609 )
610 .with_camp_id(camp_id)
611 .with_auth_strength(AuthStrength::Bootstrap);
612 if !owns.is_empty() {
613 claims = claims.with_owns(owns);
614 }
615
616 let token = self.minter.mint_mcp(&claims, &self.kid)?;
617 Ok(MintedMcpToken { token, claims })
618 }
619
620 pub async fn mint_token_exchange(
656 &self,
657 user: PrincipalId,
658 camp: PrincipalId,
659 actor: Option<Actor>,
660 aud: impl Into<String>,
661 requested_scope: Vec<Scope>,
662 now: i64,
663 ) -> Result<MintedMcpToken, McpMintError> {
664 if user.kind != PrincipalKind::User {
665 return Err(McpMintError::WrongPrincipalKind {
666 expected: PrincipalKind::User,
667 got: user.kind,
668 });
669 }
670 if camp.kind != PrincipalKind::Camp {
671 return Err(McpMintError::WrongPrincipalKind {
672 expected: PrincipalKind::Camp,
673 got: camp.kind,
674 });
675 }
676 let aud = aud.into();
677
678 let user_entries = self.grants.list_for(&user, &aud).await?;
680 if user_entries.is_empty() {
681 return Err(McpMintError::AudNotEntitled {
682 principal: user.to_string(),
683 aud,
684 });
685 }
686 let user_scopes = expand_scopes(&self.bundles, &user_entries).await?;
687 for s in &user_scopes {
688 validate_grant(user.kind, *s)?;
689 }
690
691 let camp_entries = self.grants.list_for(&camp, &aud).await?;
695 if camp_entries.is_empty() {
696 return Err(McpMintError::AudNotEntitled {
697 principal: camp.to_string(),
698 aud,
699 });
700 }
701 let camp_scopes = expand_scopes(&self.bundles, &camp_entries).await?;
702 for s in &camp_scopes {
703 validate_grant(camp.kind, *s)?;
704 }
705
706 for s in &requested_scope {
710 if !user_scopes.contains(s) || !camp_scopes.contains(s) {
711 return Err(McpMintError::InvalidScope {
712 scope: *s,
713 aud: aud.clone(),
714 });
715 }
716 }
717
718 let rows = self.ownership.list_for_principal(&camp).await?;
721 let owns = rows_to_owns(&rows);
722
723 let camp_id = camp.id.clone();
724 let mut claims = McpClaims::new(
725 self.iss.clone(),
726 aud,
727 user,
728 now,
729 now + self.policy.access_ttl_seconds,
730 generate_jti(),
731 requested_scope,
732 )
733 .with_camp_id(camp_id)
734 .with_auth_strength(AuthStrength::UserFresh);
735 if let Some(a) = actor {
736 claims = claims.with_act(a);
737 }
738 if !owns.is_empty() {
739 claims = claims.with_owns(owns);
740 }
741
742 let token = self.minter.mint_mcp(&claims, &self.kid)?;
743 Ok(MintedMcpToken { token, claims })
744 }
745}
746
747fn attenuate(
757 requested: &[Scope],
758 ceiling: Vec<Scope>,
759 user: &PrincipalId,
760 aud: &str,
761) -> Result<Vec<Scope>, McpMintError> {
762 if requested.is_empty() {
763 return Ok(ceiling);
764 }
765 let unentitled: Vec<Scope> = requested
766 .iter()
767 .filter(|s| !ceiling.contains(s))
768 .copied()
769 .collect();
770 if !unentitled.is_empty() {
771 return Err(McpMintError::UnentitledScopes {
772 principal: user.to_string(),
773 scopes: unentitled,
774 aud: aud.to_owned(),
775 });
776 }
777 let mut out: Vec<Scope> = Vec::with_capacity(requested.len());
780 for s in requested {
781 if !out.contains(s) {
782 out.push(*s);
783 }
784 }
785 Ok(out)
786}
787
788fn rows_to_owns(rows: &[OwnershipRow]) -> Owns {
800 fn push_unique(list: &mut Vec<String>, id: &str) {
801 if !list.iter().any(|existing| existing == id) {
802 list.push(id.to_owned());
803 }
804 }
805 let mut o = Owns::default();
806 for r in rows {
807 if r.is_revoked() {
808 continue;
809 }
810 match r.resource_kind.as_str() {
811 "service" => push_unique(&mut o.service, &r.resource_id),
812 "arch_doc" => push_unique(&mut o.arch_doc, &r.resource_id),
813 "node" => push_unique(&mut o.node, &r.resource_id),
814 other => push_unique(
815 o.extra.entry(other.to_owned()).or_default(),
816 &r.resource_id,
817 ),
818 }
819 }
820 o
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use crate::bundles::{BundleName, MemoryBundleStore, ScopeOrBundle};
827 use crate::codec::PasetoV4SecretMinter;
828 use crate::grants::MemoryGrantStore;
829 use crate::ownership::{NewOwnership, OwnershipRow, OwnershipStore};
830 use async_trait::async_trait;
831 use cheers_core::{Scope, StoreError};
832 use cheers_verify::PasetoV4PublicVerifier;
833 use std::sync::Mutex;
834
835 #[derive(Default)]
842 struct MemOwnershipStore {
843 rows: Mutex<Vec<OwnershipRow>>,
844 next_id: Mutex<u64>,
845 }
846
847 impl MemOwnershipStore {
848 fn new() -> Self {
849 Self::default()
850 }
851 }
852
853 #[async_trait]
854 impl OwnershipStore for MemOwnershipStore {
855 async fn insert(
856 &self,
857 ownership: &NewOwnership,
858 now: i64,
859 ) -> Result<OwnershipRow, StoreError> {
860 let mut id_g = self.next_id.lock().unwrap();
861 *id_g += 1;
862 let id = format!("row-{}", *id_g);
863 drop(id_g);
864 let row = OwnershipRow::new(
865 id,
866 ownership.principal_id.clone(),
867 ownership.resource_kind.clone(),
868 ownership.resource_id.clone(),
869 ownership.relationship.clone(),
870 ownership.granted_by.clone(),
871 ownership.on_behalf_of.clone(),
872 now,
873 None,
874 );
875 self.rows.lock().unwrap().push(row.clone());
876 Ok(row)
877 }
878
879 async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError> {
880 Ok(self.rows.lock().unwrap().iter().find(|r| r.id == id).cloned())
881 }
882
883 async fn revoke_by_id(&self, id: &str, now: i64) -> Result<(), StoreError> {
884 let mut g = self.rows.lock().unwrap();
885 let row = g
886 .iter_mut()
887 .find(|r| r.id == id)
888 .ok_or(StoreError::NotFound)?;
889 if row.revoked_at.is_none() {
890 row.revoked_at = Some(now);
891 }
892 Ok(())
893 }
894
895 async fn revoke_by_on_behalf_of(
896 &self,
897 user: &PrincipalId,
898 now: i64,
899 ) -> Result<u64, StoreError> {
900 let mut count = 0u64;
901 for r in self.rows.lock().unwrap().iter_mut() {
902 if r.revoked_at.is_none() && r.on_behalf_of.as_ref() == Some(user) {
903 r.revoked_at = Some(now);
904 count += 1;
905 }
906 }
907 Ok(count)
908 }
909
910 async fn list_for_principal(
911 &self,
912 principal: &PrincipalId,
913 ) -> Result<Vec<OwnershipRow>, StoreError> {
914 Ok(self
915 .rows
916 .lock()
917 .unwrap()
918 .iter()
919 .filter(|r| r.revoked_at.is_none() && r.principal_id == *principal)
920 .cloned()
921 .collect())
922 }
923
924 async fn list_for_resource(
925 &self,
926 resource_kind: &str,
927 resource_id: &str,
928 ) -> Result<Vec<OwnershipRow>, StoreError> {
929 Ok(self
930 .rows
931 .lock()
932 .unwrap()
933 .iter()
934 .filter(|r| {
935 r.revoked_at.is_none()
936 && r.resource_kind == resource_kind
937 && r.resource_id == resource_id
938 })
939 .cloned()
940 .collect())
941 }
942 }
943
944 const RIG_KID: &str = "mcp-authority-test-kid-1";
949
950 fn rig() -> (
951 McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>,
952 PasetoV4PublicVerifier,
953 ) {
954 let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
955 let bundles = MemoryBundleStore::with_defaults();
956 let grants = MemoryGrantStore::new();
957 let ownership = MemOwnershipStore::new();
958 let authority = McpAuthority::new(
959 minter,
960 bundles,
961 grants,
962 ownership,
963 "https://cheers.example",
964 RIG_KID,
965 );
966 (authority, verifier)
967 }
968
969 fn put_simple_grant(authority: &McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>, principal: PrincipalId, aud: &str, entries: Vec<ScopeOrBundle>) {
970 authority.grants.put(principal, aud, entries);
971 }
972
973 #[test]
976 fn mcp_policy_default_is_ten_minutes() {
977 let p = McpPolicy::default();
978 assert_eq!(p.access_ttl_seconds, 10 * 60);
979 let p = McpPolicy::new(300);
980 assert_eq!(p.access_ttl_seconds, 300);
981 let p = McpPolicy::default().with_access_ttl(5 * 60);
982 assert_eq!(p.access_ttl_seconds, 5 * 60);
983 }
984
985 #[test]
988 fn mint_user_fresh_signs_token_verifiable_at_edge() {
989 let (authority, verifier) = rig();
990 let user = PrincipalId::user("alice");
991 let aud = "https://kamaji.camp.example";
992 put_simple_grant(
993 &authority,
994 user.clone(),
995 aud,
996 vec![ScopeOrBundle::Scope(Scope::CloudDeploy), ScopeOrBundle::Scope(Scope::CloudRead)],
997 );
998
999 pollster::block_on(async {
1000 let minted = authority
1001 .mint_user_fresh(user.clone(), None, None, aud, 1_000)
1002 .await
1003 .unwrap();
1004
1005 assert!(minted.token.starts_with("v4.public."));
1007 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
1009 assert!(!minted.claims.jti.is_empty());
1011 assert_eq!(minted.claims.iat, 1_000);
1012 assert_eq!(
1013 minted.claims.exp,
1014 1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1015 );
1016 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1018 assert_eq!(back, minted.claims);
1019 assert_eq!(back.sub, user);
1020 assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
1021 });
1022 }
1023
1024 #[test]
1025 fn mint_user_fresh_expands_bundles_at_mint_time() {
1026 let (authority, _verifier) = rig();
1030 let user = PrincipalId::user("bob");
1031 let aud = "https://kamaji.example";
1032 put_simple_grant(
1033 &authority,
1034 user.clone(),
1035 aud,
1036 vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
1037 );
1038
1039 pollster::block_on(async {
1040 let first = authority
1041 .mint_user_fresh(user.clone(), None, None, aud, 1_000)
1042 .await
1043 .unwrap();
1044 assert!(first.claims.scope.contains(&Scope::CloudDestroy));
1045
1046 authority
1048 .bundles
1049 .put(
1050 &BundleName::new("deploy-admin"),
1051 &[Scope::CloudRead, Scope::CloudDeploy],
1052 )
1053 .await
1054 .unwrap();
1055
1056 let second = authority
1057 .mint_user_fresh(user, None, None, aud, 1_100)
1058 .await
1059 .unwrap();
1060 assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
1061 assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
1062 });
1063 }
1064
1065 #[test]
1066 fn mint_user_fresh_populates_act_camp_id_and_owns() {
1067 let (authority, verifier) = rig();
1068 let user = PrincipalId::user("carol");
1069 let camp_id = "camp-xyz";
1070 let aud = "https://aud.example";
1071 put_simple_grant(
1072 &authority,
1073 user.clone(),
1074 aud,
1075 vec![ScopeOrBundle::Scope(Scope::CampRead)],
1076 );
1077 pollster::block_on(async {
1079 let svc = NewOwnership::new(
1080 PrincipalId::camp(camp_id),
1081 "service",
1082 "svc-a",
1083 "owns",
1084 PrincipalId::service("yubaba"),
1085 Some(user.clone()),
1086 )
1087 .unwrap();
1088 authority.ownership.insert(&svc, 500).await.unwrap();
1089
1090 let doc = NewOwnership::new(
1091 PrincipalId::camp(camp_id),
1092 "arch_doc",
1093 "doc-1",
1094 "owns",
1095 PrincipalId::service("yubaba"),
1096 Some(user.clone()),
1097 )
1098 .unwrap();
1099 authority.ownership.insert(&doc, 500).await.unwrap();
1100
1101 let minted = authority
1102 .mint_user_fresh(
1103 user.clone(),
1104 Some(Actor::new(PrincipalId::service("agent-claude"))),
1105 Some(camp_id.to_owned()),
1106 aud,
1107 1_000,
1108 )
1109 .await
1110 .unwrap();
1111
1112 assert_eq!(
1113 minted.claims.act.as_ref().unwrap().sub,
1114 PrincipalId::service("agent-claude")
1115 );
1116 assert_eq!(minted.claims.camp_id.as_deref(), Some(camp_id));
1117 assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
1118 assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
1119
1120 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1122 assert_eq!(back, minted.claims);
1123 });
1124 }
1125
1126 #[test]
1127 fn mint_user_fresh_omits_owns_when_camp_owns_nothing() {
1128 let (authority, _verifier) = rig();
1131 let user = PrincipalId::user("dan");
1132 let aud = "https://aud.example";
1133 put_simple_grant(
1134 &authority,
1135 user.clone(),
1136 aud,
1137 vec![ScopeOrBundle::Scope(Scope::CampRead)],
1138 );
1139
1140 pollster::block_on(async {
1141 let minted = authority
1142 .mint_user_fresh(user, None, Some("camp-empty".into()), aud, 1_000)
1143 .await
1144 .unwrap();
1145 assert!(minted.claims.owns.is_empty());
1146 let json = serde_json::to_string(&minted.claims).unwrap();
1147 assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
1148 });
1149 }
1150
1151 #[test]
1154 fn mint_user_fresh_rejects_unentitled_aud() {
1155 let (authority, _verifier) = rig();
1156 let user = PrincipalId::user("eve");
1157 pollster::block_on(async {
1159 let err = authority
1160 .mint_user_fresh(user, None, None, "https://aud.example", 1_000)
1161 .await
1162 .unwrap_err();
1163 match err {
1164 McpMintError::AudNotEntitled { principal, aud } => {
1165 assert_eq!(principal, "user:eve");
1166 assert_eq!(aud, "https://aud.example");
1167 }
1168 other => panic!("expected AudNotEntitled, got {other:?}"),
1169 }
1170 });
1171 }
1172
1173 #[test]
1174 fn mint_user_fresh_rejects_non_user_principal() {
1175 let (authority, _verifier) = rig();
1176 pollster::block_on(async {
1177 let err = authority
1178 .mint_user_fresh(
1179 PrincipalId::service("yubaba"),
1180 None,
1181 None,
1182 "https://aud.example",
1183 1_000,
1184 )
1185 .await
1186 .unwrap_err();
1187 match err {
1188 McpMintError::WrongPrincipalKind { expected, got } => {
1189 assert_eq!(expected, PrincipalKind::User);
1190 assert_eq!(got, PrincipalKind::Service);
1191 }
1192 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1193 }
1194 });
1195 }
1196
1197 #[test]
1200 fn mint_api_token_defaults_to_ninety_days_and_the_full_effective_scope_set() {
1201 let (authority, verifier) = rig();
1202 let user = PrincipalId::user("alice");
1203 let aud = "https://kamaji.example";
1204 put_simple_grant(
1205 &authority,
1206 user.clone(),
1207 aud,
1208 vec![
1209 ScopeOrBundle::Scope(Scope::CloudRead),
1210 ScopeOrBundle::Scope(Scope::CloudDeploy),
1211 ],
1212 );
1213
1214 pollster::block_on(async {
1215 let minted = authority
1216 .mint_api_token(user.clone(), aud, &[], None, 1_000)
1217 .await
1218 .unwrap();
1219 assert_eq!(
1220 minted.claims.exp,
1221 1_000 + McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
1222 );
1223 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
1224 assert_eq!(minted.claims.sub, user);
1225 assert_eq!(
1227 minted.claims.scope,
1228 vec![Scope::CloudRead, Scope::CloudDeploy]
1229 );
1230 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1233 assert_eq!(back, minted.claims);
1234 });
1235 }
1236
1237 #[test]
1240 fn mint_api_token_attenuates_to_the_requested_subset() {
1241 let (authority, _verifier) = rig();
1242 let user = PrincipalId::user("alice");
1243 let aud = "https://kamaji.example";
1244 put_simple_grant(
1245 &authority,
1246 user.clone(),
1247 aud,
1248 vec![
1249 ScopeOrBundle::Scope(Scope::CloudRead),
1250 ScopeOrBundle::Scope(Scope::CloudDeploy),
1251 ScopeOrBundle::Scope(Scope::CloudDestroy),
1252 ],
1253 );
1254
1255 pollster::block_on(async {
1256 let minted = authority
1257 .mint_api_token(user, aud, &[Scope::CloudRead], None, 1_000)
1258 .await
1259 .unwrap();
1260 assert_eq!(minted.claims.scope, vec![Scope::CloudRead]);
1261 });
1262 }
1263
1264 #[test]
1268 fn mint_api_token_names_every_unentitled_scope_instead_of_dropping_it() {
1269 let (authority, _verifier) = rig();
1270 let user = PrincipalId::user("alice");
1271 let aud = "https://kamaji.example";
1272 put_simple_grant(
1273 &authority,
1274 user.clone(),
1275 aud,
1276 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1277 );
1278
1279 pollster::block_on(async {
1280 let err = authority
1281 .mint_api_token(
1282 user,
1283 aud,
1284 &[Scope::CloudRead, Scope::CloudDestroy, Scope::CampAdmin],
1285 None,
1286 1_000,
1287 )
1288 .await
1289 .unwrap_err();
1290 match err {
1291 McpMintError::UnentitledScopes { ref scopes, .. } => {
1292 assert_eq!(scopes, &vec![Scope::CloudDestroy, Scope::CampAdmin]);
1293 let msg = err.to_string();
1294 assert!(msg.contains("cloud:destroy"), "{msg}");
1295 assert!(msg.contains("camp:admin"), "{msg}");
1296 }
1297 other => panic!("expected UnentitledScopes, got {other:?}"),
1298 }
1299 });
1300 }
1301
1302 #[test]
1306 fn mint_api_token_rejects_service_only_scope_smuggled_via_bundle() {
1307 let (authority, _verifier) = rig();
1308 let user = PrincipalId::user("mallory");
1309 let aud = "https://aud.example";
1310
1311 pollster::block_on(async {
1312 authority
1313 .bundles
1314 .put(
1315 &BundleName::new("dangerous"),
1316 &[Scope::CampRead, Scope::OwnershipWrite],
1317 )
1318 .await
1319 .unwrap();
1320 authority.grants.put(
1321 user.clone(),
1322 aud,
1323 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1324 );
1325 match authority.mint_api_token(user, aud, &[], None, 1_000).await {
1326 Err(McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope {
1327 scope,
1328 kind,
1329 })) => {
1330 assert_eq!(scope, Scope::OwnershipWrite);
1331 assert_eq!(kind, PrincipalKind::User);
1332 }
1333 other => panic!("expected GrantMisconfigured, got {other:?}"),
1334 }
1335 });
1336 }
1337
1338 #[test]
1339 fn mint_api_token_rejects_an_out_of_range_ttl_rather_than_clamping() {
1340 let (authority, _verifier) = rig();
1341 let user = PrincipalId::user("alice");
1342 let aud = "https://kamaji.example";
1343 put_simple_grant(
1344 &authority,
1345 user.clone(),
1346 aud,
1347 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1348 );
1349
1350 pollster::block_on(async {
1351 for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
1352 match authority
1353 .mint_api_token(user.clone(), aud, &[], Some(bad), 1_000)
1354 .await
1355 {
1356 Err(McpMintError::TtlOutOfRange { requested, max }) => {
1357 assert_eq!(requested, bad);
1358 assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
1359 }
1360 other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
1361 }
1362 }
1363 let ok = authority
1365 .mint_api_token(
1366 user,
1367 aud,
1368 &[],
1369 Some(McpPolicy::MAX_API_TOKEN_TTL_SECONDS),
1370 1_000,
1371 )
1372 .await
1373 .unwrap();
1374 assert_eq!(
1375 ok.claims.exp,
1376 1_000 + McpPolicy::MAX_API_TOKEN_TTL_SECONDS
1377 );
1378 });
1379 }
1380
1381 #[test]
1382 fn mint_api_token_rejects_an_unentitled_aud_before_signing() {
1383 let (authority, _verifier) = rig();
1384 let user = PrincipalId::user("alice");
1385 pollster::block_on(async {
1386 match authority
1387 .mint_api_token(user, "https://nothing-granted.example", &[], None, 1_000)
1388 .await
1389 {
1390 Err(McpMintError::AudNotEntitled { principal, aud }) => {
1391 assert_eq!(principal, "user:alice");
1392 assert_eq!(aud, "https://nothing-granted.example");
1393 }
1394 other => panic!("expected AudNotEntitled, got {other:?}"),
1395 }
1396 });
1397 }
1398
1399 #[test]
1405 fn mint_api_token_carries_no_owns_no_camp_id_and_no_act() {
1406 let (authority, _verifier) = rig();
1407 let user = PrincipalId::user("alice");
1408 let aud = "https://kamaji.example";
1409 put_simple_grant(
1410 &authority,
1411 user.clone(),
1412 aud,
1413 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1414 );
1415
1416 pollster::block_on(async {
1417 let minted = authority
1418 .mint_api_token(user, aud, &[], None, 1_000)
1419 .await
1420 .unwrap();
1421 assert!(minted.claims.owns.is_empty());
1422 assert_eq!(minted.claims.camp_id, None);
1423 assert_eq!(minted.claims.act, None);
1424 });
1425 }
1426
1427 #[test]
1428 fn mint_api_token_rejects_a_non_user_principal() {
1429 let (authority, _verifier) = rig();
1430 pollster::block_on(async {
1431 match authority
1432 .mint_api_token(PrincipalId::camp("c1"), "https://aud.example", &[], None, 1)
1433 .await
1434 {
1435 Err(McpMintError::WrongPrincipalKind { expected, got }) => {
1436 assert_eq!(expected, PrincipalKind::User);
1437 assert_eq!(got, PrincipalKind::Camp);
1438 }
1439 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1440 }
1441 });
1442 }
1443
1444 #[test]
1455 fn rotate_api_token_mints_from_the_presented_scopes_without_reading_grants() {
1456 let (authority, verifier) = rig();
1457 let user = PrincipalId::user("alice");
1458 let aud = "https://kamaji.example";
1459 let minted = authority
1462 .rotate_api_token(
1463 user.clone(),
1464 aud,
1465 &[Scope::CloudRead, Scope::CloudDeploy],
1466 &[],
1467 None,
1468 1_000,
1469 )
1470 .unwrap();
1471 assert_eq!(minted.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
1472 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
1473 assert_eq!(minted.claims.sub, user.clone());
1474 assert_eq!(minted.claims.aud, aud);
1475 assert!(minted.claims.owns.is_empty());
1476 let claims = verifier.verify_mcp_at(&minted.token, 1_001, RIG_KID).unwrap();
1478 assert_eq!(claims.jti, minted.claims.jti);
1479
1480 pollster::block_on(async {
1482 assert!(matches!(
1483 authority.mint_api_token(user, aud, &[], None, 1_000).await,
1484 Err(McpMintError::AudNotEntitled { .. })
1485 ));
1486 });
1487 }
1488
1489 #[test]
1490 fn rotate_api_token_can_narrow_but_never_widen() {
1491 let (authority, _verifier) = rig();
1492 let user = PrincipalId::user("alice");
1493 let aud = "https://kamaji.example";
1494 put_simple_grant(
1496 &authority,
1497 user.clone(),
1498 aud,
1499 vec![
1500 ScopeOrBundle::Scope(Scope::CloudRead),
1501 ScopeOrBundle::Scope(Scope::CloudDeploy),
1502 ScopeOrBundle::Scope(Scope::CloudDestroy),
1503 ],
1504 );
1505
1506 let narrowed = authority
1508 .rotate_api_token(
1509 user.clone(),
1510 aud,
1511 &[Scope::CloudRead, Scope::CloudDeploy],
1512 &[Scope::CloudRead],
1513 None,
1514 1_000,
1515 )
1516 .unwrap();
1517 assert_eq!(narrowed.claims.scope, vec![Scope::CloudRead]);
1518
1519 match authority.rotate_api_token(
1522 user,
1523 aud,
1524 &[Scope::CloudRead],
1525 &[Scope::CloudDestroy],
1526 None,
1527 1_000,
1528 ) {
1529 Err(McpMintError::UnentitledScopes { scopes, .. }) => {
1530 assert_eq!(scopes, vec![Scope::CloudDestroy]);
1531 }
1532 other => panic!("expected UnentitledScopes, got {other:?}"),
1533 }
1534 }
1535
1536 #[test]
1540 fn rotate_api_token_gets_a_fresh_ttl_under_the_same_ceiling() {
1541 let (authority, _verifier) = rig();
1542 let user = PrincipalId::user("alice");
1543 let aud = "https://kamaji.example";
1544
1545 let default_ttl = authority
1546 .rotate_api_token(user.clone(), aud, &[Scope::CloudRead], &[], None, 5_000)
1547 .unwrap();
1548 assert_eq!(
1549 default_ttl.claims.exp - default_ttl.claims.iat,
1550 McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
1551 );
1552 assert_eq!(default_ttl.claims.iat, 5_000, "the clock restarts at `now`");
1553
1554 for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
1555 match authority.rotate_api_token(
1556 user.clone(),
1557 aud,
1558 &[Scope::CloudRead],
1559 &[],
1560 Some(bad),
1561 5_000,
1562 ) {
1563 Err(McpMintError::TtlOutOfRange { requested, max }) => {
1564 assert_eq!(requested, bad);
1565 assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
1566 }
1567 other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
1568 }
1569 }
1570 }
1571
1572 #[test]
1576 fn rotate_api_token_rejects_a_service_only_scope_in_the_presented_token() {
1577 let (authority, _verifier) = rig();
1578 assert!(matches!(
1579 authority.rotate_api_token(
1580 PrincipalId::user("alice"),
1581 "https://kamaji.example",
1582 &[Scope::OwnershipWrite],
1583 &[],
1584 None,
1585 1_000,
1586 ),
1587 Err(McpMintError::GrantMisconfigured(_))
1588 ));
1589 }
1590
1591 #[test]
1592 fn rotate_api_token_rejects_a_non_user_principal() {
1593 let (authority, _verifier) = rig();
1594 match authority.rotate_api_token(
1595 PrincipalId::camp("c1"),
1596 "https://aud.example",
1597 &[Scope::CloudRead],
1598 &[],
1599 None,
1600 1,
1601 ) {
1602 Err(McpMintError::WrongPrincipalKind { expected, got }) => {
1603 assert_eq!(expected, PrincipalKind::User);
1604 assert_eq!(got, PrincipalKind::Camp);
1605 }
1606 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1607 }
1608 }
1609
1610 #[test]
1611 fn mint_user_fresh_rejects_service_only_scope_smuggled_via_bundle() {
1612 let (authority, _verifier) = rig();
1617 let user = PrincipalId::user("mallory");
1618 let aud = "https://aud.example";
1619
1620 pollster::block_on(async {
1621 authority
1622 .bundles
1623 .put(
1624 &BundleName::new("dangerous"),
1625 &[Scope::CampRead, Scope::OwnershipWrite],
1626 )
1627 .await
1628 .unwrap();
1629 authority.grants.put(
1630 user.clone(),
1631 aud,
1632 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1633 );
1634
1635 let err = authority
1636 .mint_user_fresh(user, None, None, aud, 1_000)
1637 .await
1638 .unwrap_err();
1639 match err {
1640 McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1641 assert_eq!(scope, Scope::OwnershipWrite);
1642 assert_eq!(kind, PrincipalKind::User);
1643 }
1644 other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1645 }
1646 });
1647 }
1648
1649 #[test]
1652 fn mint_bootstrap_signs_token_verifiable_at_edge() {
1653 let (authority, verifier) = rig();
1654 let camp = PrincipalId::camp("c-xyz");
1655 let aud = "https://kamaji.camp.example";
1656 put_simple_grant(
1657 &authority,
1658 camp.clone(),
1659 aud,
1660 vec![
1661 ScopeOrBundle::Scope(Scope::CloudDeploy),
1662 ScopeOrBundle::Scope(Scope::CloudRead),
1663 ],
1664 );
1665
1666 pollster::block_on(async {
1667 let minted = authority
1668 .mint_bootstrap(camp.clone(), aud, 1_000)
1669 .await
1670 .unwrap();
1671
1672 assert!(minted.token.starts_with("v4.public."));
1674 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::Bootstrap));
1676 assert_eq!(minted.claims.sub, camp);
1679 assert_eq!(minted.claims.camp_id.as_deref(), Some("c-xyz"));
1680 assert!(minted.claims.act.is_none());
1683 assert!(!minted.claims.jti.is_empty());
1684 assert_eq!(minted.claims.iat, 1_000);
1685 assert_eq!(
1686 minted.claims.exp,
1687 1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1688 );
1689
1690 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1693 assert_eq!(back, minted.claims);
1694 assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
1695 });
1696 }
1697
1698 #[test]
1699 fn mint_bootstrap_owns_reflects_ownership_table_state() {
1700 let (authority, _verifier) = rig();
1701 let camp = PrincipalId::camp("c-with-owns");
1702 let user = PrincipalId::user("u-on-behalf");
1703 let aud = "https://aud.example";
1704 put_simple_grant(
1705 &authority,
1706 camp.clone(),
1707 aud,
1708 vec![ScopeOrBundle::Scope(Scope::CampRead)],
1709 );
1710
1711 pollster::block_on(async {
1712 let svc = NewOwnership::new(
1716 camp.clone(),
1717 "service",
1718 "svc-a",
1719 "owns",
1720 PrincipalId::service("yubaba"),
1721 Some(user.clone()),
1722 )
1723 .unwrap();
1724 authority.ownership.insert(&svc, 500).await.unwrap();
1725
1726 let doc = NewOwnership::new(
1727 camp.clone(),
1728 "arch_doc",
1729 "doc-1",
1730 "owns",
1731 PrincipalId::service("yubaba"),
1732 Some(user.clone()),
1733 )
1734 .unwrap();
1735 authority.ownership.insert(&doc, 500).await.unwrap();
1736
1737 let minted = authority
1738 .mint_bootstrap(camp.clone(), aud, 1_000)
1739 .await
1740 .unwrap();
1741
1742 assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
1743 assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
1744 });
1745 }
1746
1747 #[test]
1748 fn rows_to_owns_dedups_duplicate_resource_ids() {
1749 let camp = PrincipalId::camp("c-dup");
1753 let svc = PrincipalId::service("yubaba");
1754 let mk = |id: &str, kind: &str, rid: &str| {
1755 OwnershipRow::new(
1756 id.to_owned(),
1757 camp.clone(),
1758 kind.to_owned(),
1759 rid.to_owned(),
1760 "owns".to_owned(),
1761 svc.clone(),
1762 None,
1763 500,
1764 None,
1765 )
1766 };
1767 let rows = vec![
1768 mk("row-1", "service", "svc-a"),
1769 mk("row-2", "service", "svc-a"), mk("row-3", "service", "svc-b"),
1771 mk("row-4", "node", "aa11"),
1772 mk("row-5", "node", "aa11"), mk("row-6", "pond", "p-1"),
1774 mk("row-7", "pond", "p-1"), ];
1776
1777 let owns = rows_to_owns(&rows);
1778 assert_eq!(owns.service, vec!["svc-a".to_string(), "svc-b".to_string()]);
1779 assert_eq!(owns.node, vec!["aa11".to_string()]);
1780 assert_eq!(
1781 owns.extra.get("pond"),
1782 Some(&vec!["p-1".to_string()])
1783 );
1784 }
1785
1786 #[test]
1787 fn mint_bootstrap_omits_owns_when_camp_owns_nothing() {
1788 let (authority, _verifier) = rig();
1793 let camp = PrincipalId::camp("c-empty");
1794 let aud = "https://aud.example";
1795 put_simple_grant(
1796 &authority,
1797 camp.clone(),
1798 aud,
1799 vec![ScopeOrBundle::Scope(Scope::CampRead)],
1800 );
1801
1802 pollster::block_on(async {
1803 let minted = authority
1804 .mint_bootstrap(camp, aud, 1_000)
1805 .await
1806 .unwrap();
1807 assert!(minted.claims.owns.is_empty());
1808 let json = serde_json::to_string(&minted.claims).unwrap();
1809 assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
1810 assert!(json.contains("\"camp_id\":\"c-empty\""), "camp_id must remain: {json}");
1812 });
1813 }
1814
1815 #[test]
1816 fn mint_bootstrap_expands_bundles_at_mint_time() {
1817 let (authority, _verifier) = rig();
1820 let camp = PrincipalId::camp("c-deploy");
1821 let aud = "https://aud.example";
1822 put_simple_grant(
1823 &authority,
1824 camp.clone(),
1825 aud,
1826 vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
1827 );
1828
1829 pollster::block_on(async {
1830 let first = authority
1831 .mint_bootstrap(camp.clone(), aud, 1_000)
1832 .await
1833 .unwrap();
1834 assert!(first.claims.scope.contains(&Scope::CloudDestroy));
1835
1836 authority
1837 .bundles
1838 .put(
1839 &BundleName::new("deploy-admin"),
1840 &[Scope::CloudRead, Scope::CloudDeploy],
1841 )
1842 .await
1843 .unwrap();
1844
1845 let second = authority
1846 .mint_bootstrap(camp, aud, 1_100)
1847 .await
1848 .unwrap();
1849 assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
1850 assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
1851 });
1852 }
1853
1854 #[test]
1857 fn mint_bootstrap_rejects_non_camp_principal() {
1858 let (authority, _verifier) = rig();
1859 pollster::block_on(async {
1860 let err = authority
1863 .mint_bootstrap(
1864 PrincipalId::user("alice"),
1865 "https://aud.example",
1866 1_000,
1867 )
1868 .await
1869 .unwrap_err();
1870 match err {
1871 McpMintError::WrongPrincipalKind { expected, got } => {
1872 assert_eq!(expected, PrincipalKind::Camp);
1873 assert_eq!(got, PrincipalKind::User);
1874 }
1875 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1876 }
1877
1878 let err = authority
1880 .mint_bootstrap(
1881 PrincipalId::service("yubaba"),
1882 "https://aud.example",
1883 1_000,
1884 )
1885 .await
1886 .unwrap_err();
1887 match err {
1888 McpMintError::WrongPrincipalKind { expected, got } => {
1889 assert_eq!(expected, PrincipalKind::Camp);
1890 assert_eq!(got, PrincipalKind::Service);
1891 }
1892 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1893 }
1894 });
1895 }
1896
1897 #[test]
1898 fn mint_bootstrap_rejects_unentitled_aud() {
1899 let (authority, _verifier) = rig();
1900 let camp = PrincipalId::camp("c-no-grants");
1901 pollster::block_on(async {
1903 let err = authority
1904 .mint_bootstrap(camp, "https://aud.example", 1_000)
1905 .await
1906 .unwrap_err();
1907 match err {
1908 McpMintError::AudNotEntitled { principal, aud } => {
1909 assert_eq!(principal, "camp:c-no-grants");
1910 assert_eq!(aud, "https://aud.example");
1911 }
1912 other => panic!("expected AudNotEntitled, got {other:?}"),
1913 }
1914 });
1915 }
1916
1917 #[test]
1918 fn mint_bootstrap_rejects_service_only_scope_smuggled_via_bundle() {
1919 let (authority, _verifier) = rig();
1925 let camp = PrincipalId::camp("c-sneaky");
1926 let aud = "https://aud.example";
1927
1928 pollster::block_on(async {
1929 authority
1930 .bundles
1931 .put(
1932 &BundleName::new("dangerous"),
1933 &[Scope::CampRead, Scope::OwnershipWrite],
1934 )
1935 .await
1936 .unwrap();
1937 authority.grants.put(
1938 camp.clone(),
1939 aud,
1940 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1941 );
1942
1943 let err = authority
1944 .mint_bootstrap(camp, aud, 1_000)
1945 .await
1946 .unwrap_err();
1947 match err {
1948 McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1949 assert_eq!(scope, Scope::OwnershipWrite);
1950 assert_eq!(kind, PrincipalKind::Camp);
1951 }
1952 other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1953 }
1954 });
1955 }
1956
1957 #[test]
1960 fn mint_token_exchange_attributes_to_user_with_camp_as_context() {
1961 let (authority, verifier) = rig();
1962 let user = PrincipalId::user("alice");
1963 let camp = PrincipalId::camp("c-multi");
1964 let aud = "https://kamaji.camp.example";
1965
1966 put_simple_grant(
1969 &authority,
1970 user.clone(),
1971 aud,
1972 vec![
1973 ScopeOrBundle::Scope(Scope::CloudDeploy),
1974 ScopeOrBundle::Scope(Scope::CloudRead),
1975 ],
1976 );
1977 put_simple_grant(
1978 &authority,
1979 camp.clone(),
1980 aud,
1981 vec![
1982 ScopeOrBundle::Scope(Scope::CloudDeploy),
1983 ScopeOrBundle::Scope(Scope::CloudRead),
1984 ],
1985 );
1986
1987 pollster::block_on(async {
1989 let svc = NewOwnership::new(
1990 camp.clone(),
1991 "service",
1992 "svc-prod",
1993 "owns",
1994 PrincipalId::service("yubaba"),
1995 Some(user.clone()),
1996 )
1997 .unwrap();
1998 authority.ownership.insert(&svc, 500).await.unwrap();
1999
2000 let minted = authority
2001 .mint_token_exchange(
2002 user.clone(),
2003 camp.clone(),
2004 Some(Actor::new(PrincipalId::service("agent-claude"))),
2005 aud,
2006 vec![Scope::CloudDeploy],
2007 1_000,
2008 )
2009 .await
2010 .unwrap();
2011
2012 assert!(minted.token.starts_with("v4.public."));
2013 assert_eq!(minted.claims.sub, user);
2016 assert_eq!(minted.claims.camp_id.as_deref(), Some("c-multi"));
2018 assert_eq!(
2020 minted.claims.act.as_ref().unwrap().sub,
2021 PrincipalId::service("agent-claude"),
2022 );
2023 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
2025 assert_eq!(minted.claims.scope, vec![Scope::CloudDeploy]);
2027 assert_eq!(minted.claims.owns.service, vec!["svc-prod".to_string()]);
2029 assert!(!minted.claims.jti.is_empty());
2031 assert_eq!(minted.claims.iat, 1_000);
2032 assert_eq!(
2033 minted.claims.exp,
2034 1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
2035 );
2036 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
2038 assert_eq!(back, minted.claims);
2039 });
2040 }
2041
2042 #[test]
2043 fn mint_token_exchange_intersection_drops_scope_one_side_lacks() {
2044 let (authority, _verifier) = rig();
2049 let user = PrincipalId::user("bob");
2050 let camp = PrincipalId::camp("c-narrow");
2051 let aud = "https://aud.example";
2052
2053 put_simple_grant(
2055 &authority,
2056 user.clone(),
2057 aud,
2058 vec![
2059 ScopeOrBundle::Scope(Scope::CloudDeploy),
2060 ScopeOrBundle::Scope(Scope::CloudRead),
2061 ],
2062 );
2063 put_simple_grant(
2064 &authority,
2065 camp.clone(),
2066 aud,
2067 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
2068 );
2069
2070 pollster::block_on(async {
2071 let minted = authority
2073 .mint_token_exchange(
2074 user.clone(),
2075 camp.clone(),
2076 None,
2077 aud,
2078 vec![Scope::CloudRead],
2079 1_000,
2080 )
2081 .await
2082 .unwrap();
2083 assert_eq!(minted.claims.scope, vec![Scope::CloudRead]);
2084
2085 let err = authority
2088 .mint_token_exchange(
2089 user,
2090 camp,
2091 None,
2092 aud,
2093 vec![Scope::CloudRead, Scope::CloudDeploy],
2094 1_000,
2095 )
2096 .await
2097 .unwrap_err();
2098 match err {
2099 McpMintError::InvalidScope { scope, aud: a } => {
2100 assert_eq!(scope, Scope::CloudDeploy);
2101 assert_eq!(a, aud);
2102 }
2103 other => panic!("expected InvalidScope, got {other:?}"),
2104 }
2105 });
2106 }
2107
2108 #[test]
2109 fn mint_token_exchange_rejects_scope_user_lacks() {
2110 let (authority, _verifier) = rig();
2112 let user = PrincipalId::user("carol");
2113 let camp = PrincipalId::camp("c-wide");
2114 let aud = "https://aud.example";
2115
2116 put_simple_grant(
2117 &authority,
2118 user.clone(),
2119 aud,
2120 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
2121 );
2122 put_simple_grant(
2123 &authority,
2124 camp.clone(),
2125 aud,
2126 vec![
2127 ScopeOrBundle::Scope(Scope::CloudRead),
2128 ScopeOrBundle::Scope(Scope::CloudDeploy),
2129 ],
2130 );
2131
2132 pollster::block_on(async {
2133 let err = authority
2134 .mint_token_exchange(
2135 user,
2136 camp,
2137 None,
2138 aud,
2139 vec![Scope::CloudDeploy],
2140 1_000,
2141 )
2142 .await
2143 .unwrap_err();
2144 match err {
2145 McpMintError::InvalidScope { scope, aud: _ } => {
2146 assert_eq!(scope, Scope::CloudDeploy);
2147 }
2148 other => panic!("expected InvalidScope, got {other:?}"),
2149 }
2150 });
2151 }
2152
2153 #[test]
2156 fn mint_token_exchange_rejects_wrong_user_kind() {
2157 let (authority, _verifier) = rig();
2158 pollster::block_on(async {
2159 let err = authority
2160 .mint_token_exchange(
2161 PrincipalId::camp("c-as-user"),
2162 PrincipalId::camp("c"),
2163 None,
2164 "https://aud.example",
2165 vec![],
2166 1_000,
2167 )
2168 .await
2169 .unwrap_err();
2170 match err {
2171 McpMintError::WrongPrincipalKind { expected, got } => {
2172 assert_eq!(expected, PrincipalKind::User);
2173 assert_eq!(got, PrincipalKind::Camp);
2174 }
2175 other => panic!("expected WrongPrincipalKind, got {other:?}"),
2176 }
2177 });
2178 }
2179
2180 #[test]
2181 fn mint_token_exchange_rejects_wrong_camp_kind() {
2182 let (authority, _verifier) = rig();
2183 pollster::block_on(async {
2184 let err = authority
2185 .mint_token_exchange(
2186 PrincipalId::user("alice"),
2187 PrincipalId::service("yubaba"),
2188 None,
2189 "https://aud.example",
2190 vec![],
2191 1_000,
2192 )
2193 .await
2194 .unwrap_err();
2195 match err {
2196 McpMintError::WrongPrincipalKind { expected, got } => {
2197 assert_eq!(expected, PrincipalKind::Camp);
2198 assert_eq!(got, PrincipalKind::Service);
2199 }
2200 other => panic!("expected WrongPrincipalKind, got {other:?}"),
2201 }
2202 });
2203 }
2204
2205 #[test]
2206 fn mint_token_exchange_rejects_unentitled_user() {
2207 let (authority, _verifier) = rig();
2208 let user = PrincipalId::user("eve");
2209 let camp = PrincipalId::camp("c");
2210 let aud = "https://aud.example";
2211 put_simple_grant(
2213 &authority,
2214 camp.clone(),
2215 aud,
2216 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
2217 );
2218
2219 pollster::block_on(async {
2220 let err = authority
2221 .mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
2222 .await
2223 .unwrap_err();
2224 match err {
2225 McpMintError::AudNotEntitled { principal, aud: _ } => {
2226 assert_eq!(principal, "user:eve");
2227 }
2228 other => panic!("expected AudNotEntitled, got {other:?}"),
2229 }
2230 });
2231 }
2232
2233 #[test]
2234 fn mint_token_exchange_rejects_unentitled_camp() {
2235 let (authority, _verifier) = rig();
2238 let user = PrincipalId::user("frank");
2239 let camp = PrincipalId::camp("c-no-grants");
2240 let aud = "https://aud.example";
2241 put_simple_grant(
2242 &authority,
2243 user.clone(),
2244 aud,
2245 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
2246 );
2247
2248 pollster::block_on(async {
2249 let err = authority
2250 .mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
2251 .await
2252 .unwrap_err();
2253 match err {
2254 McpMintError::AudNotEntitled { principal, aud: _ } => {
2255 assert_eq!(principal, "camp:c-no-grants");
2256 }
2257 other => panic!("expected AudNotEntitled, got {other:?}"),
2258 }
2259 });
2260 }
2261
2262 #[test]
2263 fn mint_token_exchange_catches_service_only_scope_smuggled_via_camp_bundle() {
2264 let (authority, _verifier) = rig();
2268 let user = PrincipalId::user("g");
2269 let camp = PrincipalId::camp("c-sneaky");
2270 let aud = "https://aud.example";
2271
2272 pollster::block_on(async {
2273 authority
2274 .bundles
2275 .put(
2276 &BundleName::new("dangerous"),
2277 &[Scope::CampRead, Scope::OwnershipWrite],
2278 )
2279 .await
2280 .unwrap();
2281 put_simple_grant(
2282 &authority,
2283 user.clone(),
2284 aud,
2285 vec![ScopeOrBundle::Scope(Scope::CampRead)],
2286 );
2287 put_simple_grant(
2288 &authority,
2289 camp,
2290 aud,
2291 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
2292 );
2293
2294 let err = authority
2295 .mint_token_exchange(
2296 user,
2297 PrincipalId::camp("c-sneaky"),
2298 None,
2299 aud,
2300 vec![Scope::CampRead],
2301 1_000,
2302 )
2303 .await
2304 .unwrap_err();
2305 match err {
2306 McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
2307 assert_eq!(scope, Scope::OwnershipWrite);
2308 assert_eq!(kind, PrincipalKind::Camp);
2309 }
2310 other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
2311 }
2312 });
2313 }
2314
2315 #[test]
2318 fn mcp_mint_error_converts_to_umbrella_error() {
2319 let e: Error = McpMintError::AudNotEntitled {
2320 principal: "user:x".into(),
2321 aud: "a".into(),
2322 }
2323 .into();
2324 match e {
2325 Error::InvalidInput(msg) => assert!(msg.contains("aud")),
2326 other => panic!("expected InvalidInput, got {other:?}"),
2327 }
2328
2329 let e: Error = McpMintError::Store(StoreError::Conflict).into();
2330 assert!(matches!(e, Error::Store(StoreError::Conflict)));
2331
2332 let e: Error = McpMintError::Codec(CodecError::Malformed).into();
2333 assert!(matches!(e, Error::Codec(CodecError::Malformed)));
2334 }
2335}