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
474#[derive(Debug, Clone, PartialEq, Eq)]
481pub enum PassRoleError {
482 RoleNotFound(String),
484 TrustPolicyDenies {
488 role_arn: String,
489 service_principal: String,
490 },
491 InvalidTrustPolicy(String),
493}
494
495impl std::fmt::Display for PassRoleError {
496 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
497 match self {
498 Self::RoleNotFound(arn) => write!(f, "role not found: {arn}"),
499 Self::TrustPolicyDenies {
500 role_arn,
501 service_principal,
502 } => write!(
503 f,
504 "Role's trust policy does not allow {service_principal} to assume the role: {role_arn}"
505 ),
506 Self::InvalidTrustPolicy(arn) => {
507 write!(f, "invalid trust policy on role {arn}")
508 }
509 }
510 }
511}
512
513impl std::error::Error for PassRoleError {}
514
515pub trait RoleTrustValidator: Send + Sync {
522 fn validate(
523 &self,
524 account_id: &str,
525 role_arn: &str,
526 service_principal: &str,
527 ) -> Result<(), PassRoleError>;
528}
529
530pub struct MultiResourcePolicyProvider {
545 providers: Vec<Arc<dyn ResourcePolicyProvider>>,
546}
547
548impl MultiResourcePolicyProvider {
549 pub fn new(providers: Vec<Arc<dyn ResourcePolicyProvider>>) -> Self {
551 Self { providers }
552 }
553
554 pub fn shared(
558 providers: Vec<Arc<dyn ResourcePolicyProvider>>,
559 ) -> Arc<dyn ResourcePolicyProvider> {
560 Arc::new(Self::new(providers))
561 }
562
563 pub fn len(&self) -> usize {
565 self.providers.len()
566 }
567
568 pub fn is_empty(&self) -> bool {
570 self.providers.is_empty()
571 }
572}
573
574impl ResourcePolicyProvider for MultiResourcePolicyProvider {
575 fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String> {
576 self.providers
577 .iter()
578 .find_map(|p| p.resource_policy(service, resource_arn))
579 }
580}
581
582#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
590pub enum IamMode {
591 #[default]
593 Off,
594 Soft,
597 Strict,
599}
600
601impl IamMode {
602 pub fn is_enabled(self) -> bool {
604 !matches!(self, IamMode::Off)
605 }
606
607 pub fn is_strict(self) -> bool {
609 matches!(self, IamMode::Strict)
610 }
611
612 pub fn as_str(self) -> &'static str {
613 match self {
614 IamMode::Off => "off",
615 IamMode::Soft => "soft",
616 IamMode::Strict => "strict",
617 }
618 }
619}
620
621impl fmt::Display for IamMode {
622 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
623 f.write_str(self.as_str())
624 }
625}
626
627#[derive(Debug)]
629pub struct ParseIamModeError(String);
630
631impl fmt::Display for ParseIamModeError {
632 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
633 write!(
634 f,
635 "invalid IAM mode `{}`; expected one of: off, soft, strict",
636 self.0
637 )
638 }
639}
640
641impl std::error::Error for ParseIamModeError {}
642
643impl FromStr for IamMode {
644 type Err = ParseIamModeError;
645
646 fn from_str(s: &str) -> Result<Self, Self::Err> {
647 match s.trim().to_ascii_lowercase().as_str() {
648 "off" | "none" | "disabled" => Ok(IamMode::Off),
649 "soft" | "audit" | "warn" => Ok(IamMode::Soft),
650 "strict" | "enforce" | "deny" => Ok(IamMode::Strict),
651 other => Err(ParseIamModeError(other.to_string())),
652 }
653 }
654}
655
656pub fn is_root_bypass(access_key_id: &str) -> bool {
668 access_key_id
669 .trim()
670 .get(..4)
671 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("test"))
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn iam_mode_default_is_off() {
680 assert_eq!(IamMode::default(), IamMode::Off);
681 assert!(!IamMode::default().is_enabled());
682 }
683
684 #[test]
685 fn iam_mode_from_str_accepts_primary_values() {
686 assert_eq!(IamMode::from_str("off").unwrap(), IamMode::Off);
687 assert_eq!(IamMode::from_str("soft").unwrap(), IamMode::Soft);
688 assert_eq!(IamMode::from_str("strict").unwrap(), IamMode::Strict);
689 }
690
691 #[test]
692 fn iam_mode_from_str_is_case_insensitive_and_trimmed() {
693 assert_eq!(IamMode::from_str(" OFF ").unwrap(), IamMode::Off);
694 assert_eq!(IamMode::from_str("Soft").unwrap(), IamMode::Soft);
695 assert_eq!(IamMode::from_str("STRICT").unwrap(), IamMode::Strict);
696 }
697
698 #[test]
699 fn iam_mode_from_str_accepts_aliases() {
700 assert_eq!(IamMode::from_str("disabled").unwrap(), IamMode::Off);
701 assert_eq!(IamMode::from_str("audit").unwrap(), IamMode::Soft);
702 assert_eq!(IamMode::from_str("enforce").unwrap(), IamMode::Strict);
703 }
704
705 #[test]
706 fn iam_mode_from_str_rejects_garbage() {
707 assert!(IamMode::from_str("").is_err());
708 assert!(IamMode::from_str("allow").is_err());
709 assert!(IamMode::from_str("yes").is_err());
710 }
711
712 #[test]
713 fn iam_mode_display_roundtrips() {
714 for mode in [IamMode::Off, IamMode::Soft, IamMode::Strict] {
715 assert_eq!(IamMode::from_str(&mode.to_string()).unwrap(), mode);
716 }
717 }
718
719 #[test]
720 fn iam_mode_flags() {
721 assert!(!IamMode::Off.is_enabled());
722 assert!(!IamMode::Off.is_strict());
723 assert!(IamMode::Soft.is_enabled());
724 assert!(!IamMode::Soft.is_strict());
725 assert!(IamMode::Strict.is_enabled());
726 assert!(IamMode::Strict.is_strict());
727 }
728
729 #[test]
730 fn root_bypass_matches_test_prefix() {
731 assert!(is_root_bypass("test"));
732 assert!(is_root_bypass("TEST"));
733 assert!(is_root_bypass("Test"));
734 assert!(is_root_bypass("testAccessKey"));
735 assert!(is_root_bypass("TESTAKIAIOSFODNN7EXAMPLE"));
736 }
737
738 #[test]
739 fn root_bypass_does_not_panic_on_multibyte_input() {
740 assert!(!is_root_bypass("té"));
742 assert!(!is_root_bypass("日本語キー"));
743 assert!(!is_root_bypass("🔑🔑"));
744 }
745
746 #[test]
747 fn principal_type_from_arn_classifies_known_shapes() {
748 assert_eq!(
749 PrincipalType::from_arn("arn:aws:iam::123456789012:user/alice"),
750 PrincipalType::User
751 );
752 assert_eq!(
753 PrincipalType::from_arn("arn:aws:sts::123456789012:assumed-role/R/s"),
754 PrincipalType::AssumedRole
755 );
756 assert_eq!(
757 PrincipalType::from_arn("arn:aws:sts::123456789012:federated-user/bob"),
758 PrincipalType::FederatedUser
759 );
760 assert_eq!(
761 PrincipalType::from_arn("arn:aws:iam::123456789012:root"),
762 PrincipalType::Root
763 );
764 }
765
766 #[test]
767 fn principal_type_unparseable_is_unknown_not_root() {
768 assert_eq!(
773 PrincipalType::from_arn("not-an-arn"),
774 PrincipalType::Unknown
775 );
776 assert_eq!(PrincipalType::from_arn(""), PrincipalType::Unknown);
777 assert_eq!(
778 PrincipalType::from_arn("arn:aws:iam::123456789012:something-weird"),
779 PrincipalType::Unknown
780 );
781
782 let p = Principal {
785 arn: "garbage".to_string(),
786 user_id: "x".to_string(),
787 account_id: "123456789012".to_string(),
788 principal_type: PrincipalType::Unknown,
789 source_identity: None,
790 tags: None,
791 };
792 assert!(!p.is_root());
793 }
794
795 #[test]
796 fn principal_is_root_covers_root_type_and_arn_suffix() {
797 let p = Principal {
798 arn: "arn:aws:iam::123456789012:root".to_string(),
799 user_id: "AIDAROOT".to_string(),
800 account_id: "123456789012".to_string(),
801 principal_type: PrincipalType::Root,
802 source_identity: None,
803 tags: None,
804 };
805 assert!(p.is_root());
806
807 let user = Principal {
808 arn: "arn:aws:iam::123456789012:user/alice".to_string(),
809 user_id: "AIDAALICE".to_string(),
810 account_id: "123456789012".to_string(),
811 principal_type: PrincipalType::User,
812 source_identity: None,
813 tags: None,
814 };
815 assert!(!user.is_root());
816 }
817
818 #[test]
819 fn resolved_credential_accessors_forward_to_principal() {
820 let rc = ResolvedCredential {
821 secret_access_key: "s".into(),
822 session_token: None,
823 principal: Principal {
824 arn: "arn:aws:iam::123456789012:user/alice".into(),
825 user_id: "AIDAALICE".into(),
826 account_id: "123456789012".into(),
827 principal_type: PrincipalType::User,
828 source_identity: None,
829 tags: None,
830 },
831 session_policies: Vec::new(),
832 mfa_present: false,
833 token_issued_at: None,
834 federated_provider: None,
835 };
836 assert_eq!(rc.principal_arn(), "arn:aws:iam::123456789012:user/alice");
837 assert_eq!(rc.user_id(), "AIDAALICE");
838 assert_eq!(rc.account_id(), "123456789012");
839 }
840
841 #[test]
842 fn root_bypass_rejects_non_test_keys() {
843 assert!(!is_root_bypass(""));
844 assert!(!is_root_bypass(" "));
845 assert!(!is_root_bypass("AKIAIOSFODNN7EXAMPLE"));
846 assert!(!is_root_bypass("FKIA123456"));
847 assert!(!is_root_bypass("tes"));
848 assert!(!is_root_bypass("tst"));
849 }
850
851 struct FakeProvider {
856 service: &'static str,
857 arn: &'static str,
858 policy: &'static str,
859 }
860
861 impl ResourcePolicyProvider for FakeProvider {
862 fn resource_policy(&self, service: &str, resource_arn: &str) -> Option<String> {
863 if service.eq_ignore_ascii_case(self.service) && resource_arn == self.arn {
864 Some(self.policy.to_string())
865 } else {
866 None
867 }
868 }
869 }
870
871 fn fake(
872 service: &'static str,
873 arn: &'static str,
874 policy: &'static str,
875 ) -> Arc<dyn ResourcePolicyProvider> {
876 Arc::new(FakeProvider {
877 service,
878 arn,
879 policy,
880 })
881 }
882
883 #[test]
884 fn multi_provider_empty_always_returns_none() {
885 let m = MultiResourcePolicyProvider::new(vec![]);
886 assert!(m.is_empty());
887 assert_eq!(m.len(), 0);
888 assert_eq!(m.resource_policy("s3", "arn:aws:s3:::x"), None);
889 }
890
891 #[test]
892 fn multi_provider_delegates_to_single_child() {
893 let m = MultiResourcePolicyProvider::new(vec![fake("s3", "arn:aws:s3:::b", r#"{"v":1}"#)]);
894 assert_eq!(m.len(), 1);
895 assert_eq!(
896 m.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
897 Some(r#"{"v":1}"#)
898 );
899 assert_eq!(m.resource_policy("s3", "arn:aws:s3:::missing"), None);
900 assert_eq!(m.resource_policy("sns", "arn:aws:s3:::b"), None);
901 }
902
903 #[test]
904 fn multi_provider_hits_first_matching_child() {
905 let m = MultiResourcePolicyProvider::new(vec![
906 fake("s3", "arn:aws:s3:::b", r#"{"v":"s3"}"#),
907 fake("sns", "arn:aws:sns:us-east-1:123:t", r#"{"v":"sns"}"#),
908 ]);
909 assert_eq!(
910 m.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
911 Some(r#"{"v":"s3"}"#)
912 );
913 assert_eq!(
914 m.resource_policy("sns", "arn:aws:sns:us-east-1:123:t")
915 .as_deref(),
916 Some(r#"{"v":"sns"}"#)
917 );
918 }
919
920 #[test]
921 fn multi_provider_is_order_independent_when_services_differ() {
922 let children: Vec<Arc<dyn ResourcePolicyProvider>> = vec![
925 fake("s3", "arn:aws:s3:::b", "s3-doc"),
926 fake("sns", "arn:aws:sns:us-east-1:123:t", "sns-doc"),
927 fake(
928 "lambda",
929 "arn:aws:lambda:us-east-1:123:function:f",
930 "lam-doc",
931 ),
932 ];
933 let forward = MultiResourcePolicyProvider::new(children.clone());
934 let reversed = MultiResourcePolicyProvider::new({
935 let mut v = children.clone();
936 v.reverse();
937 v
938 });
939 for (svc, arn) in [
940 ("s3", "arn:aws:s3:::b"),
941 ("sns", "arn:aws:sns:us-east-1:123:t"),
942 ("lambda", "arn:aws:lambda:us-east-1:123:function:f"),
943 ] {
944 assert_eq!(
945 forward.resource_policy(svc, arn),
946 reversed.resource_policy(svc, arn),
947 "service {svc}"
948 );
949 }
950 }
951
952 #[test]
953 fn multi_provider_returns_none_for_unhandled_service() {
954 let m = MultiResourcePolicyProvider::new(vec![fake("s3", "arn:aws:s3:::b", "doc")]);
955 assert_eq!(
956 m.resource_policy("kms", "arn:aws:kms:us-east-1:123:key/k"),
957 None
958 );
959 assert_eq!(m.resource_policy("iam", "arn:aws:iam::123:role/r"), None);
960 }
961
962 #[test]
963 fn multi_provider_shared_wraps_in_arc() {
964 let arc = MultiResourcePolicyProvider::shared(vec![fake("s3", "arn:aws:s3:::b", "doc")]);
965 assert_eq!(
966 arc.resource_policy("s3", "arn:aws:s3:::b").as_deref(),
967 Some("doc")
968 );
969 }
970
971 #[test]
974 fn lookup_mfa_present_emits_bool_string() {
975 let ctx = ConditionContext {
976 aws_mfa_present: Some(true),
977 ..Default::default()
978 };
979 assert_eq!(
980 ctx.lookup("aws:MultiFactorAuthPresent"),
981 Some(vec!["true".to_string()])
982 );
983 let ctx = ConditionContext {
984 aws_mfa_present: Some(false),
985 ..Default::default()
986 };
987 assert_eq!(
988 ctx.lookup("aws:multifactorauthpresent"),
989 Some(vec!["false".to_string()])
990 );
991 }
992
993 #[test]
994 fn lookup_mfa_age_emits_seconds() {
995 let ctx = ConditionContext {
996 aws_mfa_age_seconds: Some(900),
997 ..Default::default()
998 };
999 assert_eq!(
1000 ctx.lookup("aws:MultiFactorAuthAge"),
1001 Some(vec!["900".to_string()])
1002 );
1003 }
1004
1005 #[test]
1006 fn lookup_called_via_returns_full_chain() {
1007 let ctx = ConditionContext {
1008 aws_called_via: vec![
1009 "cloudformation.amazonaws.com".to_string(),
1010 "lambda.amazonaws.com".to_string(),
1011 ],
1012 ..Default::default()
1013 };
1014 assert_eq!(
1015 ctx.lookup("aws:CalledVia"),
1016 Some(vec![
1017 "cloudformation.amazonaws.com".to_string(),
1018 "lambda.amazonaws.com".to_string(),
1019 ])
1020 );
1021 }
1022
1023 #[test]
1024 fn lookup_called_via_empty_returns_none() {
1025 let ctx = ConditionContext::default();
1026 assert_eq!(ctx.lookup("aws:CalledVia"), None);
1027 }
1028
1029 #[test]
1030 fn lookup_source_vpc_keys() {
1031 let ctx = ConditionContext {
1032 aws_source_vpc: Some("vpc-123".to_string()),
1033 aws_source_vpce: Some("vpce-456".to_string()),
1034 aws_vpc_source_ip: Some("10.0.1.5".parse::<IpAddr>().unwrap()),
1035 ..Default::default()
1036 };
1037 assert_eq!(
1038 ctx.lookup("aws:SourceVpc"),
1039 Some(vec!["vpc-123".to_string()])
1040 );
1041 assert_eq!(
1042 ctx.lookup("aws:SourceVpce"),
1043 Some(vec!["vpce-456".to_string()])
1044 );
1045 assert_eq!(
1046 ctx.lookup("aws:VpcSourceIp"),
1047 Some(vec!["10.0.1.5".to_string()])
1048 );
1049 }
1050
1051 #[test]
1052 fn lookup_federated_provider_and_token_issue_time() {
1053 use chrono::TimeZone;
1054 let ctx = ConditionContext {
1055 aws_federated_provider: Some("cognito-identity.amazonaws.com".to_string()),
1056 aws_token_issue_time: Some(
1057 chrono::Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0).unwrap(),
1058 ),
1059 ..Default::default()
1060 };
1061 assert_eq!(
1062 ctx.lookup("aws:FederatedProvider"),
1063 Some(vec!["cognito-identity.amazonaws.com".to_string()])
1064 );
1065 assert_eq!(
1066 ctx.lookup("aws:TokenIssueTime"),
1067 Some(vec!["2026-04-30T12:00:00Z".to_string()])
1068 );
1069 }
1070
1071 fn abac_context() -> ConditionContext {
1072 ConditionContext {
1073 resource_tags: Some(
1074 [("Environment", "prod"), ("CostCenter", "42")]
1075 .iter()
1076 .map(|(k, v)| (k.to_string(), v.to_string()))
1077 .collect(),
1078 ),
1079 request_tags: Some(
1080 [("Project", "web"), ("Team", "platform")]
1081 .iter()
1082 .map(|(k, v)| (k.to_string(), v.to_string()))
1083 .collect(),
1084 ),
1085 principal_tags: Some(
1086 [("Department", "eng"), ("Role", "developer")]
1087 .iter()
1088 .map(|(k, v)| (k.to_string(), v.to_string()))
1089 .collect(),
1090 ),
1091 ..Default::default()
1092 }
1093 }
1094
1095 #[test]
1096 fn lookup_resource_tag_case_sensitive_key() {
1097 let ctx = abac_context();
1098 assert_eq!(
1099 ctx.lookup("aws:ResourceTag/Environment"),
1100 Some(vec!["prod".to_string()])
1101 );
1102 assert_eq!(ctx.lookup("aws:ResourceTag/environment"), None);
1104 }
1105
1106 #[test]
1107 fn lookup_resource_tag_prefix_case_insensitive() {
1108 let ctx = abac_context();
1109 assert_eq!(
1111 ctx.lookup("AWS:resourcetag/Environment"),
1112 Some(vec!["prod".to_string()])
1113 );
1114 assert_eq!(
1115 ctx.lookup("Aws:RESOURCETAG/CostCenter"),
1116 Some(vec!["42".to_string()])
1117 );
1118 }
1119
1120 #[test]
1121 fn lookup_request_tag() {
1122 let ctx = abac_context();
1123 assert_eq!(
1124 ctx.lookup("aws:RequestTag/Project"),
1125 Some(vec!["web".to_string()])
1126 );
1127 assert_eq!(ctx.lookup("aws:RequestTag/project"), None);
1128 }
1129
1130 #[test]
1131 fn lookup_principal_tag() {
1132 let ctx = abac_context();
1133 assert_eq!(
1134 ctx.lookup("aws:PrincipalTag/Department"),
1135 Some(vec!["eng".to_string()])
1136 );
1137 assert_eq!(ctx.lookup("aws:PrincipalTag/department"), None);
1138 }
1139
1140 #[test]
1141 fn lookup_tag_keys_returns_all_request_tag_keys() {
1142 let ctx = abac_context();
1143 let mut keys = ctx.lookup("aws:TagKeys").unwrap();
1144 keys.sort();
1145 assert_eq!(keys, vec!["Project", "Team"]);
1146 }
1147
1148 #[test]
1149 fn lookup_tag_keys_case_insensitive() {
1150 let ctx = abac_context();
1151 assert!(ctx.lookup("AWS:TAGKEYS").is_some());
1152 assert!(ctx.lookup("aws:tagkeys").is_some());
1153 }
1154
1155 #[test]
1156 fn lookup_tag_none_when_field_not_set() {
1157 let ctx = ConditionContext::default();
1158 assert_eq!(ctx.lookup("aws:ResourceTag/Foo"), None);
1159 assert_eq!(ctx.lookup("aws:RequestTag/Foo"), None);
1160 assert_eq!(ctx.lookup("aws:PrincipalTag/Foo"), None);
1161 assert_eq!(ctx.lookup("aws:TagKeys"), None);
1162 }
1163
1164 #[test]
1165 fn lookup_tag_missing_key_returns_none() {
1166 let ctx = abac_context();
1167 assert_eq!(ctx.lookup("aws:ResourceTag/NonExistent"), None);
1168 assert_eq!(ctx.lookup("aws:RequestTag/NonExistent"), None);
1169 assert_eq!(ctx.lookup("aws:PrincipalTag/NonExistent"), None);
1170 }
1171}