Skip to main content

fakecloud_iam/
evaluator.rs

1//! Phase 1 IAM identity-policy evaluator.
2//!
3//! This module is a **pure function** over a set of policy documents and a
4//! request: it does no I/O, no network, no state mutation, and never panics.
5//! Dispatch (in batch 6) wires it up by collecting the principal's effective
6//! policy set via [`collect_identity_policies`] and calling
7//! [`evaluate`].
8//!
9//! # Phase 1 scope
10//!
11//! Implemented:
12//! - `Effect: "Allow"` / `Effect: "Deny"` with **Deny precedence**: any
13//!   matching `Deny` statement wins, regardless of how many `Allow`s match.
14//! - `Action` / `NotAction` with `*` and `?` wildcards (case-insensitive
15//!   service prefix match, case-sensitive action match — matches AWS).
16//! - `Resource` / `NotResource` with `*` and `?` wildcards.
17//! - Identity policies attached to users (inline + managed) and to groups
18//!   the user belongs to.
19//! - Identity policies attached to roles (inline + managed).
20//! - Empty effective policy set → implicit deny.
21//!
22//! **Not** implemented (returns implicit deny rather than guessing — these
23//! are tracked for Phase 2 and documented on `/docs/reference/security`):
24//! - `Condition` blocks (StringEquals, IpAddress, DateLessThan, …)
25//! - `NotPrincipal` (used in resource-based policies)
26//! - Resource-based policies (S3 bucket policies, SNS topic policies,
27//!   KMS key policies, Lambda resource policies, …)
28//! - Permission boundaries
29//! - Service control policies
30//! - Session policies passed to `AssumeRole`
31//! - ABAC / tag conditions
32//!
33//! Statements that contain a `Condition` block are **skipped during
34//! evaluation** with a `tracing::debug!` so users running in `soft` mode
35//! can see which policies didn't get considered.
36
37use std::collections::HashSet;
38
39use fakecloud_core::auth::{Principal, PrincipalType};
40use serde_json::Value;
41
42use crate::state::IamState;
43
44/// The result of evaluating a request against a set of policies.
45///
46/// `Allow` requires at least one matching `Allow` statement and zero
47/// matching `Deny` statements. `ExplicitDeny` indicates at least one
48/// matching `Deny` statement (which takes precedence over any `Allow`).
49/// `ImplicitDeny` is the catch-all for "no policy spoke to this request".
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Decision {
52    Allow,
53    ImplicitDeny,
54    ExplicitDeny,
55}
56
57impl Decision {
58    /// Returns true if the request should be allowed.
59    pub fn is_allow(self) -> bool {
60        matches!(self, Decision::Allow)
61    }
62}
63
64/// One IAM action to evaluate against a policy set.
65///
66/// `action` follows the canonical `service:Action` shape (e.g.
67/// `s3:GetObject`, `sqs:SendMessage`). `resource` is a fully-qualified
68/// AWS ARN; the per-service resource extractors in batches 6-8 produce
69/// these.
70///
71/// `_context` is reserved for Phase 2 condition-key plumbing and is
72/// currently unused — present as a field so the evaluator's call sites
73/// don't need to change when conditions land.
74#[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/// Request-time context keys that Phase 2 will use for `Condition`
83/// evaluation. Currently empty so the evaluator API doesn't need a
84/// breaking change later.
85#[derive(Debug, Clone, Default)]
86pub struct RequestContext {}
87
88/// Parsed view of a single statement within a policy document.
89#[derive(Debug, Clone)]
90pub(crate) struct ParsedStatement {
91    pub effect: Effect,
92    pub action: ActionMatch,
93    pub resource: ResourceMatch,
94    /// Whether this statement carried a `Condition` block. Phase 1 cannot
95    /// evaluate conditions, so any conditioned statement is skipped during
96    /// evaluation rather than silently treated as unconditioned.
97    pub has_condition: bool,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub(crate) enum Effect {
102    Allow,
103    Deny,
104}
105
106/// Action / NotAction patterns. `Allow` lists are positive matches;
107/// `Deny` lists are negative matches (NotAction).
108#[derive(Debug, Clone)]
109pub(crate) enum ActionMatch {
110    Action(Vec<String>),
111    NotAction(Vec<String>),
112}
113
114/// Resource / NotResource patterns.
115#[derive(Debug, Clone)]
116pub(crate) enum ResourceMatch {
117    Resource(Vec<String>),
118    NotResource(Vec<String>),
119    /// Statement omitted both `Resource` and `NotResource`. AWS treats
120    /// this as "applies to all resources" only inside trust policies; for
121    /// identity policies it's a validation error. We treat missing as
122    /// wildcard-all to match how some Terraform-generated policies look
123    /// in practice, but the evaluator never silently grants more than
124    /// the policy text actually says — this maps to the same behavior
125    /// as `Resource: ["*"]`.
126    Implicit,
127}
128
129/// Parsed policy document — only the fields the evaluator needs. Any
130/// statement that fails to parse (wrong shape, unknown effect, etc.) is
131/// dropped with a warn-level log and the rest of the document is still
132/// usable, matching how AWS behaves with invalid statements (the broken
133/// statement is ignored, not the whole policy).
134#[derive(Debug, Clone, Default)]
135pub struct PolicyDocument {
136    pub(crate) statements: Vec<ParsedStatement>,
137}
138
139impl PolicyDocument {
140    /// Parse a policy document from its JSON string form. Returns an
141    /// empty document on JSON errors so the caller can fall through to
142    /// implicit-deny rather than panicking on malformed state.
143    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    /// Parse a policy document from a `serde_json::Value`. Used by both
155    /// [`PolicyDocument::parse`] and tests that build inline `serde_json!`
156    /// values.
157    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    /// Number of parsed statements in this document. Used by tests as a
167    /// proxy for "did this statement parse successfully?" without exposing
168    /// the internal representation.
169    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
208/// Coerce a JSON value into a list of strings. AWS policy schema accepts
209/// either a single string or an array of strings for `Action`/`Resource`.
210/// Non-string entries are dropped.
211fn 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
222/// Evaluate a request against a set of policy documents.
223///
224/// Implements AWS's standard identity-policy evaluation logic for Phase 1
225/// features only. See the module-level docstring for the exhaustive list
226/// of what is and isn't covered.
227///
228/// # Algorithm
229///
230/// 1. Walk every statement in every policy.
231/// 2. Skip any statement that has a `Condition` block (Phase 2).
232/// 3. For each statement that matches the request's action *and* resource:
233///    - If `Effect: Deny` → return [`Decision::ExplicitDeny`] immediately.
234///    - If `Effect: Allow` → record that we saw an allow.
235/// 4. After all statements are scanned: return [`Decision::Allow`] if any
236///    allow matched, otherwise [`Decision::ImplicitDeny`].
237pub 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
291/// IAM-style glob match supporting `*` (any sequence) and `?` (single
292/// character). When `case_insensitive_service_prefix` is true and the
293/// pattern looks like an action (`service:Action`), the service prefix is
294/// matched case-insensitively while the action name is matched as-is —
295/// matches how AWS evaluates Action patterns.
296fn 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
310/// Plain glob matcher with `*` (zero or more) and `?` (exactly one).
311/// Iterative two-pointer implementation — runs in `O(pattern.len() *
312/// value.len())` worst case, no backtracking explosions.
313fn 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
342/// Collect every identity policy that should be considered when
343/// evaluating a request from `principal`.
344///
345/// Phase 1 walks identity policies only (user inline + managed, group
346/// inline + managed via membership, role inline + managed). Resource
347/// policies, permission boundaries, and SCPs are not consulted —
348/// see the module-level scope notes.
349///
350/// The returned vector is the **deduplicated** set of policy documents,
351/// parsed and ready to feed into [`evaluate`]. Unknown managed policy
352/// ARNs are skipped with a debug log.
353pub 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            // Root bypasses evaluation; the caller (dispatch) should
369            // short-circuit via `Principal::is_root` before reaching here.
370            // Returning an empty vec means an explicit `Allow` is required,
371            // which is the safe default if a caller forgets to bypass.
372        }
373        PrincipalType::FederatedUser | PrincipalType::Unknown => {
374            // No identity-policy story for these in Phase 1.
375        }
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    // Group memberships: walk every group whose members include the user.
402    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
454/// Extract the bare `user_name` component from an IAM user ARN.
455///
456/// IAM users can be created with a non-default path (e.g. `/engineering/`),
457/// which produces ARNs of the form
458/// `arn:aws:iam::123456789012:user/engineering/alice`. `IamState` indexes
459/// users by the bare name (`alice`), so returning the full
460/// `engineering/alice` would silently miss the user and make
461/// `collect_user_policies` return an empty set — the evaluator would then
462/// issue an incorrect implicit deny for every pathed user.
463/// (Identified by cubic on PR #392.)
464fn user_name_from_arn(arn: &str) -> Option<&str> {
465    let after = arn.rsplit_once(":user/").map(|(_, name)| name)?;
466    // Bare name is the last segment; the rest is the path.
467    Some(after.rsplit('/').next().unwrap_or(after))
468}
469
470fn role_name_from_assumed_role_arn(arn: &str) -> Option<&str> {
471    // `arn:aws:sts::<account>:assumed-role/<role-name>/<session>`
472    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    // --- glob_match -----------------------------------------------------
506
507    #[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        // Pattern that would blow up a naive recursive matcher.
533        assert!(!glob_match("a*a*a*a*a*b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
534    }
535
536    // --- iam_glob_match (action specifics) ------------------------------
537
538    #[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        // Action name is case-sensitive in AWS.
547        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    // --- evaluate -------------------------------------------------------
560
561    #[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        // Order doesn't matter — Deny still wins when listed first.
615        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        // Allowed because GetObject is not in NotAction.
692        assert_eq!(
693            evaluate(
694                &[policy.clone()],
695                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
696            ),
697            Decision::Allow
698        );
699        // Implicit-denied because DeleteObject is in NotAction (no allow matches).
700        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        // Phase 1 doesn't evaluate Condition, so the statement is skipped
749        // and we fall through to implicit deny — safer than guessing.
750        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        // A Deny-with-Condition is also skipped (we can't tell if the
763        // condition would have matched). The Allow that follows still
764        // grants the request.
765        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        // The dropped statement doesn't contribute, but the second
825        // valid one still grants the request.
826        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        // Default path — bare user name.
894        assert_eq!(
895            user_name_from_arn("arn:aws:iam::123456789012:user/alice"),
896            Some("alice")
897        );
898        // Non-default path — must return the bare name, not
899        // `engineering/alice`. IamState indexes users by the bare name,
900        // so returning the path would silently drop pathed users from
901        // policy evaluation (identified by cubic on PR #392).
902        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        // Regression guard for the pathed-user bug: a user created under
916        // `/engineering/` must still have their inline policies picked up
917        // by the evaluator.
918        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    // --- collect_identity_policies --------------------------------------
970
971    #[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        // Root short-circuits via Principal::is_root in dispatch; here we
1085        // just assert collect_identity_policies doesn't synthesize a
1086        // wildcard allow on its behalf.
1087        assert!(collect_identity_policies(&state, &principal).is_empty());
1088    }
1089}