1use std::collections::HashSet;
38
39use fakecloud_core::auth::{Principal, PrincipalType};
40use serde_json::Value;
41
42use crate::state::IamState;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Decision {
52 Allow,
53 ImplicitDeny,
54 ExplicitDeny,
55}
56
57impl Decision {
58 pub fn is_allow(self) -> bool {
60 matches!(self, Decision::Allow)
61 }
62}
63
64#[derive(Debug, Clone)]
75pub struct EvalRequest<'a> {
76 pub principal: &'a Principal,
77 pub action: String,
78 pub resource: String,
79 pub context: RequestContext,
80}
81
82#[derive(Debug, Clone, Default)]
86pub struct RequestContext {}
87
88#[derive(Debug, Clone)]
90pub(crate) struct ParsedStatement {
91 pub effect: Effect,
92 pub action: ActionMatch,
93 pub resource: ResourceMatch,
94 pub has_condition: bool,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub(crate) enum Effect {
102 Allow,
103 Deny,
104}
105
106#[derive(Debug, Clone)]
109pub(crate) enum ActionMatch {
110 Action(Vec<String>),
111 NotAction(Vec<String>),
112}
113
114#[derive(Debug, Clone)]
116pub(crate) enum ResourceMatch {
117 Resource(Vec<String>),
118 NotResource(Vec<String>),
119 Implicit,
127}
128
129#[derive(Debug, Clone, Default)]
135pub struct PolicyDocument {
136 pub(crate) statements: Vec<ParsedStatement>,
137}
138
139impl PolicyDocument {
140 pub fn parse(json: &str) -> Self {
144 let value: Value = match serde_json::from_str(json) {
145 Ok(v) => v,
146 Err(e) => {
147 tracing::warn!(error = %e, "failed to parse policy document JSON; ignoring");
148 return Self::default();
149 }
150 };
151 Self::from_value(&value)
152 }
153
154 pub fn from_value(value: &Value) -> Self {
158 let statements = match value.get("Statement") {
159 Some(Value::Array(arr)) => arr.iter().filter_map(parse_statement).collect::<Vec<_>>(),
160 Some(obj @ Value::Object(_)) => parse_statement(obj).into_iter().collect(),
161 _ => Vec::new(),
162 };
163 Self { statements }
164 }
165
166 pub fn statement_count(&self) -> usize {
170 self.statements.len()
171 }
172}
173
174fn parse_statement(value: &Value) -> Option<ParsedStatement> {
175 let obj = value.as_object()?;
176 let effect = match obj.get("Effect")?.as_str()? {
177 "Allow" => Effect::Allow,
178 "Deny" => Effect::Deny,
179 other => {
180 tracing::warn!(effect = other, "unknown Effect; ignoring statement");
181 return None;
182 }
183 };
184 let action = if let Some(a) = obj.get("Action") {
185 ActionMatch::Action(coerce_string_list(a))
186 } else if let Some(na) = obj.get("NotAction") {
187 ActionMatch::NotAction(coerce_string_list(na))
188 } else {
189 tracing::warn!("statement has no Action or NotAction; ignoring");
190 return None;
191 };
192 let resource = if let Some(r) = obj.get("Resource") {
193 ResourceMatch::Resource(coerce_string_list(r))
194 } else if let Some(nr) = obj.get("NotResource") {
195 ResourceMatch::NotResource(coerce_string_list(nr))
196 } else {
197 ResourceMatch::Implicit
198 };
199 let has_condition = obj.contains_key("Condition");
200 Some(ParsedStatement {
201 effect,
202 action,
203 resource,
204 has_condition,
205 })
206}
207
208fn coerce_string_list(value: &Value) -> Vec<String> {
212 match value {
213 Value::String(s) => vec![s.clone()],
214 Value::Array(arr) => arr
215 .iter()
216 .filter_map(|v| v.as_str().map(|s| s.to_string()))
217 .collect(),
218 _ => Vec::new(),
219 }
220}
221
222pub fn evaluate(policies: &[PolicyDocument], request: &EvalRequest<'_>) -> Decision {
238 let mut allowed = false;
239 for policy in policies {
240 for statement in &policy.statements {
241 if statement.has_condition {
242 tracing::debug!(
243 target: "fakecloud::iam::audit",
244 action = %request.action,
245 "skipping statement with Condition (not yet evaluated in Phase 1)"
246 );
247 continue;
248 }
249 if !action_matches(&statement.action, &request.action) {
250 continue;
251 }
252 if !resource_matches(&statement.resource, &request.resource) {
253 continue;
254 }
255 match statement.effect {
256 Effect::Deny => return Decision::ExplicitDeny,
257 Effect::Allow => allowed = true,
258 }
259 }
260 }
261 if allowed {
262 Decision::Allow
263 } else {
264 Decision::ImplicitDeny
265 }
266}
267
268fn action_matches(action: &ActionMatch, request_action: &str) -> bool {
269 match action {
270 ActionMatch::Action(patterns) => patterns
271 .iter()
272 .any(|p| iam_glob_match(p, request_action, true)),
273 ActionMatch::NotAction(patterns) => patterns
274 .iter()
275 .all(|p| !iam_glob_match(p, request_action, true)),
276 }
277}
278
279fn resource_matches(resource: &ResourceMatch, request_resource: &str) -> bool {
280 match resource {
281 ResourceMatch::Resource(patterns) => patterns
282 .iter()
283 .any(|p| iam_glob_match(p, request_resource, false)),
284 ResourceMatch::NotResource(patterns) => patterns
285 .iter()
286 .all(|p| !iam_glob_match(p, request_resource, false)),
287 ResourceMatch::Implicit => true,
288 }
289}
290
291fn iam_glob_match(pattern: &str, value: &str, case_insensitive_service_prefix: bool) -> bool {
297 if case_insensitive_service_prefix {
298 if let (Some((p_svc, p_act)), Some((v_svc, v_act))) =
299 (pattern.split_once(':'), value.split_once(':'))
300 {
301 if !glob_match(&p_svc.to_ascii_lowercase(), &v_svc.to_ascii_lowercase()) {
302 return false;
303 }
304 return glob_match(p_act, v_act);
305 }
306 }
307 glob_match(pattern, value)
308}
309
310fn glob_match(pattern: &str, value: &str) -> bool {
314 let p: Vec<char> = pattern.chars().collect();
315 let v: Vec<char> = value.chars().collect();
316 let mut pi = 0usize;
317 let mut vi = 0usize;
318 let mut star: Option<usize> = None;
319 let mut star_v: usize = 0;
320 while vi < v.len() {
321 if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
322 pi += 1;
323 vi += 1;
324 } else if pi < p.len() && p[pi] == '*' {
325 star = Some(pi);
326 star_v = vi;
327 pi += 1;
328 } else if let Some(s) = star {
329 pi = s + 1;
330 star_v += 1;
331 vi = star_v;
332 } else {
333 return false;
334 }
335 }
336 while pi < p.len() && p[pi] == '*' {
337 pi += 1;
338 }
339 pi == p.len()
340}
341
342pub fn collect_identity_policies(state: &IamState, principal: &Principal) -> Vec<PolicyDocument> {
354 let mut docs = Vec::new();
355 let mut seen_managed: HashSet<String> = HashSet::new();
356 match principal.principal_type {
357 PrincipalType::User => {
358 if let Some(user_name) = user_name_from_arn(&principal.arn) {
359 collect_user_policies(state, user_name, &mut docs, &mut seen_managed);
360 }
361 }
362 PrincipalType::AssumedRole => {
363 if let Some(role_name) = role_name_from_assumed_role_arn(&principal.arn) {
364 collect_role_policies(state, role_name, &mut docs, &mut seen_managed);
365 }
366 }
367 PrincipalType::Root => {
368 }
373 PrincipalType::FederatedUser | PrincipalType::Unknown => {
374 }
376 }
377 docs
378}
379
380fn collect_user_policies(
381 state: &IamState,
382 user_name: &str,
383 docs: &mut Vec<PolicyDocument>,
384 seen_managed: &mut HashSet<String>,
385) {
386 if let Some(inline) = state.user_inline_policies.get(user_name) {
387 for doc in inline.values() {
388 docs.push(PolicyDocument::parse(doc));
389 }
390 }
391 if let Some(arns) = state.user_policies.get(user_name) {
392 for arn in arns {
393 if !seen_managed.insert(arn.clone()) {
394 continue;
395 }
396 if let Some(doc) = managed_policy_default_document(state, arn) {
397 docs.push(PolicyDocument::parse(&doc));
398 }
399 }
400 }
401 for (group_name, group) in &state.groups {
403 if !group.members.iter().any(|m| m == user_name) {
404 continue;
405 }
406 for doc in group.inline_policies.values() {
407 docs.push(PolicyDocument::parse(doc));
408 }
409 for arn in &group.attached_policies {
410 if !seen_managed.insert(arn.clone()) {
411 continue;
412 }
413 if let Some(doc) = managed_policy_default_document(state, arn) {
414 docs.push(PolicyDocument::parse(&doc));
415 }
416 }
417 let _ = group_name;
418 }
419}
420
421fn collect_role_policies(
422 state: &IamState,
423 role_name: &str,
424 docs: &mut Vec<PolicyDocument>,
425 seen_managed: &mut HashSet<String>,
426) {
427 if let Some(inline) = state.role_inline_policies.get(role_name) {
428 for doc in inline.values() {
429 docs.push(PolicyDocument::parse(doc));
430 }
431 }
432 if let Some(arns) = state.role_policies.get(role_name) {
433 for arn in arns {
434 if !seen_managed.insert(arn.clone()) {
435 continue;
436 }
437 if let Some(doc) = managed_policy_default_document(state, arn) {
438 docs.push(PolicyDocument::parse(&doc));
439 }
440 }
441 }
442}
443
444fn managed_policy_default_document(state: &IamState, arn: &str) -> Option<String> {
445 let policy = state.policies.get(arn)?;
446 policy
447 .versions
448 .iter()
449 .find(|v| v.is_default)
450 .or_else(|| policy.versions.first())
451 .map(|v| v.document.clone())
452}
453
454fn user_name_from_arn(arn: &str) -> Option<&str> {
465 let after = arn.rsplit_once(":user/").map(|(_, name)| name)?;
466 Some(after.rsplit('/').next().unwrap_or(after))
468}
469
470fn role_name_from_assumed_role_arn(arn: &str) -> Option<&str> {
471 let after = arn.rsplit_once(":assumed-role/")?.1;
473 Some(after.split('/').next().unwrap_or(after))
474}
475
476#[cfg(test)]
477#[allow(clippy::cloned_ref_to_slice_refs)]
478mod tests {
479 use super::*;
480 use serde_json::json;
481
482 fn principal_user(arn: &str) -> Principal {
483 Principal {
484 arn: arn.to_string(),
485 user_id: "AIDA".into(),
486 account_id: "123456789012".into(),
487 principal_type: PrincipalType::User,
488 source_identity: None,
489 }
490 }
491
492 fn req<'a>(principal: &'a Principal, action: &str, resource: &str) -> EvalRequest<'a> {
493 EvalRequest {
494 principal,
495 action: action.to_string(),
496 resource: resource.to_string(),
497 context: RequestContext::default(),
498 }
499 }
500
501 fn doc(json: serde_json::Value) -> PolicyDocument {
502 PolicyDocument::from_value(&json)
503 }
504
505 #[test]
508 fn glob_literal_match() {
509 assert!(glob_match("foo", "foo"));
510 assert!(!glob_match("foo", "bar"));
511 }
512
513 #[test]
514 fn glob_star_matches_any() {
515 assert!(glob_match("*", "foo"));
516 assert!(glob_match("*", ""));
517 assert!(glob_match("foo*", "foobar"));
518 assert!(glob_match("*bar", "foobar"));
519 assert!(glob_match("f*r", "foobar"));
520 assert!(!glob_match("foo*", "fo"));
521 }
522
523 #[test]
524 fn glob_question_mark_matches_one() {
525 assert!(glob_match("f?o", "foo"));
526 assert!(!glob_match("f?o", "fo"));
527 assert!(!glob_match("f?o", "foo!"));
528 }
529
530 #[test]
531 fn glob_no_backtracking_explosion() {
532 assert!(!glob_match("a*a*a*a*a*b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
534 }
535
536 #[test]
539 fn iam_action_service_prefix_is_case_insensitive() {
540 assert!(iam_glob_match("S3:GetObject", "s3:GetObject", true));
541 assert!(iam_glob_match("s3:GetObject", "S3:GetObject", true));
542 }
543
544 #[test]
545 fn iam_action_name_is_case_sensitive() {
546 assert!(!iam_glob_match("s3:getobject", "s3:GetObject", true));
548 assert!(iam_glob_match("s3:GetObject", "s3:GetObject", true));
549 }
550
551 #[test]
552 fn iam_action_supports_wildcards() {
553 assert!(iam_glob_match("s3:Get*", "s3:GetObject", true));
554 assert!(iam_glob_match("s3:*", "s3:DeleteObject", true));
555 assert!(iam_glob_match("*", "s3:GetObject", true));
556 assert!(!iam_glob_match("s3:Get*", "s3:PutObject", true));
557 }
558
559 #[test]
562 fn empty_policy_set_is_implicit_deny() {
563 let p = principal_user("arn:aws:iam::123456789012:user/alice");
564 assert_eq!(
565 evaluate(&[], &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")),
566 Decision::ImplicitDeny
567 );
568 }
569
570 #[test]
571 fn allow_with_matching_action_and_resource() {
572 let p = principal_user("arn:aws:iam::123456789012:user/alice");
573 let policy = doc(json!({
574 "Version": "2012-10-17",
575 "Statement": [{
576 "Effect": "Allow",
577 "Action": "s3:GetObject",
578 "Resource": "arn:aws:s3:::bucket/key"
579 }]
580 }));
581 assert_eq!(
582 evaluate(
583 &[policy],
584 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
585 ),
586 Decision::Allow
587 );
588 }
589
590 #[test]
591 fn deny_takes_precedence_over_allow() {
592 let p = principal_user("arn:aws:iam::123456789012:user/alice");
593 let allow = doc(json!({
594 "Statement": [{
595 "Effect": "Allow",
596 "Action": "*",
597 "Resource": "*"
598 }]
599 }));
600 let deny = doc(json!({
601 "Statement": [{
602 "Effect": "Deny",
603 "Action": "s3:DeleteObject",
604 "Resource": "*"
605 }]
606 }));
607 assert_eq!(
608 evaluate(
609 &[allow.clone(), deny.clone()],
610 &req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
611 ),
612 Decision::ExplicitDeny
613 );
614 assert_eq!(
616 evaluate(
617 &[deny, allow],
618 &req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
619 ),
620 Decision::ExplicitDeny
621 );
622 }
623
624 #[test]
625 fn allow_with_wrong_action_is_implicit_deny() {
626 let p = principal_user("arn:aws:iam::123456789012:user/alice");
627 let policy = doc(json!({
628 "Statement": [{
629 "Effect": "Allow",
630 "Action": "s3:GetObject",
631 "Resource": "*"
632 }]
633 }));
634 assert_eq!(
635 evaluate(
636 &[policy],
637 &req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
638 ),
639 Decision::ImplicitDeny
640 );
641 }
642
643 #[test]
644 fn allow_with_wrong_resource_is_implicit_deny() {
645 let p = principal_user("arn:aws:iam::123456789012:user/alice");
646 let policy = doc(json!({
647 "Statement": [{
648 "Effect": "Allow",
649 "Action": "s3:GetObject",
650 "Resource": "arn:aws:s3:::other-bucket/*"
651 }]
652 }));
653 assert_eq!(
654 evaluate(
655 &[policy],
656 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
657 ),
658 Decision::ImplicitDeny
659 );
660 }
661
662 #[test]
663 fn resource_wildcard_matches_arn_path() {
664 let p = principal_user("arn:aws:iam::123456789012:user/alice");
665 let policy = doc(json!({
666 "Statement": [{
667 "Effect": "Allow",
668 "Action": "s3:GetObject",
669 "Resource": "arn:aws:s3:::bucket/*"
670 }]
671 }));
672 assert_eq!(
673 evaluate(
674 &[policy],
675 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/path/to/key")
676 ),
677 Decision::Allow
678 );
679 }
680
681 #[test]
682 fn not_action_excludes_listed_actions() {
683 let p = principal_user("arn:aws:iam::123456789012:user/alice");
684 let policy = doc(json!({
685 "Statement": [{
686 "Effect": "Allow",
687 "NotAction": "s3:DeleteObject",
688 "Resource": "*"
689 }]
690 }));
691 assert_eq!(
693 evaluate(
694 &[policy.clone()],
695 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
696 ),
697 Decision::Allow
698 );
699 assert_eq!(
701 evaluate(
702 &[policy],
703 &req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
704 ),
705 Decision::ImplicitDeny
706 );
707 }
708
709 #[test]
710 fn not_resource_excludes_listed_resources() {
711 let p = principal_user("arn:aws:iam::123456789012:user/alice");
712 let policy = doc(json!({
713 "Statement": [{
714 "Effect": "Allow",
715 "Action": "s3:GetObject",
716 "NotResource": "arn:aws:s3:::secret-bucket/*"
717 }]
718 }));
719 assert_eq!(
720 evaluate(
721 &[policy.clone()],
722 &req(&p, "s3:GetObject", "arn:aws:s3:::public-bucket/key")
723 ),
724 Decision::Allow
725 );
726 assert_eq!(
727 evaluate(
728 &[policy],
729 &req(&p, "s3:GetObject", "arn:aws:s3:::secret-bucket/key")
730 ),
731 Decision::ImplicitDeny
732 );
733 }
734
735 #[test]
736 fn statement_with_condition_is_skipped_in_phase1() {
737 let p = principal_user("arn:aws:iam::123456789012:user/alice");
738 let policy = doc(json!({
739 "Statement": [{
740 "Effect": "Allow",
741 "Action": "*",
742 "Resource": "*",
743 "Condition": {
744 "StringEquals": { "aws:username": "alice" }
745 }
746 }]
747 }));
748 assert_eq!(
751 evaluate(
752 &[policy],
753 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
754 ),
755 Decision::ImplicitDeny
756 );
757 }
758
759 #[test]
760 fn deny_with_condition_does_not_stop_an_otherwise_allowed_request() {
761 let p = principal_user("arn:aws:iam::123456789012:user/alice");
762 let policy = doc(json!({
766 "Statement": [
767 {
768 "Effect": "Deny",
769 "Action": "*",
770 "Resource": "*",
771 "Condition": { "Bool": { "aws:MultiFactorAuthPresent": "false" } }
772 },
773 {
774 "Effect": "Allow",
775 "Action": "s3:GetObject",
776 "Resource": "*"
777 }
778 ]
779 }));
780 assert_eq!(
781 evaluate(
782 &[policy],
783 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
784 ),
785 Decision::Allow
786 );
787 }
788
789 #[test]
790 fn array_action_matches_any_entry() {
791 let p = principal_user("arn:aws:iam::123456789012:user/alice");
792 let policy = doc(json!({
793 "Statement": [{
794 "Effect": "Allow",
795 "Action": ["s3:GetObject", "s3:PutObject"],
796 "Resource": "*"
797 }]
798 }));
799 assert_eq!(
800 evaluate(
801 &[policy.clone()],
802 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
803 ),
804 Decision::Allow
805 );
806 assert_eq!(
807 evaluate(
808 &[policy],
809 &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
810 ),
811 Decision::Allow
812 );
813 }
814
815 #[test]
816 fn statement_without_effect_is_dropped() {
817 let p = principal_user("arn:aws:iam::123456789012:user/alice");
818 let policy = doc(json!({
819 "Statement": [
820 { "Action": "s3:GetObject", "Resource": "*" },
821 { "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*" }
822 ]
823 }));
824 assert_eq!(policy.statement_count(), 1);
827 assert_eq!(
828 evaluate(
829 &[policy],
830 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
831 ),
832 Decision::Allow
833 );
834 }
835
836 #[test]
837 fn statement_without_action_is_dropped() {
838 let policy = doc(json!({
839 "Statement": [{ "Effect": "Allow", "Resource": "*" }]
840 }));
841 assert_eq!(policy.statement_count(), 0);
842 }
843
844 #[test]
845 fn implicit_resource_acts_like_wildcard() {
846 let p = principal_user("arn:aws:iam::123456789012:user/alice");
847 let policy = doc(json!({
848 "Statement": [{ "Effect": "Allow", "Action": "s3:GetObject" }]
849 }));
850 assert_eq!(
851 evaluate(
852 &[policy],
853 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
854 ),
855 Decision::Allow
856 );
857 }
858
859 #[test]
860 fn malformed_policy_json_is_implicit_deny() {
861 let p = principal_user("arn:aws:iam::123456789012:user/alice");
862 let policy = PolicyDocument::parse("{ this is not valid json");
863 assert_eq!(policy.statement_count(), 0);
864 assert_eq!(
865 evaluate(
866 &[policy],
867 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
868 ),
869 Decision::ImplicitDeny
870 );
871 }
872
873 #[test]
874 fn deny_short_circuits_after_match() {
875 let p = principal_user("arn:aws:iam::123456789012:user/alice");
876 let policy = doc(json!({
877 "Statement": [
878 { "Effect": "Deny", "Action": "*", "Resource": "*" },
879 { "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*" }
880 ]
881 }));
882 assert_eq!(
883 evaluate(
884 &[policy],
885 &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
886 ),
887 Decision::ExplicitDeny
888 );
889 }
890
891 #[test]
892 fn user_name_from_arn_strips_iam_path() {
893 assert_eq!(
895 user_name_from_arn("arn:aws:iam::123456789012:user/alice"),
896 Some("alice")
897 );
898 assert_eq!(
903 user_name_from_arn("arn:aws:iam::123456789012:user/engineering/alice"),
904 Some("alice")
905 );
906 assert_eq!(
907 user_name_from_arn("arn:aws:iam::123456789012:user/path/to/alice"),
908 Some("alice")
909 );
910 assert_eq!(user_name_from_arn("arn:aws:iam::123456789012:role/r"), None);
911 }
912
913 #[test]
914 fn collect_identity_policies_resolves_pathed_user() {
915 use crate::state::IamUser;
919 use chrono::Utc;
920 let mut state = IamState::new("123456789012");
921 state.users.insert(
922 "alice".to_string(),
923 IamUser {
924 user_name: "alice".into(),
925 user_id: "AIDAALICE".into(),
926 arn: "arn:aws:iam::123456789012:user/engineering/alice".into(),
927 path: "/engineering/".into(),
928 created_at: Utc::now(),
929 tags: Vec::new(),
930 permissions_boundary: None,
931 },
932 );
933 let mut inline = std::collections::HashMap::new();
934 inline.insert(
935 "AllowGet".to_string(),
936 r#"{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#
937 .to_string(),
938 );
939 state
940 .user_inline_policies
941 .insert("alice".to_string(), inline);
942
943 let principal = Principal {
944 arn: "arn:aws:iam::123456789012:user/engineering/alice".to_string(),
945 user_id: "AIDAALICE".to_string(),
946 account_id: "123456789012".to_string(),
947 principal_type: PrincipalType::User,
948 source_identity: None,
949 };
950 let docs = collect_identity_policies(&state, &principal);
951 assert_eq!(docs.len(), 1, "pathed user's inline policy was missed");
952 assert_eq!(
953 evaluate(
954 &docs,
955 &req(&principal, "s3:GetObject", "arn:aws:s3:::bucket/key")
956 ),
957 Decision::Allow
958 );
959 }
960
961 #[test]
962 fn role_name_from_assumed_role_arn_strips_session() {
963 assert_eq!(
964 role_name_from_assumed_role_arn("arn:aws:sts::123456789012:assumed-role/ops/session-1"),
965 Some("ops")
966 );
967 }
968
969 #[test]
972 fn collect_identity_policies_picks_up_user_inline() {
973 use crate::state::IamUser;
974 use chrono::Utc;
975 let mut state = IamState::new("123456789012");
976 state.users.insert(
977 "alice".to_string(),
978 IamUser {
979 user_name: "alice".into(),
980 user_id: "AIDAALICE".into(),
981 arn: "arn:aws:iam::123456789012:user/alice".into(),
982 path: "/".into(),
983 created_at: Utc::now(),
984 tags: Vec::new(),
985 permissions_boundary: None,
986 },
987 );
988 let mut inline = std::collections::HashMap::new();
989 inline.insert(
990 "AllowGet".to_string(),
991 r#"{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#
992 .to_string(),
993 );
994 state
995 .user_inline_policies
996 .insert("alice".to_string(), inline);
997
998 let principal = principal_user("arn:aws:iam::123456789012:user/alice");
999 let docs = collect_identity_policies(&state, &principal);
1000 assert_eq!(docs.len(), 1);
1001 assert_eq!(
1002 evaluate(
1003 &docs,
1004 &req(&principal, "s3:GetObject", "arn:aws:s3:::bucket/key")
1005 ),
1006 Decision::Allow
1007 );
1008 }
1009
1010 #[test]
1011 fn collect_identity_policies_picks_up_managed_via_groups() {
1012 use crate::state::{IamGroup, IamPolicy, IamUser, PolicyVersion};
1013 use chrono::Utc;
1014 let mut state = IamState::new("123456789012");
1015 state.users.insert(
1016 "alice".to_string(),
1017 IamUser {
1018 user_name: "alice".into(),
1019 user_id: "AIDAALICE".into(),
1020 arn: "arn:aws:iam::123456789012:user/alice".into(),
1021 path: "/".into(),
1022 created_at: Utc::now(),
1023 tags: Vec::new(),
1024 permissions_boundary: None,
1025 },
1026 );
1027 let policy_arn = "arn:aws:iam::123456789012:policy/AllowGet".to_string();
1028 state.policies.insert(
1029 policy_arn.clone(),
1030 IamPolicy {
1031 policy_name: "AllowGet".into(),
1032 policy_id: "ANPA1".into(),
1033 arn: policy_arn.clone(),
1034 path: "/".into(),
1035 description: "".into(),
1036 created_at: Utc::now(),
1037 tags: Vec::new(),
1038 default_version_id: "v1".into(),
1039 versions: vec![PolicyVersion {
1040 version_id: "v1".into(),
1041 document: r#"{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#.into(),
1042 is_default: true,
1043 created_at: Utc::now(),
1044 }],
1045 next_version_num: 2,
1046 attachment_count: 1,
1047 },
1048 );
1049 state.groups.insert(
1050 "readers".to_string(),
1051 IamGroup {
1052 group_name: "readers".into(),
1053 group_id: "AGPA1".into(),
1054 arn: "arn:aws:iam::123456789012:group/readers".into(),
1055 path: "/".into(),
1056 created_at: Utc::now(),
1057 members: vec!["alice".into()],
1058 inline_policies: std::collections::HashMap::new(),
1059 attached_policies: vec![policy_arn],
1060 },
1061 );
1062 let principal = principal_user("arn:aws:iam::123456789012:user/alice");
1063 let docs = collect_identity_policies(&state, &principal);
1064 assert_eq!(docs.len(), 1);
1065 assert_eq!(
1066 evaluate(
1067 &docs,
1068 &req(&principal, "s3:GetObject", "arn:aws:s3:::bucket/key")
1069 ),
1070 Decision::Allow
1071 );
1072 }
1073
1074 #[test]
1075 fn collect_identity_policies_for_root_returns_empty() {
1076 let state = IamState::new("123456789012");
1077 let principal = Principal {
1078 arn: "arn:aws:iam::123456789012:root".into(),
1079 user_id: "ROOT".into(),
1080 account_id: "123456789012".into(),
1081 principal_type: PrincipalType::Root,
1082 source_identity: None,
1083 };
1084 assert!(collect_identity_policies(&state, &principal).is_empty());
1088 }
1089}