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}
51
52impl McpPolicy {
53 pub const DEFAULT_ACCESS_TTL_SECONDS: i64 = 10 * 60;
55
56 pub fn new(access_ttl_seconds: i64) -> Self {
57 Self { access_ttl_seconds }
58 }
59
60 pub fn with_access_ttl(mut self, seconds: i64) -> Self {
61 self.access_ttl_seconds = seconds;
62 self
63 }
64}
65
66impl Default for McpPolicy {
67 fn default() -> Self {
68 Self {
69 access_ttl_seconds: Self::DEFAULT_ACCESS_TTL_SECONDS,
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub struct MintedMcpToken {
78 pub token: String,
80 pub claims: McpClaims,
82}
83
84#[derive(Debug, thiserror::Error)]
90#[non_exhaustive]
91pub enum McpMintError {
92 #[error("no entitlement: principal '{principal}' has no grant for aud '{aud}'")]
95 AudNotEntitled { principal: String, aud: String },
96 #[error("requested scope '{scope}' is not granted by both principals for aud '{aud}'")]
101 InvalidScope { scope: Scope, aud: String },
102 #[error("mint path expects {expected} principal; got {got}")]
106 WrongPrincipalKind {
107 expected: PrincipalKind,
108 got: PrincipalKind,
109 },
110 #[error(transparent)]
115 GrantMisconfigured(#[from] GrantError),
116 #[error(transparent)]
119 BundleExpansion(#[from] BundleExpansionError),
120 #[error(transparent)]
122 Store(#[from] StoreError),
123 #[error(transparent)]
125 Codec(#[from] CodecError),
126}
127
128impl From<McpMintError> for Error {
129 fn from(e: McpMintError) -> Self {
130 match e {
131 McpMintError::Store(s) => Error::Store(s),
132 McpMintError::Codec(c) => Error::Codec(c),
133 McpMintError::BundleExpansion(BundleExpansionError::Store(s)) => Error::Store(s),
134 other => Error::InvalidInput(other.to_string()),
140 }
141 }
142}
143
144pub struct McpAuthority<B, G, O> {
155 minter: PasetoV4SecretMinter,
156 bundles: B,
157 grants: G,
158 ownership: O,
159 iss: String,
160 kid: String,
164 policy: McpPolicy,
165}
166
167impl<B, G, O> McpAuthority<B, G, O>
168where
169 B: BundleStore,
170 G: GrantStore,
171 O: OwnershipStore,
172{
173 pub fn new(
179 minter: PasetoV4SecretMinter,
180 bundles: B,
181 grants: G,
182 ownership: O,
183 iss: impl Into<String>,
184 kid: impl Into<String>,
185 ) -> Self {
186 Self {
187 minter,
188 bundles,
189 grants,
190 ownership,
191 iss: iss.into(),
192 kid: kid.into(),
193 policy: McpPolicy::default(),
194 }
195 }
196
197 pub fn with_policy(mut self, policy: McpPolicy) -> Self {
199 self.policy = policy;
200 self
201 }
202
203 pub fn policy(&self) -> &McpPolicy {
204 &self.policy
205 }
206
207 pub fn issuer(&self) -> &str {
208 &self.iss
209 }
210
211 pub async fn mint_user_fresh(
233 &self,
234 user: PrincipalId,
235 actor: Option<Actor>,
236 camp_id: Option<String>,
237 aud: impl Into<String>,
238 now: i64,
239 ) -> Result<MintedMcpToken, McpMintError> {
240 if user.kind != PrincipalKind::User {
241 return Err(McpMintError::WrongPrincipalKind {
242 expected: PrincipalKind::User,
243 got: user.kind,
244 });
245 }
246 let aud = aud.into();
247
248 let entries = self.grants.list_for(&user, &aud).await?;
249 if entries.is_empty() {
250 return Err(McpMintError::AudNotEntitled {
251 principal: user.to_string(),
252 aud,
253 });
254 }
255
256 let scopes = expand_scopes(&self.bundles, &entries).await?;
257 for s in &scopes {
258 validate_grant(user.kind, *s)?;
259 }
260
261 let owns = match &camp_id {
262 Some(id) => {
263 let camp_principal = PrincipalId::camp(id);
264 let rows = self.ownership.list_for_principal(&camp_principal).await?;
265 rows_to_owns(&rows)
266 }
267 None => Owns::default(),
268 };
269
270 let mut claims = McpClaims::new(
271 self.iss.clone(),
272 aud,
273 user,
274 now,
275 now + self.policy.access_ttl_seconds,
276 generate_jti(),
277 scopes,
278 )
279 .with_auth_strength(AuthStrength::UserFresh);
280 if let Some(a) = actor {
281 claims = claims.with_act(a);
282 }
283 if let Some(c) = camp_id {
284 claims = claims.with_camp_id(c);
285 }
286 if !owns.is_empty() {
287 claims = claims.with_owns(owns);
288 }
289
290 let token = self.minter.mint_mcp(&claims, &self.kid)?;
291 Ok(MintedMcpToken { token, claims })
292 }
293
294 pub async fn mint_bootstrap(
320 &self,
321 camp: PrincipalId,
322 aud: impl Into<String>,
323 now: i64,
324 ) -> Result<MintedMcpToken, McpMintError> {
325 if camp.kind != PrincipalKind::Camp {
326 return Err(McpMintError::WrongPrincipalKind {
327 expected: PrincipalKind::Camp,
328 got: camp.kind,
329 });
330 }
331 let aud = aud.into();
332
333 let entries = self.grants.list_for(&camp, &aud).await?;
334 if entries.is_empty() {
335 return Err(McpMintError::AudNotEntitled {
336 principal: camp.to_string(),
337 aud,
338 });
339 }
340
341 let scopes = expand_scopes(&self.bundles, &entries).await?;
342 for s in &scopes {
343 validate_grant(camp.kind, *s)?;
344 }
345
346 let rows = self.ownership.list_for_principal(&camp).await?;
347 let owns = rows_to_owns(&rows);
348
349 let camp_id = camp.id.clone();
350 let mut claims = McpClaims::new(
351 self.iss.clone(),
352 aud,
353 camp,
354 now,
355 now + self.policy.access_ttl_seconds,
356 generate_jti(),
357 scopes,
358 )
359 .with_camp_id(camp_id)
360 .with_auth_strength(AuthStrength::Bootstrap);
361 if !owns.is_empty() {
362 claims = claims.with_owns(owns);
363 }
364
365 let token = self.minter.mint_mcp(&claims, &self.kid)?;
366 Ok(MintedMcpToken { token, claims })
367 }
368
369 pub async fn mint_token_exchange(
405 &self,
406 user: PrincipalId,
407 camp: PrincipalId,
408 actor: Option<Actor>,
409 aud: impl Into<String>,
410 requested_scope: Vec<Scope>,
411 now: i64,
412 ) -> Result<MintedMcpToken, McpMintError> {
413 if user.kind != PrincipalKind::User {
414 return Err(McpMintError::WrongPrincipalKind {
415 expected: PrincipalKind::User,
416 got: user.kind,
417 });
418 }
419 if camp.kind != PrincipalKind::Camp {
420 return Err(McpMintError::WrongPrincipalKind {
421 expected: PrincipalKind::Camp,
422 got: camp.kind,
423 });
424 }
425 let aud = aud.into();
426
427 let user_entries = self.grants.list_for(&user, &aud).await?;
429 if user_entries.is_empty() {
430 return Err(McpMintError::AudNotEntitled {
431 principal: user.to_string(),
432 aud,
433 });
434 }
435 let user_scopes = expand_scopes(&self.bundles, &user_entries).await?;
436 for s in &user_scopes {
437 validate_grant(user.kind, *s)?;
438 }
439
440 let camp_entries = self.grants.list_for(&camp, &aud).await?;
444 if camp_entries.is_empty() {
445 return Err(McpMintError::AudNotEntitled {
446 principal: camp.to_string(),
447 aud,
448 });
449 }
450 let camp_scopes = expand_scopes(&self.bundles, &camp_entries).await?;
451 for s in &camp_scopes {
452 validate_grant(camp.kind, *s)?;
453 }
454
455 for s in &requested_scope {
459 if !user_scopes.contains(s) || !camp_scopes.contains(s) {
460 return Err(McpMintError::InvalidScope {
461 scope: *s,
462 aud: aud.clone(),
463 });
464 }
465 }
466
467 let rows = self.ownership.list_for_principal(&camp).await?;
470 let owns = rows_to_owns(&rows);
471
472 let camp_id = camp.id.clone();
473 let mut claims = McpClaims::new(
474 self.iss.clone(),
475 aud,
476 user,
477 now,
478 now + self.policy.access_ttl_seconds,
479 generate_jti(),
480 requested_scope,
481 )
482 .with_camp_id(camp_id)
483 .with_auth_strength(AuthStrength::UserFresh);
484 if let Some(a) = actor {
485 claims = claims.with_act(a);
486 }
487 if !owns.is_empty() {
488 claims = claims.with_owns(owns);
489 }
490
491 let token = self.minter.mint_mcp(&claims, &self.kid)?;
492 Ok(MintedMcpToken { token, claims })
493 }
494}
495
496fn rows_to_owns(rows: &[OwnershipRow]) -> Owns {
508 fn push_unique(list: &mut Vec<String>, id: &str) {
509 if !list.iter().any(|existing| existing == id) {
510 list.push(id.to_owned());
511 }
512 }
513 let mut o = Owns::default();
514 for r in rows {
515 if r.is_revoked() {
516 continue;
517 }
518 match r.resource_kind.as_str() {
519 "service" => push_unique(&mut o.service, &r.resource_id),
520 "arch_doc" => push_unique(&mut o.arch_doc, &r.resource_id),
521 "node" => push_unique(&mut o.node, &r.resource_id),
522 other => push_unique(
523 o.extra.entry(other.to_owned()).or_default(),
524 &r.resource_id,
525 ),
526 }
527 }
528 o
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::bundles::{BundleName, MemoryBundleStore, ScopeOrBundle};
535 use crate::codec::PasetoV4SecretMinter;
536 use crate::grants::MemoryGrantStore;
537 use crate::ownership::{NewOwnership, OwnershipRow, OwnershipStore};
538 use async_trait::async_trait;
539 use cheers_core::{Scope, StoreError};
540 use cheers_verify::PasetoV4PublicVerifier;
541 use std::sync::Mutex;
542
543 #[derive(Default)]
550 struct MemOwnershipStore {
551 rows: Mutex<Vec<OwnershipRow>>,
552 next_id: Mutex<u64>,
553 }
554
555 impl MemOwnershipStore {
556 fn new() -> Self {
557 Self::default()
558 }
559 }
560
561 #[async_trait]
562 impl OwnershipStore for MemOwnershipStore {
563 async fn insert(
564 &self,
565 ownership: &NewOwnership,
566 now: i64,
567 ) -> Result<OwnershipRow, StoreError> {
568 let mut id_g = self.next_id.lock().unwrap();
569 *id_g += 1;
570 let id = format!("row-{}", *id_g);
571 drop(id_g);
572 let row = OwnershipRow::new(
573 id,
574 ownership.principal_id.clone(),
575 ownership.resource_kind.clone(),
576 ownership.resource_id.clone(),
577 ownership.relationship.clone(),
578 ownership.granted_by.clone(),
579 ownership.on_behalf_of.clone(),
580 now,
581 None,
582 );
583 self.rows.lock().unwrap().push(row.clone());
584 Ok(row)
585 }
586
587 async fn get(&self, id: &str) -> Result<Option<OwnershipRow>, StoreError> {
588 Ok(self.rows.lock().unwrap().iter().find(|r| r.id == id).cloned())
589 }
590
591 async fn revoke_by_id(&self, id: &str, now: i64) -> Result<(), StoreError> {
592 let mut g = self.rows.lock().unwrap();
593 let row = g
594 .iter_mut()
595 .find(|r| r.id == id)
596 .ok_or(StoreError::NotFound)?;
597 if row.revoked_at.is_none() {
598 row.revoked_at = Some(now);
599 }
600 Ok(())
601 }
602
603 async fn revoke_by_on_behalf_of(
604 &self,
605 user: &PrincipalId,
606 now: i64,
607 ) -> Result<u64, StoreError> {
608 let mut count = 0u64;
609 for r in self.rows.lock().unwrap().iter_mut() {
610 if r.revoked_at.is_none() && r.on_behalf_of.as_ref() == Some(user) {
611 r.revoked_at = Some(now);
612 count += 1;
613 }
614 }
615 Ok(count)
616 }
617
618 async fn list_for_principal(
619 &self,
620 principal: &PrincipalId,
621 ) -> Result<Vec<OwnershipRow>, StoreError> {
622 Ok(self
623 .rows
624 .lock()
625 .unwrap()
626 .iter()
627 .filter(|r| r.revoked_at.is_none() && r.principal_id == *principal)
628 .cloned()
629 .collect())
630 }
631
632 async fn list_for_resource(
633 &self,
634 resource_kind: &str,
635 resource_id: &str,
636 ) -> Result<Vec<OwnershipRow>, StoreError> {
637 Ok(self
638 .rows
639 .lock()
640 .unwrap()
641 .iter()
642 .filter(|r| {
643 r.revoked_at.is_none()
644 && r.resource_kind == resource_kind
645 && r.resource_id == resource_id
646 })
647 .cloned()
648 .collect())
649 }
650 }
651
652 const RIG_KID: &str = "mcp-authority-test-kid-1";
657
658 fn rig() -> (
659 McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>,
660 PasetoV4PublicVerifier,
661 ) {
662 let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
663 let bundles = MemoryBundleStore::with_defaults();
664 let grants = MemoryGrantStore::new();
665 let ownership = MemOwnershipStore::new();
666 let authority = McpAuthority::new(
667 minter,
668 bundles,
669 grants,
670 ownership,
671 "https://cheers.example",
672 RIG_KID,
673 );
674 (authority, verifier)
675 }
676
677 fn put_simple_grant(authority: &McpAuthority<MemoryBundleStore, MemoryGrantStore, MemOwnershipStore>, principal: PrincipalId, aud: &str, entries: Vec<ScopeOrBundle>) {
678 authority.grants.put(principal, aud, entries);
679 }
680
681 #[test]
684 fn mcp_policy_default_is_ten_minutes() {
685 let p = McpPolicy::default();
686 assert_eq!(p.access_ttl_seconds, 10 * 60);
687 let p = McpPolicy::new(300);
688 assert_eq!(p.access_ttl_seconds, 300);
689 let p = McpPolicy::default().with_access_ttl(5 * 60);
690 assert_eq!(p.access_ttl_seconds, 5 * 60);
691 }
692
693 #[test]
696 fn mint_user_fresh_signs_token_verifiable_at_edge() {
697 let (authority, verifier) = rig();
698 let user = PrincipalId::user("alice");
699 let aud = "https://kamaji.camp.example";
700 put_simple_grant(
701 &authority,
702 user.clone(),
703 aud,
704 vec![ScopeOrBundle::Scope(Scope::CloudDeploy), ScopeOrBundle::Scope(Scope::CloudRead)],
705 );
706
707 pollster::block_on(async {
708 let minted = authority
709 .mint_user_fresh(user.clone(), None, None, aud, 1_000)
710 .await
711 .unwrap();
712
713 assert!(minted.token.starts_with("v4.public."));
715 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
717 assert!(!minted.claims.jti.is_empty());
719 assert_eq!(minted.claims.iat, 1_000);
720 assert_eq!(
721 minted.claims.exp,
722 1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
723 );
724 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
726 assert_eq!(back, minted.claims);
727 assert_eq!(back.sub, user);
728 assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
729 });
730 }
731
732 #[test]
733 fn mint_user_fresh_expands_bundles_at_mint_time() {
734 let (authority, _verifier) = rig();
738 let user = PrincipalId::user("bob");
739 let aud = "https://kamaji.example";
740 put_simple_grant(
741 &authority,
742 user.clone(),
743 aud,
744 vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
745 );
746
747 pollster::block_on(async {
748 let first = authority
749 .mint_user_fresh(user.clone(), None, None, aud, 1_000)
750 .await
751 .unwrap();
752 assert!(first.claims.scope.contains(&Scope::CloudDestroy));
753
754 authority
756 .bundles
757 .put(
758 &BundleName::new("deploy-admin"),
759 &[Scope::CloudRead, Scope::CloudDeploy],
760 )
761 .await
762 .unwrap();
763
764 let second = authority
765 .mint_user_fresh(user, None, None, aud, 1_100)
766 .await
767 .unwrap();
768 assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
769 assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
770 });
771 }
772
773 #[test]
774 fn mint_user_fresh_populates_act_camp_id_and_owns() {
775 let (authority, verifier) = rig();
776 let user = PrincipalId::user("carol");
777 let camp_id = "camp-xyz";
778 let aud = "https://aud.example";
779 put_simple_grant(
780 &authority,
781 user.clone(),
782 aud,
783 vec![ScopeOrBundle::Scope(Scope::CampRead)],
784 );
785 pollster::block_on(async {
787 let svc = NewOwnership::new(
788 PrincipalId::camp(camp_id),
789 "service",
790 "svc-a",
791 "owns",
792 PrincipalId::service("yubaba"),
793 Some(user.clone()),
794 )
795 .unwrap();
796 authority.ownership.insert(&svc, 500).await.unwrap();
797
798 let doc = NewOwnership::new(
799 PrincipalId::camp(camp_id),
800 "arch_doc",
801 "doc-1",
802 "owns",
803 PrincipalId::service("yubaba"),
804 Some(user.clone()),
805 )
806 .unwrap();
807 authority.ownership.insert(&doc, 500).await.unwrap();
808
809 let minted = authority
810 .mint_user_fresh(
811 user.clone(),
812 Some(Actor::new(PrincipalId::service("agent-claude"))),
813 Some(camp_id.to_owned()),
814 aud,
815 1_000,
816 )
817 .await
818 .unwrap();
819
820 assert_eq!(
821 minted.claims.act.as_ref().unwrap().sub,
822 PrincipalId::service("agent-claude")
823 );
824 assert_eq!(minted.claims.camp_id.as_deref(), Some(camp_id));
825 assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
826 assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
827
828 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
830 assert_eq!(back, minted.claims);
831 });
832 }
833
834 #[test]
835 fn mint_user_fresh_omits_owns_when_camp_owns_nothing() {
836 let (authority, _verifier) = rig();
839 let user = PrincipalId::user("dan");
840 let aud = "https://aud.example";
841 put_simple_grant(
842 &authority,
843 user.clone(),
844 aud,
845 vec![ScopeOrBundle::Scope(Scope::CampRead)],
846 );
847
848 pollster::block_on(async {
849 let minted = authority
850 .mint_user_fresh(user, None, Some("camp-empty".into()), aud, 1_000)
851 .await
852 .unwrap();
853 assert!(minted.claims.owns.is_empty());
854 let json = serde_json::to_string(&minted.claims).unwrap();
855 assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
856 });
857 }
858
859 #[test]
862 fn mint_user_fresh_rejects_unentitled_aud() {
863 let (authority, _verifier) = rig();
864 let user = PrincipalId::user("eve");
865 pollster::block_on(async {
867 let err = authority
868 .mint_user_fresh(user, None, None, "https://aud.example", 1_000)
869 .await
870 .unwrap_err();
871 match err {
872 McpMintError::AudNotEntitled { principal, aud } => {
873 assert_eq!(principal, "user:eve");
874 assert_eq!(aud, "https://aud.example");
875 }
876 other => panic!("expected AudNotEntitled, got {other:?}"),
877 }
878 });
879 }
880
881 #[test]
882 fn mint_user_fresh_rejects_non_user_principal() {
883 let (authority, _verifier) = rig();
884 pollster::block_on(async {
885 let err = authority
886 .mint_user_fresh(
887 PrincipalId::service("yubaba"),
888 None,
889 None,
890 "https://aud.example",
891 1_000,
892 )
893 .await
894 .unwrap_err();
895 match err {
896 McpMintError::WrongPrincipalKind { expected, got } => {
897 assert_eq!(expected, PrincipalKind::User);
898 assert_eq!(got, PrincipalKind::Service);
899 }
900 other => panic!("expected WrongPrincipalKind, got {other:?}"),
901 }
902 });
903 }
904
905 #[test]
906 fn mint_user_fresh_rejects_service_only_scope_smuggled_via_bundle() {
907 let (authority, _verifier) = rig();
912 let user = PrincipalId::user("mallory");
913 let aud = "https://aud.example";
914
915 pollster::block_on(async {
916 authority
917 .bundles
918 .put(
919 &BundleName::new("dangerous"),
920 &[Scope::CampRead, Scope::OwnershipWrite],
921 )
922 .await
923 .unwrap();
924 authority.grants.put(
925 user.clone(),
926 aud,
927 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
928 );
929
930 let err = authority
931 .mint_user_fresh(user, None, None, aud, 1_000)
932 .await
933 .unwrap_err();
934 match err {
935 McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
936 assert_eq!(scope, Scope::OwnershipWrite);
937 assert_eq!(kind, PrincipalKind::User);
938 }
939 other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
940 }
941 });
942 }
943
944 #[test]
947 fn mint_bootstrap_signs_token_verifiable_at_edge() {
948 let (authority, verifier) = rig();
949 let camp = PrincipalId::camp("c-xyz");
950 let aud = "https://kamaji.camp.example";
951 put_simple_grant(
952 &authority,
953 camp.clone(),
954 aud,
955 vec![
956 ScopeOrBundle::Scope(Scope::CloudDeploy),
957 ScopeOrBundle::Scope(Scope::CloudRead),
958 ],
959 );
960
961 pollster::block_on(async {
962 let minted = authority
963 .mint_bootstrap(camp.clone(), aud, 1_000)
964 .await
965 .unwrap();
966
967 assert!(minted.token.starts_with("v4.public."));
969 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::Bootstrap));
971 assert_eq!(minted.claims.sub, camp);
974 assert_eq!(minted.claims.camp_id.as_deref(), Some("c-xyz"));
975 assert!(minted.claims.act.is_none());
978 assert!(!minted.claims.jti.is_empty());
979 assert_eq!(minted.claims.iat, 1_000);
980 assert_eq!(
981 minted.claims.exp,
982 1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
983 );
984
985 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
988 assert_eq!(back, minted.claims);
989 assert_eq!(back.scope, vec![Scope::CloudDeploy, Scope::CloudRead]);
990 });
991 }
992
993 #[test]
994 fn mint_bootstrap_owns_reflects_ownership_table_state() {
995 let (authority, _verifier) = rig();
996 let camp = PrincipalId::camp("c-with-owns");
997 let user = PrincipalId::user("u-on-behalf");
998 let aud = "https://aud.example";
999 put_simple_grant(
1000 &authority,
1001 camp.clone(),
1002 aud,
1003 vec![ScopeOrBundle::Scope(Scope::CampRead)],
1004 );
1005
1006 pollster::block_on(async {
1007 let svc = NewOwnership::new(
1011 camp.clone(),
1012 "service",
1013 "svc-a",
1014 "owns",
1015 PrincipalId::service("yubaba"),
1016 Some(user.clone()),
1017 )
1018 .unwrap();
1019 authority.ownership.insert(&svc, 500).await.unwrap();
1020
1021 let doc = NewOwnership::new(
1022 camp.clone(),
1023 "arch_doc",
1024 "doc-1",
1025 "owns",
1026 PrincipalId::service("yubaba"),
1027 Some(user.clone()),
1028 )
1029 .unwrap();
1030 authority.ownership.insert(&doc, 500).await.unwrap();
1031
1032 let minted = authority
1033 .mint_bootstrap(camp.clone(), aud, 1_000)
1034 .await
1035 .unwrap();
1036
1037 assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
1038 assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
1039 });
1040 }
1041
1042 #[test]
1043 fn rows_to_owns_dedups_duplicate_resource_ids() {
1044 let camp = PrincipalId::camp("c-dup");
1048 let svc = PrincipalId::service("yubaba");
1049 let mk = |id: &str, kind: &str, rid: &str| {
1050 OwnershipRow::new(
1051 id.to_owned(),
1052 camp.clone(),
1053 kind.to_owned(),
1054 rid.to_owned(),
1055 "owns".to_owned(),
1056 svc.clone(),
1057 None,
1058 500,
1059 None,
1060 )
1061 };
1062 let rows = vec![
1063 mk("row-1", "service", "svc-a"),
1064 mk("row-2", "service", "svc-a"), mk("row-3", "service", "svc-b"),
1066 mk("row-4", "node", "aa11"),
1067 mk("row-5", "node", "aa11"), mk("row-6", "pond", "p-1"),
1069 mk("row-7", "pond", "p-1"), ];
1071
1072 let owns = rows_to_owns(&rows);
1073 assert_eq!(owns.service, vec!["svc-a".to_string(), "svc-b".to_string()]);
1074 assert_eq!(owns.node, vec!["aa11".to_string()]);
1075 assert_eq!(
1076 owns.extra.get("pond"),
1077 Some(&vec!["p-1".to_string()])
1078 );
1079 }
1080
1081 #[test]
1082 fn mint_bootstrap_omits_owns_when_camp_owns_nothing() {
1083 let (authority, _verifier) = rig();
1088 let camp = PrincipalId::camp("c-empty");
1089 let aud = "https://aud.example";
1090 put_simple_grant(
1091 &authority,
1092 camp.clone(),
1093 aud,
1094 vec![ScopeOrBundle::Scope(Scope::CampRead)],
1095 );
1096
1097 pollster::block_on(async {
1098 let minted = authority
1099 .mint_bootstrap(camp, aud, 1_000)
1100 .await
1101 .unwrap();
1102 assert!(minted.claims.owns.is_empty());
1103 let json = serde_json::to_string(&minted.claims).unwrap();
1104 assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
1105 assert!(json.contains("\"camp_id\":\"c-empty\""), "camp_id must remain: {json}");
1107 });
1108 }
1109
1110 #[test]
1111 fn mint_bootstrap_expands_bundles_at_mint_time() {
1112 let (authority, _verifier) = rig();
1115 let camp = PrincipalId::camp("c-deploy");
1116 let aud = "https://aud.example";
1117 put_simple_grant(
1118 &authority,
1119 camp.clone(),
1120 aud,
1121 vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
1122 );
1123
1124 pollster::block_on(async {
1125 let first = authority
1126 .mint_bootstrap(camp.clone(), aud, 1_000)
1127 .await
1128 .unwrap();
1129 assert!(first.claims.scope.contains(&Scope::CloudDestroy));
1130
1131 authority
1132 .bundles
1133 .put(
1134 &BundleName::new("deploy-admin"),
1135 &[Scope::CloudRead, Scope::CloudDeploy],
1136 )
1137 .await
1138 .unwrap();
1139
1140 let second = authority
1141 .mint_bootstrap(camp, aud, 1_100)
1142 .await
1143 .unwrap();
1144 assert!(!second.claims.scope.contains(&Scope::CloudDestroy));
1145 assert_eq!(second.claims.scope, vec![Scope::CloudRead, Scope::CloudDeploy]);
1146 });
1147 }
1148
1149 #[test]
1152 fn mint_bootstrap_rejects_non_camp_principal() {
1153 let (authority, _verifier) = rig();
1154 pollster::block_on(async {
1155 let err = authority
1158 .mint_bootstrap(
1159 PrincipalId::user("alice"),
1160 "https://aud.example",
1161 1_000,
1162 )
1163 .await
1164 .unwrap_err();
1165 match err {
1166 McpMintError::WrongPrincipalKind { expected, got } => {
1167 assert_eq!(expected, PrincipalKind::Camp);
1168 assert_eq!(got, PrincipalKind::User);
1169 }
1170 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1171 }
1172
1173 let err = authority
1175 .mint_bootstrap(
1176 PrincipalId::service("yubaba"),
1177 "https://aud.example",
1178 1_000,
1179 )
1180 .await
1181 .unwrap_err();
1182 match err {
1183 McpMintError::WrongPrincipalKind { expected, got } => {
1184 assert_eq!(expected, PrincipalKind::Camp);
1185 assert_eq!(got, PrincipalKind::Service);
1186 }
1187 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1188 }
1189 });
1190 }
1191
1192 #[test]
1193 fn mint_bootstrap_rejects_unentitled_aud() {
1194 let (authority, _verifier) = rig();
1195 let camp = PrincipalId::camp("c-no-grants");
1196 pollster::block_on(async {
1198 let err = authority
1199 .mint_bootstrap(camp, "https://aud.example", 1_000)
1200 .await
1201 .unwrap_err();
1202 match err {
1203 McpMintError::AudNotEntitled { principal, aud } => {
1204 assert_eq!(principal, "camp:c-no-grants");
1205 assert_eq!(aud, "https://aud.example");
1206 }
1207 other => panic!("expected AudNotEntitled, got {other:?}"),
1208 }
1209 });
1210 }
1211
1212 #[test]
1213 fn mint_bootstrap_rejects_service_only_scope_smuggled_via_bundle() {
1214 let (authority, _verifier) = rig();
1220 let camp = PrincipalId::camp("c-sneaky");
1221 let aud = "https://aud.example";
1222
1223 pollster::block_on(async {
1224 authority
1225 .bundles
1226 .put(
1227 &BundleName::new("dangerous"),
1228 &[Scope::CampRead, Scope::OwnershipWrite],
1229 )
1230 .await
1231 .unwrap();
1232 authority.grants.put(
1233 camp.clone(),
1234 aud,
1235 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1236 );
1237
1238 let err = authority
1239 .mint_bootstrap(camp, aud, 1_000)
1240 .await
1241 .unwrap_err();
1242 match err {
1243 McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1244 assert_eq!(scope, Scope::OwnershipWrite);
1245 assert_eq!(kind, PrincipalKind::Camp);
1246 }
1247 other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1248 }
1249 });
1250 }
1251
1252 #[test]
1255 fn mint_token_exchange_attributes_to_user_with_camp_as_context() {
1256 let (authority, verifier) = rig();
1257 let user = PrincipalId::user("alice");
1258 let camp = PrincipalId::camp("c-multi");
1259 let aud = "https://kamaji.camp.example";
1260
1261 put_simple_grant(
1264 &authority,
1265 user.clone(),
1266 aud,
1267 vec![
1268 ScopeOrBundle::Scope(Scope::CloudDeploy),
1269 ScopeOrBundle::Scope(Scope::CloudRead),
1270 ],
1271 );
1272 put_simple_grant(
1273 &authority,
1274 camp.clone(),
1275 aud,
1276 vec![
1277 ScopeOrBundle::Scope(Scope::CloudDeploy),
1278 ScopeOrBundle::Scope(Scope::CloudRead),
1279 ],
1280 );
1281
1282 pollster::block_on(async {
1284 let svc = NewOwnership::new(
1285 camp.clone(),
1286 "service",
1287 "svc-prod",
1288 "owns",
1289 PrincipalId::service("yubaba"),
1290 Some(user.clone()),
1291 )
1292 .unwrap();
1293 authority.ownership.insert(&svc, 500).await.unwrap();
1294
1295 let minted = authority
1296 .mint_token_exchange(
1297 user.clone(),
1298 camp.clone(),
1299 Some(Actor::new(PrincipalId::service("agent-claude"))),
1300 aud,
1301 vec![Scope::CloudDeploy],
1302 1_000,
1303 )
1304 .await
1305 .unwrap();
1306
1307 assert!(minted.token.starts_with("v4.public."));
1308 assert_eq!(minted.claims.sub, user);
1311 assert_eq!(minted.claims.camp_id.as_deref(), Some("c-multi"));
1313 assert_eq!(
1315 minted.claims.act.as_ref().unwrap().sub,
1316 PrincipalId::service("agent-claude"),
1317 );
1318 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
1320 assert_eq!(minted.claims.scope, vec![Scope::CloudDeploy]);
1322 assert_eq!(minted.claims.owns.service, vec!["svc-prod".to_string()]);
1324 assert!(!minted.claims.jti.is_empty());
1326 assert_eq!(minted.claims.iat, 1_000);
1327 assert_eq!(
1328 minted.claims.exp,
1329 1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1330 );
1331 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1333 assert_eq!(back, minted.claims);
1334 });
1335 }
1336
1337 #[test]
1338 fn mint_token_exchange_intersection_drops_scope_one_side_lacks() {
1339 let (authority, _verifier) = rig();
1344 let user = PrincipalId::user("bob");
1345 let camp = PrincipalId::camp("c-narrow");
1346 let aud = "https://aud.example";
1347
1348 put_simple_grant(
1350 &authority,
1351 user.clone(),
1352 aud,
1353 vec![
1354 ScopeOrBundle::Scope(Scope::CloudDeploy),
1355 ScopeOrBundle::Scope(Scope::CloudRead),
1356 ],
1357 );
1358 put_simple_grant(
1359 &authority,
1360 camp.clone(),
1361 aud,
1362 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1363 );
1364
1365 pollster::block_on(async {
1366 let minted = authority
1368 .mint_token_exchange(
1369 user.clone(),
1370 camp.clone(),
1371 None,
1372 aud,
1373 vec![Scope::CloudRead],
1374 1_000,
1375 )
1376 .await
1377 .unwrap();
1378 assert_eq!(minted.claims.scope, vec![Scope::CloudRead]);
1379
1380 let err = authority
1383 .mint_token_exchange(
1384 user,
1385 camp,
1386 None,
1387 aud,
1388 vec![Scope::CloudRead, Scope::CloudDeploy],
1389 1_000,
1390 )
1391 .await
1392 .unwrap_err();
1393 match err {
1394 McpMintError::InvalidScope { scope, aud: a } => {
1395 assert_eq!(scope, Scope::CloudDeploy);
1396 assert_eq!(a, aud);
1397 }
1398 other => panic!("expected InvalidScope, got {other:?}"),
1399 }
1400 });
1401 }
1402
1403 #[test]
1404 fn mint_token_exchange_rejects_scope_user_lacks() {
1405 let (authority, _verifier) = rig();
1407 let user = PrincipalId::user("carol");
1408 let camp = PrincipalId::camp("c-wide");
1409 let aud = "https://aud.example";
1410
1411 put_simple_grant(
1412 &authority,
1413 user.clone(),
1414 aud,
1415 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1416 );
1417 put_simple_grant(
1418 &authority,
1419 camp.clone(),
1420 aud,
1421 vec![
1422 ScopeOrBundle::Scope(Scope::CloudRead),
1423 ScopeOrBundle::Scope(Scope::CloudDeploy),
1424 ],
1425 );
1426
1427 pollster::block_on(async {
1428 let err = authority
1429 .mint_token_exchange(
1430 user,
1431 camp,
1432 None,
1433 aud,
1434 vec![Scope::CloudDeploy],
1435 1_000,
1436 )
1437 .await
1438 .unwrap_err();
1439 match err {
1440 McpMintError::InvalidScope { scope, aud: _ } => {
1441 assert_eq!(scope, Scope::CloudDeploy);
1442 }
1443 other => panic!("expected InvalidScope, got {other:?}"),
1444 }
1445 });
1446 }
1447
1448 #[test]
1451 fn mint_token_exchange_rejects_wrong_user_kind() {
1452 let (authority, _verifier) = rig();
1453 pollster::block_on(async {
1454 let err = authority
1455 .mint_token_exchange(
1456 PrincipalId::camp("c-as-user"),
1457 PrincipalId::camp("c"),
1458 None,
1459 "https://aud.example",
1460 vec![],
1461 1_000,
1462 )
1463 .await
1464 .unwrap_err();
1465 match err {
1466 McpMintError::WrongPrincipalKind { expected, got } => {
1467 assert_eq!(expected, PrincipalKind::User);
1468 assert_eq!(got, PrincipalKind::Camp);
1469 }
1470 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1471 }
1472 });
1473 }
1474
1475 #[test]
1476 fn mint_token_exchange_rejects_wrong_camp_kind() {
1477 let (authority, _verifier) = rig();
1478 pollster::block_on(async {
1479 let err = authority
1480 .mint_token_exchange(
1481 PrincipalId::user("alice"),
1482 PrincipalId::service("yubaba"),
1483 None,
1484 "https://aud.example",
1485 vec![],
1486 1_000,
1487 )
1488 .await
1489 .unwrap_err();
1490 match err {
1491 McpMintError::WrongPrincipalKind { expected, got } => {
1492 assert_eq!(expected, PrincipalKind::Camp);
1493 assert_eq!(got, PrincipalKind::Service);
1494 }
1495 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1496 }
1497 });
1498 }
1499
1500 #[test]
1501 fn mint_token_exchange_rejects_unentitled_user() {
1502 let (authority, _verifier) = rig();
1503 let user = PrincipalId::user("eve");
1504 let camp = PrincipalId::camp("c");
1505 let aud = "https://aud.example";
1506 put_simple_grant(
1508 &authority,
1509 camp.clone(),
1510 aud,
1511 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1512 );
1513
1514 pollster::block_on(async {
1515 let err = authority
1516 .mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
1517 .await
1518 .unwrap_err();
1519 match err {
1520 McpMintError::AudNotEntitled { principal, aud: _ } => {
1521 assert_eq!(principal, "user:eve");
1522 }
1523 other => panic!("expected AudNotEntitled, got {other:?}"),
1524 }
1525 });
1526 }
1527
1528 #[test]
1529 fn mint_token_exchange_rejects_unentitled_camp() {
1530 let (authority, _verifier) = rig();
1533 let user = PrincipalId::user("frank");
1534 let camp = PrincipalId::camp("c-no-grants");
1535 let aud = "https://aud.example";
1536 put_simple_grant(
1537 &authority,
1538 user.clone(),
1539 aud,
1540 vec![ScopeOrBundle::Scope(Scope::CloudRead)],
1541 );
1542
1543 pollster::block_on(async {
1544 let err = authority
1545 .mint_token_exchange(user, camp, None, aud, vec![Scope::CloudRead], 1_000)
1546 .await
1547 .unwrap_err();
1548 match err {
1549 McpMintError::AudNotEntitled { principal, aud: _ } => {
1550 assert_eq!(principal, "camp:c-no-grants");
1551 }
1552 other => panic!("expected AudNotEntitled, got {other:?}"),
1553 }
1554 });
1555 }
1556
1557 #[test]
1558 fn mint_token_exchange_catches_service_only_scope_smuggled_via_camp_bundle() {
1559 let (authority, _verifier) = rig();
1563 let user = PrincipalId::user("g");
1564 let camp = PrincipalId::camp("c-sneaky");
1565 let aud = "https://aud.example";
1566
1567 pollster::block_on(async {
1568 authority
1569 .bundles
1570 .put(
1571 &BundleName::new("dangerous"),
1572 &[Scope::CampRead, Scope::OwnershipWrite],
1573 )
1574 .await
1575 .unwrap();
1576 put_simple_grant(
1577 &authority,
1578 user.clone(),
1579 aud,
1580 vec![ScopeOrBundle::Scope(Scope::CampRead)],
1581 );
1582 put_simple_grant(
1583 &authority,
1584 camp,
1585 aud,
1586 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1587 );
1588
1589 let err = authority
1590 .mint_token_exchange(
1591 user,
1592 PrincipalId::camp("c-sneaky"),
1593 None,
1594 aud,
1595 vec![Scope::CampRead],
1596 1_000,
1597 )
1598 .await
1599 .unwrap_err();
1600 match err {
1601 McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1602 assert_eq!(scope, Scope::OwnershipWrite);
1603 assert_eq!(kind, PrincipalKind::Camp);
1604 }
1605 other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1606 }
1607 });
1608 }
1609
1610 #[test]
1613 fn mcp_mint_error_converts_to_umbrella_error() {
1614 let e: Error = McpMintError::AudNotEntitled {
1615 principal: "user:x".into(),
1616 aud: "a".into(),
1617 }
1618 .into();
1619 match e {
1620 Error::InvalidInput(msg) => assert!(msg.contains("aud")),
1621 other => panic!("expected InvalidInput, got {other:?}"),
1622 }
1623
1624 let e: Error = McpMintError::Store(StoreError::Conflict).into();
1625 assert!(matches!(e, Error::Store(StoreError::Conflict)));
1626
1627 let e: Error = McpMintError::Codec(CodecError::Malformed).into();
1628 assert!(matches!(e, Error::Codec(CodecError::Malformed)));
1629 }
1630}