1use std::collections::{BTreeMap, HashMap};
14use std::fmt;
15use std::net::IpAddr;
16use std::str::FromStr;
17use std::sync::Arc;
18
19use chrono::{DateTime, Utc};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28pub enum PrincipalType {
29 User,
31 AssumedRole,
34 FederatedUser,
36 Root,
40 Unknown,
44}
45
46impl PrincipalType {
47 pub fn as_str(self) -> &'static str {
48 match self {
49 PrincipalType::User => "user",
50 PrincipalType::AssumedRole => "assumed-role",
51 PrincipalType::FederatedUser => "federated-user",
52 PrincipalType::Root => "root",
53 PrincipalType::Unknown => "unknown",
54 }
55 }
56
57 pub fn from_arn(arn: &str) -> Self {
64 if arn.ends_with(":root") {
65 PrincipalType::Root
66 } else if arn.contains(":user/") {
67 PrincipalType::User
68 } else if arn.contains(":assumed-role/") {
69 PrincipalType::AssumedRole
70 } else if arn.contains(":federated-user/") {
71 PrincipalType::FederatedUser
72 } else {
73 PrincipalType::Unknown
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Principal {
87 pub arn: String,
88 pub user_id: String,
89 pub account_id: String,
90 pub principal_type: PrincipalType,
91 pub source_identity: Option<String>,
95 pub tags: Option<HashMap<String, String>>,
99}
100
101impl Principal {
102 pub fn is_root(&self) -> bool {
105 matches!(self.principal_type, PrincipalType::Root) || self.arn.ends_with(":root")
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ResolvedCredential {
116 pub secret_access_key: String,
117 pub session_token: Option<String>,
118 pub principal: Principal,
119 pub session_policies: Vec<String>,
122 pub mfa_present: bool,
126 pub token_issued_at: Option<DateTime<Utc>>,
132 pub federated_provider: Option<String>,
136}
137
138impl ResolvedCredential {
139 pub fn principal_arn(&self) -> &str {
143 &self.principal.arn
144 }
145
146 pub fn user_id(&self) -> &str {
147 &self.principal.user_id
148 }
149
150 pub fn account_id(&self) -> &str {
151 &self.principal.account_id
152 }
153}
154
155pub trait CredentialResolver: Send + Sync {
163 fn resolve(&self, access_key_id: &str) -> Option<ResolvedCredential>;
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct IamAction {
179 pub service: &'static str,
181 pub action: &'static str,
183 pub resource: String,
185}
186
187impl IamAction {
188 pub fn action_string(&self) -> String {
191 format!("{}:{}", self.service, self.action)
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum IamDecision {
201 Allow,
202 ImplicitDeny,
203 ExplicitDeny,
204}
205
206impl IamDecision {
207 pub fn is_allow(self) -> bool {
208 matches!(self, IamDecision::Allow)
209 }
210}
211
212#[derive(Debug, Clone, Default)]
229pub struct ConditionContext {
230 pub aws_username: Option<String>,
233 pub aws_userid: Option<String>,
235 pub aws_principal_arn: Option<String>,
237 pub aws_principal_account: Option<String>,
240 pub aws_principal_type: Option<String>,
242 pub aws_source_ip: Option<IpAddr>,
244 pub aws_current_time: Option<DateTime<Utc>>,
246 pub aws_epoch_time: Option<i64>,
249 pub aws_secure_transport: Option<bool>,
251 pub aws_requested_region: Option<String>,
253 pub aws_mfa_present: Option<bool>,
258 pub aws_mfa_age_seconds: Option<i64>,
261 pub aws_called_via: Vec<String>,
265 pub aws_source_vpce: Option<String>,
268 pub aws_source_vpc: Option<String>,
271 pub aws_vpc_source_ip: Option<IpAddr>,
274 pub aws_federated_provider: Option<String>,
278 pub aws_token_issue_time: Option<DateTime<Utc>>,
281 pub service_keys: BTreeMap<String, Vec<String>>,
283 pub resource_tags: Option<HashMap<String, String>>,
287 pub request_tags: Option<HashMap<String, String>>,
291 pub principal_tags: Option<HashMap<String, String>>,
294}
295
296impl ConditionContext {
297 pub fn lookup(&self, key: &str) -> Option<Vec<String>> {
302 let lower = key.to_ascii_lowercase();
303 let one = |s: &str| Some(vec![s.to_string()]);
304
305 if lower.starts_with("aws:resourcetag/") {
312 let tag_key = &key[16..]; return self
314 .resource_tags
315 .as_ref()
316 .and_then(|tags| tags.get(tag_key))
317 .map(|v| vec![v.clone()]);
318 }
319 if lower.starts_with("aws:requesttag/") {
320 let tag_key = &key[15..];
321 return self
322 .request_tags
323 .as_ref()
324 .and_then(|tags| tags.get(tag_key))
325 .map(|v| vec![v.clone()]);
326 }
327 if lower.starts_with("aws:principaltag/") {
328 let tag_key = &key[17..];
329 return self
330 .principal_tags
331 .as_ref()
332 .and_then(|tags| tags.get(tag_key))
333 .map(|v| vec![v.clone()]);
334 }
335 if lower == "aws:tagkeys" {
336 return self
337 .request_tags
338 .as_ref()
339 .map(|tags| tags.keys().cloned().collect());
340 }
341
342 match lower.as_str() {
343 "aws:username" => self.aws_username.as_deref().and_then(one),
344 "aws:userid" => self.aws_userid.as_deref().and_then(one),
345 "aws:principalarn" => self.aws_principal_arn.as_deref().and_then(one),
346 "aws:principalaccount" => self.aws_principal_account.as_deref().and_then(one),
347 "aws:principaltype" => self.aws_principal_type.as_deref().and_then(one),
348 "aws:sourceip" => self.aws_source_ip.map(|ip| vec![ip.to_string()]),
349 "aws:currenttime" => self
350 .aws_current_time
351 .map(|t| vec![t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)]),
352 "aws:epochtime" => self.aws_epoch_time.map(|e| vec![e.to_string()]),
353 "aws:securetransport" => self.aws_secure_transport.map(|b| vec![b.to_string()]),
354 "aws:requestedregion" => self.aws_requested_region.as_deref().and_then(one),
355 "aws:multifactorauthpresent" => self.aws_mfa_present.map(|b| vec![b.to_string()]),
356 "aws:multifactorauthage" => self.aws_mfa_age_seconds.map(|s| vec![s.to_string()]),
357 "aws:calledvia" => {
358 if self.aws_called_via.is_empty() {
359 None
360 } else {
361 Some(self.aws_called_via.clone())
362 }
363 }
364 "aws:sourcevpce" => self.aws_source_vpce.as_deref().and_then(one),
365 "aws:sourcevpc" => self.aws_source_vpc.as_deref().and_then(one),
366 "aws:vpcsourceip" => self.aws_vpc_source_ip.map(|ip| vec![ip.to_string()]),
367 "aws:federatedprovider" => self.aws_federated_provider.as_deref().and_then(one),
368 "aws:tokenissuetime" => self
369 .aws_token_issue_time
370 .map(|t| vec![t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)]),
371 _ => {
372 if let Some(vs) = self.service_keys.get(&lower) {
373 if vs.is_empty() {
374 None
375 } else {
376 Some(vs.clone())
377 }
378 } else {
379 self.service_keys
380 .iter()
381 .find(|(k, _)| k.eq_ignore_ascii_case(key))
382 .map(|(_, vs)| vs.clone())
383 }
384 }
385 }
386 }
387}
388
389pub trait IamPolicyEvaluator: Send + Sync {
394 fn evaluate(
404 &self,
405 principal: &Principal,
406 action: &IamAction,
407 context: &ConditionContext,
408 session_policies: &[String],
409 scps: Option<&[String]>,
410 ) -> IamDecision;
411
412 #[allow(clippy::too_many_arguments)]
415 fn evaluate_with_resource_policy(
416 &self,
417 principal: &Principal,
418 action: &IamAction,
419 context: &ConditionContext,
420 resource_policy_json: Option<&str>,
421 resource_account_id: &str,
422 session_policies: &[String],
423 scps: Option<&[String]>,
424 ) -> IamDecision;
425}
426
427pub trait ScpResolver: Send + Sync {
442 fn scps_for(&self, principal: &Principal) -> Option<Vec<String>>;
443}
444
445pub trait ResourcePolicyProvider: Send + Sync {
466 fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String>;
472
473 fn resource_owner_account(&self, _service: &str, _resource_arn: &str) -> Option<String> {
482 None
483 }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
493pub enum PassRoleError {
494 RoleNotFound(String),
496 TrustPolicyDenies {
500 role_arn: String,
501 service_principal: String,
502 },
503 InvalidTrustPolicy(String),
505}
506
507impl std::fmt::Display for PassRoleError {
508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509 match self {
510 Self::RoleNotFound(arn) => write!(f, "role not found: {arn}"),
511 Self::TrustPolicyDenies {
512 role_arn,
513 service_principal,
514 } => write!(
515 f,
516 "Role's trust policy does not allow {service_principal} to assume the role: {role_arn}"
517 ),
518 Self::InvalidTrustPolicy(arn) => {
519 write!(f, "invalid trust policy on role {arn}")
520 }
521 }
522 }
523}
524
525impl std::error::Error for PassRoleError {}
526
527pub trait RoleTrustValidator: Send + Sync {
534 fn validate(
535 &self,
536 account_id: &str,
537 role_arn: &str,
538 service_principal: &str,
539 ) -> Result<(), PassRoleError>;
540}
541
542pub struct MultiResourcePolicyProvider {
557 providers: Vec<Arc<dyn ResourcePolicyProvider>>,
558}
559
560impl MultiResourcePolicyProvider {
561 pub fn new(providers: Vec<Arc<dyn ResourcePolicyProvider>>) -> Self {
563 Self { providers }
564 }
565
566 pub fn shared(
570 providers: Vec<Arc<dyn ResourcePolicyProvider>>,
571 ) -> Arc<dyn ResourcePolicyProvider> {
572 Arc::new(Self::new(providers))
573 }
574
575 pub fn len(&self) -> usize {
577 self.providers.len()
578 }
579
580 pub fn is_empty(&self) -> bool {
582 self.providers.is_empty()
583 }
584}
585
586impl ResourcePolicyProvider for MultiResourcePolicyProvider {
587 fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String> {
588 self.providers
589 .iter()
590 .find_map(|p| p.resource_policy(service, resource_arn))
591 }
592
593 fn resource_owner_account(&self, service: &str, resource_arn: &str) -> Option<String> {
594 self.providers
595 .iter()
596 .find_map(|p| p.resource_owner_account(service, resource_arn))
597 }
598}
599
600#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
608pub enum IamMode {
609 #[default]
611 Off,
612 Soft,
615 Strict,
617}
618
619impl IamMode {
620 pub fn is_enabled(self) -> bool {
622 !matches!(self, IamMode::Off)
623 }
624
625 pub fn is_strict(self) -> bool {
627 matches!(self, IamMode::Strict)
628 }
629
630 pub fn as_str(self) -> &'static str {
631 match self {
632 IamMode::Off => "off",
633 IamMode::Soft => "soft",
634 IamMode::Strict => "strict",
635 }
636 }
637}
638
639impl fmt::Display for IamMode {
640 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
641 f.write_str(self.as_str())
642 }
643}
644
645#[derive(Debug)]
647pub struct ParseIamModeError(String);
648
649impl fmt::Display for ParseIamModeError {
650 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
651 write!(
652 f,
653 "invalid IAM mode `{}`; expected one of: off, soft, strict",
654 self.0
655 )
656 }
657}
658
659impl std::error::Error for ParseIamModeError {}
660
661impl FromStr for IamMode {
662 type Err = ParseIamModeError;
663
664 fn from_str(s: &str) -> Result<Self, Self::Err> {
665 match s.trim().to_ascii_lowercase().as_str() {
666 "off" | "none" | "disabled" => Ok(IamMode::Off),
667 "soft" | "audit" | "warn" => Ok(IamMode::Soft),
668 "strict" | "enforce" | "deny" => Ok(IamMode::Strict),
669 other => Err(ParseIamModeError(other.to_string())),
670 }
671 }
672}
673
674pub fn is_root_bypass(access_key_id: &str) -> bool {
686 access_key_id
687 .trim()
688 .get(..4)
689 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("test"))
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn iam_mode_default_is_off() {
698 assert_eq!(IamMode::default(), IamMode::Off);
699 assert!(!IamMode::default().is_enabled());
700 }
701
702 #[test]
703 fn iam_mode_from_str_accepts_primary_values() {
704 assert_eq!(IamMode::from_str("off").unwrap(), IamMode::Off);
705 assert_eq!(IamMode::from_str("soft").unwrap(), IamMode::Soft);
706 assert_eq!(IamMode::from_str("strict").unwrap(), IamMode::Strict);
707 }
708
709 #[test]
710 fn iam_mode_from_str_is_case_insensitive_and_trimmed() {
711 assert_eq!(IamMode::from_str(" OFF ").unwrap(), IamMode::Off);
712 assert_eq!(IamMode::from_str("Soft").unwrap(), IamMode::Soft);
713 assert_eq!(IamMode::from_str("STRICT").unwrap(), IamMode::Strict);
714 }
715
716 #[test]
717 fn iam_mode_from_str_accepts_aliases() {
718 assert_eq!(IamMode::from_str("disabled").unwrap(), IamMode::Off);
719 assert_eq!(IamMode::from_str("audit").unwrap(), IamMode::Soft);
720 assert_eq!(IamMode::from_str("enforce").unwrap(), IamMode::Strict);
721 }
722
723 #[test]
724 fn iam_mode_from_str_rejects_garbage() {
725 assert!(IamMode::from_str("").is_err());
726 assert!(IamMode::from_str("allow").is_err());
727 assert!(IamMode::from_str("yes").is_err());
728 }
729
730 #[test]
731 fn iam_mode_display_roundtrips() {
732 for mode in [IamMode::Off, IamMode::Soft, IamMode::Strict] {
733 assert_eq!(IamMode::from_str(&mode.to_string()).unwrap(), mode);
734 }
735 }
736
737 #[test]
738 fn iam_mode_flags() {
739 assert!(!IamMode::Off.is_enabled());
740 assert!(!IamMode::Off.is_strict());
741 assert!(IamMode::Soft.is_enabled());
742 assert!(!IamMode::Soft.is_strict());
743 assert!(IamMode::Strict.is_enabled());
744 assert!(IamMode::Strict.is_strict());
745 }
746
747 #[test]
748 fn root_bypass_matches_test_prefix() {
749 assert!(is_root_bypass("test"));
750 assert!(is_root_bypass("TEST"));
751 assert!(is_root_bypass("Test"));
752 assert!(is_root_bypass("testAccessKey"));
753 assert!(is_root_bypass("TESTAKIAIOSFODNN7EXAMPLE"));
754 }
755
756 #[test]
757 fn root_bypass_does_not_panic_on_multibyte_input() {
758 assert!(!is_root_bypass("té"));
760 assert!(!is_root_bypass("日本語キー"));
761 assert!(!is_root_bypass("🔑🔑"));
762 }
763
764 #[test]
765 fn principal_type_from_arn_classifies_known_shapes() {
766 assert_eq!(
767 PrincipalType::from_arn("arn:aws:iam::123456789012:user/alice"),
768 PrincipalType::User
769 );
770 assert_eq!(
771 PrincipalType::from_arn("arn:aws:sts::123456789012:assumed-role/R/s"),
772 PrincipalType::AssumedRole
773 );
774 assert_eq!(
775 PrincipalType::from_arn("arn:aws:sts::123456789012:federated-user/bob"),
776 PrincipalType::FederatedUser
777 );
778 assert_eq!(
779 PrincipalType::from_arn("arn:aws:iam::123456789012:root"),
780 PrincipalType::Root
781 );
782 }
783
784 #[test]
785 fn principal_type_unparseable_is_unknown_not_root() {
786 assert_eq!(
791 PrincipalType::from_arn("not-an-arn"),
792 PrincipalType::Unknown
793 );
794 assert_eq!(PrincipalType::from_arn(""), PrincipalType::Unknown);
795 assert_eq!(
796 PrincipalType::from_arn("arn:aws:iam::123456789012:something-weird"),
797 PrincipalType::Unknown
798 );
799
800 let p = Principal {
803 arn: "garbage".to_string(),
804 user_id: "x".to_string(),
805 account_id: "123456789012".to_string(),
806 principal_type: PrincipalType::Unknown,
807 source_identity: None,
808 tags: None,
809 };
810 assert!(!p.is_root());
811 }
812
813 #[test]
814 fn principal_is_root_covers_root_type_and_arn_suffix() {
815 let p = Principal {
816 arn: "arn:aws:iam::123456789012:root".to_string(),
817 user_id: "AIDAROOT".to_string(),
818 account_id: "123456789012".to_string(),
819 principal_type: PrincipalType::Root,
820 source_identity: None,
821 tags: None,
822 };
823 assert!(p.is_root());
824
825 let user = Principal {
826 arn: "arn:aws:iam::123456789012:user/alice".to_string(),
827 user_id: "AIDAALICE".to_string(),
828 account_id: "123456789012".to_string(),
829 principal_type: PrincipalType::User,
830 source_identity: None,
831 tags: None,
832 };
833 assert!(!user.is_root());
834 }
835
836 #[test]
837 fn resolved_credential_accessors_forward_to_principal() {
838 let rc = ResolvedCredential {
839 secret_access_key: "s".into(),
840 session_token: None,
841 principal: Principal {
842 arn: "arn:aws:iam::123456789012:user/alice".into(),
843 user_id: "AIDAALICE".into(),
844 account_id: "123456789012".into(),
845 principal_type: PrincipalType::User,
846 source_identity: None,
847 tags: None,
848 },
849 session_policies: Vec::new(),
850 mfa_present: false,
851 token_issued_at: None,
852 federated_provider: None,
853 };
854 assert_eq!(rc.principal_arn(), "arn:aws:iam::123456789012:user/alice");
855 assert_eq!(rc.user_id(), "AIDAALICE");
856 assert_eq!(rc.account_id(), "123456789012");
857 }
858
859 #[test]
860 fn root_bypass_rejects_non_test_keys() {
861 assert!(!is_root_bypass(""));
862 assert!(!is_root_bypass(" "));
863 assert!(!is_root_bypass("AKIAIOSFODNN7EXAMPLE"));
864 assert!(!is_root_bypass("FKIA123456"));
865 assert!(!is_root_bypass("tes"));
866 assert!(!is_root_bypass("tst"));
867 }
868
869 struct FakeProvider {
874 service: &'static str,
875 arn: &'static str,
876 policy: &'static str,
877 }
878
879 impl ResourcePolicyProvider for FakeProvider {
880 fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String> {
881 if service.eq_ignore_ascii_case(self.service) && resource_arn == self.arn {
882 Some(self.policy.to_string())
883 } else {
884 None
885 }
886 }
887 }
888
889 fn fake(
890 service: &'static str,
891 arn: &'static str,
892 policy: &'static str,
893 ) -> Arc<dyn ResourcePolicyProvider> {
894 Arc::new(FakeProvider {
895 service,
896 arn,
897 policy,
898 })
899 }
900
901 #[test]
902 fn multi_provider_empty_always_returns_none() {
903 let m = MultiResourcePolicyProvider::new(vec![]);
904 assert!(m.is_empty());
905 assert_eq!(m.len(), 0);
906 assert_eq!(m.resource_policy("s3", "arn:aws:s3:::x"), None);
907 }
908
909 #[test]
910 fn multi_provider_delegates_to_single_child() {
911 let m = MultiResourcePolicyProvider::new(vec![fake("s3", "arn:aws:s3:::b", r#"{"v":1}"#)]);
912 assert_eq!(m.len(), 1);
913 assert_eq!(
914 m.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
915 Some(r#"{"v":1}"#)
916 );
917 assert_eq!(m.resource_policy("s3", "arn:aws:s3:::missing"), None);
918 assert_eq!(m.resource_policy("sns", "arn:aws:s3:::b"), None);
919 }
920
921 #[test]
922 fn multi_provider_hits_first_matching_child() {
923 let m = MultiResourcePolicyProvider::new(vec![
924 fake("s3", "arn:aws:s3:::b", r#"{"v":"s3"}"#),
925 fake("sns", "arn:aws:sns:us-east-1:123:t", r#"{"v":"sns"}"#),
926 ]);
927 assert_eq!(
928 m.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
929 Some(r#"{"v":"s3"}"#)
930 );
931 assert_eq!(
932 m.resource_policy("sns", "arn:aws:sns:us-east-1:123:t")
933 .as_deref(),
934 Some(r#"{"v":"sns"}"#)
935 );
936 }
937
938 #[test]
939 fn multi_provider_is_order_independent_when_services_differ() {
940 let children: Vec<Arc<dyn ResourcePolicyProvider>> = vec![
943 fake("s3", "arn:aws:s3:::b", "s3-doc"),
944 fake("sns", "arn:aws:sns:us-east-1:123:t", "sns-doc"),
945 fake(
946 "lambda",
947 "arn:aws:lambda:us-east-1:123:function:f",
948 "lam-doc",
949 ),
950 ];
951 let forward = MultiResourcePolicyProvider::new(children.clone());
952 let reversed = MultiResourcePolicyProvider::new({
953 let mut v = children.clone();
954 v.reverse();
955 v
956 });
957 for (svc, arn) in [
958 ("s3", "arn:aws:s3:::b"),
959 ("sns", "arn:aws:sns:us-east-1:123:t"),
960 ("lambda", "arn:aws:lambda:us-east-1:123:function:f"),
961 ] {
962 assert_eq!(
963 forward.resource_policy(svc, arn),
964 reversed.resource_policy(svc, arn),
965 "service {svc}"
966 );
967 }
968 }
969
970 #[test]
971 fn multi_provider_returns_none_for_unhandled_service() {
972 let m = MultiResourcePolicyProvider::new(vec![fake("s3", "arn:aws:s3:::b", "doc")]);
973 assert_eq!(
974 m.resource_policy("kms", "arn:aws:kms:us-east-1:123:key/k"),
975 None
976 );
977 assert_eq!(m.resource_policy("iam", "arn:aws:iam::123:role/r"), None);
978 }
979
980 #[test]
981 fn multi_provider_shared_wraps_in_arc() {
982 let arc = MultiResourcePolicyProvider::shared(vec![fake("s3", "arn:aws:s3:::b", "doc")]);
983 assert_eq!(
984 arc.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
985 Some("doc")
986 );
987 }
988
989 #[test]
992 fn lookup_mfa_present_emits_bool_string() {
993 let ctx = ConditionContext {
994 aws_mfa_present: Some(true),
995 ..Default::default()
996 };
997 assert_eq!(
998 ctx.lookup("aws:MultiFactorAuthPresent"),
999 Some(vec!["true".to_string()])
1000 );
1001 let ctx = ConditionContext {
1002 aws_mfa_present: Some(false),
1003 ..Default::default()
1004 };
1005 assert_eq!(
1006 ctx.lookup("aws:multifactorauthpresent"),
1007 Some(vec!["false".to_string()])
1008 );
1009 }
1010
1011 #[test]
1012 fn lookup_mfa_age_emits_seconds() {
1013 let ctx = ConditionContext {
1014 aws_mfa_age_seconds: Some(900),
1015 ..Default::default()
1016 };
1017 assert_eq!(
1018 ctx.lookup("aws:MultiFactorAuthAge"),
1019 Some(vec!["900".to_string()])
1020 );
1021 }
1022
1023 #[test]
1024 fn lookup_called_via_returns_full_chain() {
1025 let ctx = ConditionContext {
1026 aws_called_via: vec![
1027 "cloudformation.amazonaws.com".to_string(),
1028 "lambda.amazonaws.com".to_string(),
1029 ],
1030 ..Default::default()
1031 };
1032 assert_eq!(
1033 ctx.lookup("aws:CalledVia"),
1034 Some(vec![
1035 "cloudformation.amazonaws.com".to_string(),
1036 "lambda.amazonaws.com".to_string(),
1037 ])
1038 );
1039 }
1040
1041 #[test]
1042 fn lookup_called_via_empty_returns_none() {
1043 let ctx = ConditionContext::default();
1044 assert_eq!(ctx.lookup("aws:CalledVia"), None);
1045 }
1046
1047 #[test]
1048 fn lookup_source_vpc_keys() {
1049 let ctx = ConditionContext {
1050 aws_source_vpc: Some("vpc-123".to_string()),
1051 aws_source_vpce: Some("vpce-456".to_string()),
1052 aws_vpc_source_ip: Some("10.0.1.5".parse::<IpAddr>().unwrap()),
1053 ..Default::default()
1054 };
1055 assert_eq!(
1056 ctx.lookup("aws:SourceVpc"),
1057 Some(vec!["vpc-123".to_string()])
1058 );
1059 assert_eq!(
1060 ctx.lookup("aws:SourceVpce"),
1061 Some(vec!["vpce-456".to_string()])
1062 );
1063 assert_eq!(
1064 ctx.lookup("aws:VpcSourceIp"),
1065 Some(vec!["10.0.1.5".to_string()])
1066 );
1067 }
1068
1069 #[test]
1070 fn lookup_federated_provider_and_token_issue_time() {
1071 use chrono::TimeZone;
1072 let ctx = ConditionContext {
1073 aws_federated_provider: Some("cognito-identity.amazonaws.com".to_string()),
1074 aws_token_issue_time: Some(
1075 chrono::Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0).unwrap(),
1076 ),
1077 ..Default::default()
1078 };
1079 assert_eq!(
1080 ctx.lookup("aws:FederatedProvider"),
1081 Some(vec!["cognito-identity.amazonaws.com".to_string()])
1082 );
1083 assert_eq!(
1084 ctx.lookup("aws:TokenIssueTime"),
1085 Some(vec!["2026-04-30T12:00:00Z".to_string()])
1086 );
1087 }
1088
1089 fn abac_context() -> ConditionContext {
1090 ConditionContext {
1091 resource_tags: Some(
1092 [("Environment", "prod"), ("CostCenter", "42")]
1093 .iter()
1094 .map(|(k, v)| (k.to_string(), v.to_string()))
1095 .collect(),
1096 ),
1097 request_tags: Some(
1098 [("Project", "web"), ("Team", "platform")]
1099 .iter()
1100 .map(|(k, v)| (k.to_string(), v.to_string()))
1101 .collect(),
1102 ),
1103 principal_tags: Some(
1104 [("Department", "eng"), ("Role", "developer")]
1105 .iter()
1106 .map(|(k, v)| (k.to_string(), v.to_string()))
1107 .collect(),
1108 ),
1109 ..Default::default()
1110 }
1111 }
1112
1113 #[test]
1114 fn lookup_resource_tag_case_sensitive_key() {
1115 let ctx = abac_context();
1116 assert_eq!(
1117 ctx.lookup("aws:ResourceTag/Environment"),
1118 Some(vec!["prod".to_string()])
1119 );
1120 assert_eq!(ctx.lookup("aws:ResourceTag/environment"), None);
1122 }
1123
1124 #[test]
1125 fn lookup_resource_tag_prefix_case_insensitive() {
1126 let ctx = abac_context();
1127 assert_eq!(
1129 ctx.lookup("AWS:resourcetag/Environment"),
1130 Some(vec!["prod".to_string()])
1131 );
1132 assert_eq!(
1133 ctx.lookup("Aws:RESOURCETAG/CostCenter"),
1134 Some(vec!["42".to_string()])
1135 );
1136 }
1137
1138 #[test]
1139 fn lookup_request_tag() {
1140 let ctx = abac_context();
1141 assert_eq!(
1142 ctx.lookup("aws:RequestTag/Project"),
1143 Some(vec!["web".to_string()])
1144 );
1145 assert_eq!(ctx.lookup("aws:RequestTag/project"), None);
1146 }
1147
1148 #[test]
1149 fn lookup_principal_tag() {
1150 let ctx = abac_context();
1151 assert_eq!(
1152 ctx.lookup("aws:PrincipalTag/Department"),
1153 Some(vec!["eng".to_string()])
1154 );
1155 assert_eq!(ctx.lookup("aws:PrincipalTag/department"), None);
1156 }
1157
1158 #[test]
1159 fn lookup_tag_keys_returns_all_request_tag_keys() {
1160 let ctx = abac_context();
1161 let mut keys = ctx.lookup("aws:TagKeys").unwrap();
1162 keys.sort();
1163 assert_eq!(keys, vec!["Project", "Team"]);
1164 }
1165
1166 #[test]
1167 fn lookup_tag_keys_case_insensitive() {
1168 let ctx = abac_context();
1169 assert!(ctx.lookup("AWS:TAGKEYS").is_some());
1170 assert!(ctx.lookup("aws:tagkeys").is_some());
1171 }
1172
1173 #[test]
1174 fn lookup_tag_none_when_field_not_set() {
1175 let ctx = ConditionContext::default();
1176 assert_eq!(ctx.lookup("aws:ResourceTag/Foo"), None);
1177 assert_eq!(ctx.lookup("aws:RequestTag/Foo"), None);
1178 assert_eq!(ctx.lookup("aws:PrincipalTag/Foo"), None);
1179 assert_eq!(ctx.lookup("aws:TagKeys"), None);
1180 }
1181
1182 #[test]
1183 fn lookup_tag_missing_key_returns_none() {
1184 let ctx = abac_context();
1185 assert_eq!(ctx.lookup("aws:ResourceTag/NonExistent"), None);
1186 assert_eq!(ctx.lookup("aws:RequestTag/NonExistent"), None);
1187 assert_eq!(ctx.lookup("aws:PrincipalTag/NonExistent"), None);
1188 }
1189}