1use crate::arn::{TenantPath, WamiArn};
50use crate::error::{AmiError, Result};
51use serde::{Deserialize, Serialize};
52
53pub const MAX_PROVENANCE_DEPTH: usize = 8;
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub enum Transition {
64 Authenticated,
66 AssumedRole {
68 session_name: String,
70 },
71 PermissionSet {
73 name: String,
75 },
76 Federated {
78 issuer: String,
80 },
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct Step {
90 principal: WamiArn,
91 via: Transition,
92}
93
94impl Step {
95 pub fn principal(&self) -> &WamiArn {
97 &self.principal
98 }
99
100 pub fn via(&self) -> &Transition {
102 &self.via
103 }
104
105 pub fn service(&self) -> &'static str {
107 match self.via {
108 Transition::AssumedRole { .. } | Transition::Federated { .. } => "sts",
111 Transition::PermissionSet { .. } => "sso",
112 Transition::Authenticated => "iam",
113 }
114 }
115
116 fn segment(&self) -> String {
118 match &self.via {
119 Transition::Authenticated => {
120 format!("iam:user/{}", escape(self.principal.resource_id()))
121 }
122 Transition::AssumedRole { session_name } => format!(
123 "sts:assumed-role/{}/{}",
124 escape(self.principal.resource_id()),
125 escape(session_name)
126 ),
127 Transition::PermissionSet { name } => {
128 format!("sso:permission-set/{}", escape(name))
129 }
130 Transition::Federated { issuer } => format!("sts:federated/{}", escape(issuer)),
131 }
132 }
133}
134
135fn escape(value: &str) -> String {
141 value
142 .replace('%', "%25")
143 .replace(':', "%3A")
144 .replace('/', "%2F")
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SessionInfo {
150 pub session_token: String,
152 pub expiration: i64,
154 pub assumed_role_arn: Option<WamiArn>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(try_from = "WireContext")]
164pub struct WamiContext {
165 tenant_path: TenantPath,
167
168 instance_id: String,
170
171 caller_arn: WamiArn,
173
174 provenance: Vec<Step>,
180
181 is_root: bool,
183
184 region: Option<String>,
186
187 session_info: Option<SessionInfo>,
189
190 #[serde(skip_serializing_if = "Option::is_none")]
192 source_ip: Option<String>,
193
194 #[serde(skip_serializing_if = "Option::is_none")]
196 mfa_present: Option<bool>,
197
198 #[serde(skip_serializing_if = "Option::is_none")]
200 secure_transport: Option<bool>,
201}
202
203#[derive(Deserialize)]
212struct WireContext {
213 tenant_path: TenantPath,
214 instance_id: String,
215 caller_arn: WamiArn,
216 provenance: Vec<Step>,
217 is_root: bool,
218 region: Option<String>,
219 session_info: Option<SessionInfo>,
220 source_ip: Option<String>,
221 mfa_present: Option<bool>,
222 secure_transport: Option<bool>,
223}
224
225impl TryFrom<WireContext> for WamiContext {
226 type Error = AmiError;
227
228 fn try_from(wire: WireContext) -> Result<Self> {
229 match wire.provenance.last() {
230 None => {
231 return Err(AmiError::InvalidParameter {
232 message: "context has no provenance: every context records how \
233 authority was obtained, starting at authentication"
234 .to_string(),
235 })
236 }
237 Some(last) if last.principal != wire.caller_arn => {
238 return Err(AmiError::InvalidParameter {
239 message: format!(
240 "provenance ends on {} but the caller is {}",
241 last.principal, wire.caller_arn
242 ),
243 })
244 }
245 Some(_) => {}
246 }
247
248 if wire.provenance.len() > MAX_PROVENANCE_DEPTH {
249 return Err(AmiError::InvalidParameter {
250 message: format!(
251 "provenance is {} steps deep, past the maximum of {MAX_PROVENANCE_DEPTH}",
252 wire.provenance.len()
253 ),
254 });
255 }
256
257 Ok(WamiContext {
258 tenant_path: wire.tenant_path,
259 instance_id: wire.instance_id,
260 caller_arn: wire.caller_arn,
261 provenance: wire.provenance,
262 is_root: wire.is_root,
263 region: wire.region,
264 session_info: wire.session_info,
265 source_ip: wire.source_ip,
266 mfa_present: wire.mfa_present,
267 secure_transport: wire.secure_transport,
268 })
269 }
270}
271
272impl WamiContext {
273 pub fn builder() -> WamiContextBuilder {
275 WamiContextBuilder::default()
276 }
277
278 pub fn is_root(&self) -> bool {
282 self.is_root
283 }
284
285 pub fn caller_arn(&self) -> &WamiArn {
287 &self.caller_arn
288 }
289
290 pub fn provenance(&self) -> &[Step] {
297 &self.provenance
298 }
299
300 pub fn provenance_trail(&self) -> String {
323 let mut steps = self.provenance.iter();
324 let Some(first) = steps.next() else {
325 return String::new();
328 };
329
330 let mut trail = first.principal.to_string();
331 for step in steps {
332 trail.push(':');
333 trail.push_str(&step.segment());
334 }
335 trail
336 }
337
338 #[allow(clippy::result_large_err)]
351 pub fn through(&self, principal: WamiArn, via: Transition) -> Result<WamiContext> {
352 if self.provenance.len() >= MAX_PROVENANCE_DEPTH {
353 return Err(AmiError::InvalidParameter {
354 message: format!(
355 "authority has already passed hands {MAX_PROVENANCE_DEPTH} times in this context"
356 ),
357 });
358 }
359
360 let mut next = self.clone();
361 next.is_root = self.is_root && principal.is_root_user();
362 next.tenant_path = principal.tenant_path.clone();
363 next.instance_id = principal.wami_instance_id.clone();
364
365 if matches!(
375 via,
376 Transition::AssumedRole { .. } | Transition::Federated { .. }
377 ) {
378 next.mfa_present = None;
379 next.session_info = None;
380 }
381
382 next.provenance.push(Step {
386 principal: principal.clone(),
387 via,
388 });
389 next.caller_arn = principal;
390 Ok(next)
391 }
392
393 pub fn tenant_path(&self) -> &TenantPath {
395 &self.tenant_path
396 }
397
398 pub fn instance_id(&self) -> &str {
400 &self.instance_id
401 }
402
403 pub fn region(&self) -> Option<&str> {
405 self.region.as_deref()
406 }
407
408 pub fn session_info(&self) -> Option<&SessionInfo> {
410 self.session_info.as_ref()
411 }
412
413 pub fn source_ip(&self) -> Option<&str> {
415 self.source_ip.as_deref()
416 }
417
418 pub fn mfa_present(&self) -> Option<bool> {
420 self.mfa_present
421 }
422
423 pub fn secure_transport(&self) -> Option<bool> {
425 self.secure_transport
426 }
427
428 pub fn can_access_tenant(&self, target_tenant: &TenantPath) -> bool {
435 if self.is_root {
437 return true;
438 }
439
440 target_tenant.starts_with(self.tenant_path())
442 }
443
444 pub fn is_expired(&self) -> bool {
446 if let Some(session) = &self.session_info {
447 let now = chrono::Utc::now().timestamp();
448 return now >= session.expiration;
449 }
450 false
451 }
452}
453
454#[derive(Default)]
456pub struct WamiContextBuilder {
457 tenant_path: Option<TenantPath>,
458 instance_id: Option<String>,
459 caller_arn: Option<WamiArn>,
460 is_root: Option<bool>,
462 region: Option<String>,
463 session_info: Option<SessionInfo>,
464 source_ip: Option<String>,
465 mfa_present: Option<bool>,
466 secure_transport: Option<bool>,
467}
468
469impl WamiContextBuilder {
470 pub fn tenant_path(mut self, tenant_path: TenantPath) -> Self {
472 self.tenant_path = Some(tenant_path);
473 self
474 }
475
476 pub fn instance_id(mut self, instance_id: impl Into<String>) -> Self {
478 self.instance_id = Some(instance_id.into());
479 self
480 }
481
482 pub fn caller_arn(mut self, caller_arn: WamiArn) -> Self {
484 self.caller_arn = Some(caller_arn);
485 self
486 }
487
488 pub fn is_root(mut self, is_root: bool) -> Self {
494 self.is_root = Some(is_root);
495 self
496 }
497
498 pub fn region(mut self, region: impl Into<String>) -> Self {
500 self.region = Some(region.into());
501 self
502 }
503
504 pub fn session_info(mut self, session_info: SessionInfo) -> Self {
506 self.session_info = Some(session_info);
507 self
508 }
509
510 pub fn source_ip(mut self, ip: impl Into<String>) -> Self {
512 self.source_ip = Some(ip.into());
513 self
514 }
515
516 pub fn mfa_present(mut self, present: bool) -> Self {
518 self.mfa_present = Some(present);
519 self
520 }
521
522 pub fn secure_transport(mut self, secure: bool) -> Self {
524 self.secure_transport = Some(secure);
525 self
526 }
527
528 #[allow(clippy::result_large_err)]
530 pub fn build(self) -> Result<WamiContext> {
531 let caller_arn = self.caller_arn.ok_or_else(|| AmiError::InvalidParameter {
536 message: "caller_arn is required".to_string(),
537 })?;
538
539 let tenant_path = self
540 .tenant_path
541 .unwrap_or_else(|| caller_arn.tenant_path.clone());
542
543 let instance_id = self
544 .instance_id
545 .unwrap_or_else(|| caller_arn.wami_instance_id.clone());
546
547 if instance_id.trim().is_empty() {
549 return Err(AmiError::InvalidParameter {
550 message: "instance_id cannot be empty".to_string(),
551 });
552 }
553
554 Ok(WamiContext {
555 tenant_path,
556 instance_id,
557 is_root: self.is_root.unwrap_or_else(|| caller_arn.is_root_user()),
558 provenance: vec![Step {
562 principal: caller_arn.clone(),
563 via: Transition::Authenticated,
564 }],
565 caller_arn,
566 region: self.region,
567 session_info: self.session_info,
568 source_ip: self.source_ip,
569 mfa_present: self.mfa_present,
570 secure_transport: self.secure_transport,
571 })
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn test_context_builder() {
581 let arn: WamiArn = "arn:wami:iam:12345678/87654321:wami:999888777:user/12345"
582 .parse()
583 .unwrap();
584
585 let context = WamiContext::builder()
586 .instance_id("999888777")
587 .tenant_path(TenantPath::new(vec![12345678, 87654321]))
588 .caller_arn(arn.clone())
589 .is_root(false)
590 .region("us-east-1")
591 .build()
592 .unwrap();
593
594 assert_eq!(context.instance_id(), "999888777");
595 assert_eq!(context.tenant_path().to_string(), "12345678/87654321");
596 assert_eq!(context.caller_arn(), &arn);
597 assert!(!context.is_root());
598 assert_eq!(context.region(), Some("us-east-1"));
599 }
600
601 #[test]
602 fn test_root_context() {
603 let arn: WamiArn = "arn:wami:iam:0:wami:999888777:user/root".parse().unwrap();
604
605 let context = WamiContext::builder()
606 .instance_id("999888777")
607 .tenant_path(TenantPath::single(0))
608 .caller_arn(arn)
609 .is_root(true)
610 .build()
611 .unwrap();
612
613 assert!(context.is_root());
614 assert_eq!(context.tenant_path().to_string(), "0");
615 }
616
617 #[test]
618 fn test_can_access_tenant() {
619 let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
620 .parse()
621 .unwrap();
622
623 let context = WamiContext::builder()
624 .instance_id("999888777")
625 .tenant_path(TenantPath::single(12345678))
626 .caller_arn(arn)
627 .is_root(false)
628 .build()
629 .unwrap();
630
631 assert!(context.can_access_tenant(&TenantPath::single(12345678)));
633
634 assert!(context.can_access_tenant(&TenantPath::new(vec![12345678, 87654321])));
636
637 assert!(!context.can_access_tenant(&TenantPath::single(99999999)));
639
640 assert!(!context.can_access_tenant(&TenantPath::single(0)));
642 }
643
644 #[test]
645 fn test_root_can_access_any_tenant() {
646 let arn: WamiArn = "arn:wami:iam:0:wami:999888777:user/root".parse().unwrap();
647
648 let context = WamiContext::builder()
649 .instance_id("999888777")
650 .tenant_path(TenantPath::single(0))
651 .caller_arn(arn)
652 .is_root(true)
653 .build()
654 .unwrap();
655
656 assert!(context.can_access_tenant(&TenantPath::single(0)));
658 assert!(context.can_access_tenant(&TenantPath::single(12345678)));
659 assert!(context.can_access_tenant(&TenantPath::new(vec![12345678, 87654321, 99999999])));
660 }
661
662 #[test]
663 fn test_session_expiration() {
664 let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
665 .parse()
666 .unwrap();
667
668 let future_time = chrono::Utc::now().timestamp() + 3600; let session = SessionInfo {
670 session_token: "token123".to_string(),
671 expiration: future_time,
672 assumed_role_arn: None,
673 };
674
675 let context = WamiContext::builder()
676 .instance_id("999888777")
677 .tenant_path(TenantPath::single(12345678))
678 .caller_arn(arn)
679 .session_info(session)
680 .build()
681 .unwrap();
682
683 assert!(!context.is_expired());
684 }
685
686 #[test]
687 fn test_expired_session() {
688 let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
689 .parse()
690 .unwrap();
691
692 let past_time = chrono::Utc::now().timestamp() - 3600; let session = SessionInfo {
694 session_token: "token123".to_string(),
695 expiration: past_time,
696 assumed_role_arn: None,
697 };
698
699 let context = WamiContext::builder()
700 .instance_id("999888777")
701 .tenant_path(TenantPath::single(12345678))
702 .caller_arn(arn)
703 .session_info(session)
704 .build()
705 .unwrap();
706
707 assert!(context.is_expired());
708 }
709
710 #[test]
711 fn test_context_builder_all_fields() {
712 let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
713 .parse()
714 .unwrap();
715 let future_time = chrono::Utc::now().timestamp() + 3600;
716 let session = SessionInfo {
717 session_token: "token123".to_string(),
718 expiration: future_time,
719 assumed_role_arn: None,
720 };
721
722 let context = WamiContext::builder()
723 .instance_id("999888777")
724 .tenant_path(TenantPath::single(12345678))
725 .caller_arn(arn.clone())
726 .is_root(false)
727 .region("us-west-2")
728 .session_info(session.clone())
729 .build()
730 .unwrap();
731
732 assert_eq!(context.instance_id(), "999888777");
733 assert_eq!(context.caller_arn(), &arn);
734 assert_eq!(context.region(), Some("us-west-2"));
735 assert_eq!(
736 context.session_info().map(|s| s.session_token.as_str()),
737 Some("token123")
738 );
739 }
740
741 #[test]
742 fn test_context_without_optional_fields() {
743 let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
744 .parse()
745 .unwrap();
746
747 let context = WamiContext::builder()
748 .instance_id("999888777")
749 .tenant_path(TenantPath::single(12345678))
750 .caller_arn(arn)
751 .is_root(false)
752 .build()
753 .unwrap();
754
755 assert_eq!(context.region(), None);
756 assert!(context.session_info().is_none());
757 }
758
759 #[test]
760 fn test_caller_arn_is_the_only_required_field() {
761 let result = WamiContext::builder()
763 .tenant_path(TenantPath::single(0))
764 .build();
765 assert!(result.is_err());
766
767 let result = WamiContext::builder().instance_id("999888777").build();
768 assert!(result.is_err());
769 }
770
771 fn arn_for(tenant: u64, user_id: &str) -> WamiArn {
773 WamiArn::builder()
774 .service(crate::arn::Service::Iam)
775 .tenant_path(TenantPath::single(tenant))
776 .wami_instance("999888777")
777 .resource("user", user_id)
778 .build()
779 .unwrap()
780 }
781
782 #[test]
783 fn test_scope_is_derived_from_caller_arn() {
784 let context = WamiContext::builder()
785 .caller_arn(arn_for(12345678, "alice"))
786 .build()
787 .unwrap();
788
789 assert_eq!(context.tenant_path(), &TenantPath::single(12345678));
790 assert_eq!(context.instance_id(), "999888777");
791 }
792
793 #[test]
794 fn test_explicit_scope_still_wins() {
795 let context = WamiContext::builder()
797 .caller_arn(arn_for(12345678, "alice"))
798 .tenant_path(TenantPath::single(87654321))
799 .instance_id("111222333")
800 .build()
801 .unwrap();
802
803 assert_eq!(context.tenant_path(), &TenantPath::single(87654321));
804 assert_eq!(context.instance_id(), "111222333");
805 }
806
807 #[test]
808 fn test_root_is_derived_from_root_arn() {
809 let context = WamiContext::builder()
810 .caller_arn(arn_for(0, "root"))
811 .build()
812 .unwrap();
813
814 assert!(context.is_root());
815 }
816
817 #[test]
818 fn test_a_user_named_root_in_another_tenant_is_not_root() {
819 let context = WamiContext::builder()
823 .caller_arn(arn_for(12345678, "root"))
824 .build()
825 .unwrap();
826
827 assert!(!context.is_root());
828 }
829
830 #[test]
831 fn test_ordinary_user_in_root_tenant_is_not_root() {
832 let context = WamiContext::builder()
833 .caller_arn(arn_for(0, "alice"))
834 .build()
835 .unwrap();
836
837 assert!(!context.is_root());
838 }
839
840 #[test]
841 fn a_built_context_starts_its_chain_at_authentication() {
842 let context = WamiContext::builder()
845 .caller_arn(arn_for(12345678, "alice"))
846 .build()
847 .unwrap();
848
849 assert_eq!(context.provenance().len(), 1);
850 assert_eq!(context.provenance()[0].via(), &Transition::Authenticated);
851 assert_eq!(context.provenance()[0].principal(), context.caller_arn());
852 }
853
854 #[test]
855 fn through_moves_the_caller_and_records_the_move_together() {
856 let alice = WamiContext::builder()
857 .caller_arn(arn_for(12345678, "alice"))
858 .build()
859 .unwrap();
860
861 let role = arn_for(12345678, "DataScientist");
862 let assumed = alice
863 .through(
864 role.clone(),
865 Transition::AssumedRole {
866 session_name: "session1".to_string(),
867 },
868 )
869 .unwrap();
870
871 assert_eq!(assumed.caller_arn(), &role);
872 assert_eq!(assumed.provenance().len(), 2);
873 assert_eq!(
875 assumed.provenance().last().unwrap().principal(),
876 assumed.caller_arn()
877 );
878 assert_eq!(assumed.provenance()[0].principal().resource_id(), "alice");
880 }
881
882 #[test]
883 fn the_arn_itself_never_changes_shape() {
884 let alice_arn = arn_for(12345678, "alice");
887 let alice = WamiContext::builder()
888 .caller_arn(alice_arn.clone())
889 .build()
890 .unwrap();
891
892 let assumed = alice
893 .through(
894 arn_for(12345678, "DataScientist"),
895 Transition::AssumedRole {
896 session_name: "s".to_string(),
897 },
898 )
899 .unwrap();
900
901 assert_eq!(assumed.provenance()[0].principal(), &alice_arn);
902 assert!(!assumed.caller_arn().to_string().contains("assumed-role"));
903 assert!(!assumed.caller_arn().to_string().contains(":iam:policy"));
904 }
905
906 #[test]
907 fn root_is_never_regained_by_assuming_something() {
908 let alice = WamiContext::builder()
911 .caller_arn(arn_for(12345678, "alice"))
912 .build()
913 .unwrap();
914 assert!(!alice.is_root());
915
916 let escalated = alice
917 .through(arn_for(0, "root"), Transition::Authenticated)
918 .unwrap();
919 assert!(!escalated.is_root(), "assuming root granted root");
920 }
921
922 #[test]
923 fn root_is_dropped_when_authority_moves_elsewhere() {
924 let root = WamiContext::builder()
925 .caller_arn(arn_for(0, "root"))
926 .build()
927 .unwrap();
928 assert!(root.is_root());
929
930 let as_role = root
931 .through(
932 arn_for(12345678, "DataScientist"),
933 Transition::AssumedRole {
934 session_name: "s".to_string(),
935 },
936 )
937 .unwrap();
938 assert!(!as_role.is_root());
939 }
940
941 #[test]
942 fn scope_follows_the_new_principal() {
943 let alice = WamiContext::builder()
945 .caller_arn(arn_for(12345678, "alice"))
946 .build()
947 .unwrap();
948
949 let elsewhere = alice
950 .through(arn_for(87654321, "bob"), Transition::Authenticated)
951 .unwrap();
952
953 assert_eq!(elsewhere.tenant_path(), &TenantPath::single(87654321));
954 }
955
956 #[test]
957 fn the_chain_refuses_to_grow_past_its_bound() {
958 let mut context = WamiContext::builder()
961 .caller_arn(arn_for(12345678, "alice"))
962 .build()
963 .unwrap();
964
965 for i in 1..MAX_PROVENANCE_DEPTH {
967 context = context
968 .through(
969 arn_for(12345678, &format!("role{i}")),
970 Transition::Authenticated,
971 )
972 .unwrap();
973 }
974 assert_eq!(context.provenance().len(), MAX_PROVENANCE_DEPTH);
975
976 let refused = context.through(arn_for(12345678, "one-too-many"), Transition::Authenticated);
977 assert!(refused.is_err());
978 assert_eq!(context.provenance().len(), MAX_PROVENANCE_DEPTH);
979 }
980
981 #[test]
982 fn a_context_without_provenance_is_refused_on_the_wire() {
983 let json = r#"{
987 "tenant_path": [12345678],
988 "instance_id": "999888777",
989 "caller_arn": "arn:wami:iam:12345678:wami:999888777:user/alice",
990 "provenance": [],
991 "is_root": false,
992 "region": null,
993 "session_info": null
994 }"#;
995
996 assert!(serde_json::from_str::<WamiContext>(json).is_err());
997 }
998
999 #[test]
1000 fn a_chain_ending_on_someone_else_is_refused() {
1001 let alice = WamiContext::builder()
1004 .caller_arn(arn_for(12345678, "alice"))
1005 .build()
1006 .unwrap();
1007
1008 let mut tampered: serde_json::Value =
1009 serde_json::from_str(&serde_json::to_string(&alice).unwrap()).unwrap();
1010 tampered["caller_arn"] =
1011 serde_json::json!("arn:wami:iam:12345678:wami:999888777:user/mallory");
1012
1013 let err = serde_json::from_value::<WamiContext>(tampered).unwrap_err();
1014 assert!(err.to_string().contains("provenance ends on"), "{err}");
1015 }
1016
1017 #[test]
1018 fn an_overlong_chain_is_refused_on_the_wire() {
1019 let mut context = WamiContext::builder()
1021 .caller_arn(arn_for(12345678, "alice"))
1022 .build()
1023 .unwrap();
1024 for i in 1..MAX_PROVENANCE_DEPTH {
1025 context = context
1026 .through(
1027 arn_for(12345678, &format!("role{i}")),
1028 Transition::Authenticated,
1029 )
1030 .unwrap();
1031 }
1032
1033 let mut value: serde_json::Value =
1034 serde_json::from_str(&serde_json::to_string(&context).unwrap()).unwrap();
1035 let extra = value["provenance"][0].clone();
1036 value["provenance"].as_array_mut().unwrap().push(extra);
1037
1038 assert!(serde_json::from_value::<WamiContext>(value).is_err());
1039 }
1040
1041 #[test]
1042 fn assuming_a_role_drops_the_mfa_of_whoever_authenticated() {
1043 let alice = WamiContext::builder()
1047 .caller_arn(arn_for(12345678, "alice"))
1048 .mfa_present(true)
1049 .session_info(SessionInfo {
1050 session_token: "tok".to_string(),
1051 expiration: 9_999_999_999,
1052 assumed_role_arn: None,
1053 })
1054 .build()
1055 .unwrap();
1056 assert_eq!(alice.mfa_present(), Some(true));
1057
1058 let assumed = alice
1059 .through(
1060 arn_for(12345678, "DataScientist"),
1061 Transition::AssumedRole {
1062 session_name: "s".to_string(),
1063 },
1064 )
1065 .unwrap();
1066
1067 assert_eq!(assumed.mfa_present(), None);
1068 assert!(assumed.session_info().is_none());
1069 }
1070
1071 fn matches_like(trail: &str, pattern: &str) -> bool {
1074 let mut rest = trail;
1075 for (i, part) in pattern.split('%').enumerate() {
1076 if part.is_empty() {
1077 continue;
1078 }
1079 match (i, rest.find(part)) {
1080 (_, None) => return false,
1081 (0, Some(0)) | (1.., Some(_)) => {
1082 rest = &rest[rest.find(part).unwrap() + part.len()..]
1083 }
1084 (0, Some(_)) => return false,
1085 }
1086 }
1087 pattern.ends_with('%') || rest.is_empty()
1088 }
1089
1090 #[test]
1091 fn a_trail_answers_the_queries_the_issue_asks_for() {
1092 let trail = WamiContext::builder()
1093 .caller_arn(arn_for(12345678, "alice"))
1094 .build()
1095 .unwrap()
1096 .through(
1097 arn_for(12345678, "DataScientist"),
1098 Transition::AssumedRole {
1099 session_name: "session-abc123".to_string(),
1100 },
1101 )
1102 .unwrap()
1103 .through(
1104 arn_for(12345678, "DataScientist"),
1105 Transition::PermissionSet {
1106 name: "DeveloperAccess".to_string(),
1107 },
1108 )
1109 .unwrap()
1110 .provenance_trail();
1111
1112 assert!(matches_like(&trail, "%:sts:assumed-role/%"));
1114 assert!(matches_like(&trail, "%:sso:%"));
1115 assert!(matches_like(&trail, "%:sts:%:sso:%"));
1116
1117 assert!(!matches_like(&trail, "%:iam:policy/ReadOnly%"));
1119
1120 assert!(trail.starts_with("arn:wami:"));
1122 assert!(trail.contains("user/alice"));
1123 assert!(trail.contains("assumed-role/DataScientist/session-abc123"));
1124 }
1125
1126 #[test]
1127 fn a_trail_is_not_the_caller_arn() {
1128 let alice = WamiContext::builder()
1131 .caller_arn(arn_for(12345678, "alice"))
1132 .build()
1133 .unwrap();
1134 let assumed = alice
1135 .through(
1136 arn_for(12345678, "role"),
1137 Transition::AssumedRole {
1138 session_name: "s".to_string(),
1139 },
1140 )
1141 .unwrap();
1142
1143 assert_ne!(assumed.provenance_trail(), assumed.caller_arn().to_string());
1144 assert_eq!(
1145 assumed.caller_arn().to_string(),
1146 arn_for(12345678, "role").to_string()
1147 );
1148 }
1149
1150 #[test]
1151 fn a_value_cannot_forge_a_segment_boundary() {
1152 let trail = WamiContext::builder()
1155 .caller_arn(arn_for(12345678, "alice"))
1156 .build()
1157 .unwrap()
1158 .through(
1159 arn_for(12345678, "bob"),
1160 Transition::Federated {
1161 issuer: "https://idp.example/:sso:permission-set/Admin".to_string(),
1162 },
1163 )
1164 .unwrap()
1165 .provenance_trail();
1166
1167 assert!(matches_like(&trail, "%:sts:federated/%"));
1168 assert!(
1169 !matches_like(&trail, "%:sso:permission-set/%"),
1170 "an issuer forged an SSO segment: {trail}"
1171 );
1172 }
1173
1174 #[test]
1175 fn each_transition_names_its_service() {
1176 let context = WamiContext::builder()
1177 .caller_arn(arn_for(12345678, "alice"))
1178 .build()
1179 .unwrap();
1180 assert_eq!(context.provenance()[0].service(), "iam");
1181
1182 let assumed = context
1183 .through(
1184 arn_for(12345678, "r"),
1185 Transition::AssumedRole {
1186 session_name: "s".to_string(),
1187 },
1188 )
1189 .unwrap();
1190 assert_eq!(assumed.provenance()[1].service(), "sts");
1191
1192 let sso = assumed
1193 .through(
1194 arn_for(12345678, "r"),
1195 Transition::PermissionSet {
1196 name: "n".to_string(),
1197 },
1198 )
1199 .unwrap();
1200 assert_eq!(sso.provenance()[2].service(), "sso");
1201
1202 let federated = context
1203 .through(
1204 arn_for(12345678, "b"),
1205 Transition::Federated {
1206 issuer: "i".to_string(),
1207 },
1208 )
1209 .unwrap();
1210 assert_eq!(federated.provenance()[1].service(), "sts");
1211 }
1212
1213 #[test]
1214 fn federation_drops_them_too() {
1215 let alice = WamiContext::builder()
1219 .caller_arn(arn_for(12345678, "alice"))
1220 .mfa_present(true)
1221 .session_info(SessionInfo {
1222 session_token: "tok".to_string(),
1223 expiration: 9_999_999_999,
1224 assumed_role_arn: None,
1225 })
1226 .build()
1227 .unwrap();
1228
1229 let federated = alice
1230 .through(
1231 arn_for(12345678, "external-bob"),
1232 Transition::Federated {
1233 issuer: "https://idp.example".to_string(),
1234 },
1235 )
1236 .unwrap();
1237
1238 assert_eq!(federated.mfa_present(), None);
1239 assert!(federated.session_info().is_none());
1240 assert_eq!(
1241 federated.provenance().last().unwrap().via(),
1242 &Transition::Federated {
1243 issuer: "https://idp.example".to_string()
1244 }
1245 );
1246 }
1247
1248 #[test]
1249 fn every_field_survives_a_round_trip() {
1250 let context = WamiContext::builder()
1254 .caller_arn(arn_for(12345678, "alice"))
1255 .region("eu-west-3")
1256 .session_info(SessionInfo {
1257 session_token: "tok".to_string(),
1258 expiration: 9_999_999_999,
1259 assumed_role_arn: Some(arn_for(12345678, "role")),
1260 })
1261 .source_ip("203.0.113.7")
1262 .mfa_present(true)
1263 .secure_transport(true)
1264 .build()
1265 .unwrap();
1266
1267 let back: WamiContext =
1268 serde_json::from_str(&serde_json::to_string(&context).unwrap()).unwrap();
1269
1270 assert_eq!(back.caller_arn(), context.caller_arn());
1271 assert_eq!(back.tenant_path(), context.tenant_path());
1272 assert_eq!(back.instance_id(), context.instance_id());
1273 assert_eq!(back.is_root(), context.is_root());
1274 assert_eq!(back.region(), Some("eu-west-3"));
1275 assert_eq!(back.source_ip(), Some("203.0.113.7"));
1276 assert_eq!(back.mfa_present(), Some(true));
1277 assert_eq!(back.secure_transport(), Some(true));
1278 assert_eq!(back.provenance(), context.provenance());
1279 assert_eq!(
1280 back.session_info().map(|s| s.session_token.as_str()),
1281 Some("tok")
1282 );
1283 }
1284
1285 #[test]
1286 fn a_permission_set_is_not_a_change_of_identity() {
1287 let alice = WamiContext::builder()
1289 .caller_arn(arn_for(12345678, "alice"))
1290 .mfa_present(true)
1291 .build()
1292 .unwrap();
1293
1294 let scoped = alice
1295 .through(
1296 arn_for(12345678, "alice"),
1297 Transition::PermissionSet {
1298 name: "DeveloperAccess".to_string(),
1299 },
1300 )
1301 .unwrap();
1302
1303 assert_eq!(scoped.mfa_present(), Some(true));
1304 }
1305
1306 #[test]
1307 fn request_attributes_survive_a_transition() {
1308 let alice = WamiContext::builder()
1311 .caller_arn(arn_for(12345678, "alice"))
1312 .source_ip("203.0.113.7")
1313 .secure_transport(true)
1314 .build()
1315 .unwrap();
1316
1317 let assumed = alice
1318 .through(
1319 arn_for(12345678, "role"),
1320 Transition::AssumedRole {
1321 session_name: "s".to_string(),
1322 },
1323 )
1324 .unwrap();
1325
1326 assert_eq!(assumed.source_ip(), Some("203.0.113.7"));
1327 assert_eq!(assumed.secure_transport(), Some(true));
1328 }
1329
1330 #[test]
1331 fn deriving_a_context_leaves_the_original_alone() {
1332 let alice = WamiContext::builder()
1333 .caller_arn(arn_for(12345678, "alice"))
1334 .build()
1335 .unwrap();
1336
1337 let _ = alice
1338 .through(arn_for(12345678, "role"), Transition::Authenticated)
1339 .unwrap();
1340
1341 assert_eq!(alice.provenance().len(), 1);
1342 assert_eq!(alice.caller_arn().resource_id(), "alice");
1343 }
1344
1345 #[test]
1346 fn transitions_survive_serialisation() {
1347 let context = WamiContext::builder()
1349 .caller_arn(arn_for(12345678, "alice"))
1350 .build()
1351 .unwrap()
1352 .through(
1353 arn_for(12345678, "DataScientist"),
1354 Transition::AssumedRole {
1355 session_name: "session1".to_string(),
1356 },
1357 )
1358 .unwrap();
1359
1360 let json = serde_json::to_string(&context).unwrap();
1361 let back: WamiContext = serde_json::from_str(&json).unwrap();
1362
1363 assert_eq!(back.provenance(), context.provenance());
1364 assert_eq!(back.caller_arn(), context.caller_arn());
1365 }
1366
1367 #[test]
1368 fn test_explicit_is_root_false_beats_a_root_arn() {
1369 let context = WamiContext::builder()
1371 .caller_arn(arn_for(0, "root"))
1372 .is_root(false)
1373 .build()
1374 .unwrap();
1375
1376 assert!(!context.is_root());
1377 }
1378}