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//! **Phase 2** — `Condition` block evaluation is now integrated via
23//! [`crate::condition`]. A statement that carries a `Condition` is
24//! evaluated against the [`RequestContext`] (populated at dispatch time);
25//! the statement applies iff every operator entry matches. Unknown
26//! operators / unknown keys / parse errors safe-fail to "statement does
27//! not apply" with a `fakecloud::iam::audit` debug log, matching the
28//! no-silent-accept rule from Phase 1.
29//!
30//! **Phase 3** — [`evaluate_with_gates`] and
31//! [`evaluate_with_resource_policy_and_gates`] add intersection with
32//! optional permission-boundary and session-policy layers. Each layer
33//! is evaluated independently with the same matching logic; the final
34//! decision requires every present layer to allow, and an explicit
35//! `Deny` in any layer still wins.
36//!
37//! **Not** implemented (returns implicit deny rather than guessing — these
38//! are tracked for future phases and documented on `/docs/reference/security`):
39//! - Service control policies
40//!
41use std::collections::HashSet;
42
43use fakecloud_core::auth::{Principal, PrincipalType};
44use serde_json::Value;
45
46use crate::condition::{CompiledCondition, ConditionContext};
47use crate::state::IamState;
48
49/// Request-time context keys used when evaluating `Condition` blocks.
50///
51/// This is a re-export of [`ConditionContext`] to keep the evaluator's
52/// public API stable while centralizing the context definition in the
53/// [`crate::condition`] module.
54pub type RequestContext = ConditionContext;
55
56/// The result of evaluating a request against a set of policies.
57///
58/// `Allow` requires at least one matching `Allow` statement and zero
59/// matching `Deny` statements. `ExplicitDeny` indicates at least one
60/// matching `Deny` statement (which takes precedence over any `Allow`).
61/// `ImplicitDeny` is the catch-all for "no policy spoke to this request".
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Decision {
64    Allow,
65    ImplicitDeny,
66    ExplicitDeny,
67}
68
69impl Decision {
70    /// Returns true if the request should be allowed.
71    pub fn is_allow(self) -> bool {
72        matches!(self, Decision::Allow)
73    }
74}
75
76/// One IAM action to evaluate against a policy set.
77///
78/// `action` follows the canonical `service:Action` shape (e.g.
79/// `s3:GetObject`, `sqs:SendMessage`). `resource` is a fully-qualified
80/// AWS ARN; the per-service resource extractors in batches 6-8 produce
81/// these.
82///
83/// `context` carries request-time condition keys (populated at dispatch)
84/// used when evaluating statements with a `Condition` block.
85#[derive(Debug, Clone)]
86pub struct EvalRequest<'a> {
87    pub principal: &'a Principal,
88    pub action: String,
89    pub resource: String,
90    pub context: RequestContext,
91}
92
93/// Parsed view of a single statement within a policy document.
94#[derive(Debug, Clone)]
95pub(crate) struct ParsedStatement {
96    pub effect: Effect,
97    pub action: ActionMatch,
98    pub resource: ResourceMatch,
99    /// Compiled `Condition` block if the statement carried one. A
100    /// statement with `Some(_)` only applies when the compiled block
101    /// evaluates to `true` against the request's [`RequestContext`].
102    pub condition: Option<CompiledCondition>,
103    /// How this statement restricts which principals it applies to.
104    /// Identity policies always parse as [`PrincipalPattern::None`];
105    /// resource policies may carry a `Principal` or `NotPrincipal` key.
106    pub principal: PrincipalPattern,
107}
108
109/// `Principal` / `NotPrincipal` pattern on a parsed statement.
110///
111/// Identity policies never carry `Principal` — they inherit the
112/// principal from the attaching identity. Resource policies (S3 bucket
113/// policies in the initial Phase 2 rollout) use `Principal` to name
114/// which users, accounts, or services the statement grants to.
115#[derive(Debug, Clone)]
116pub(crate) enum PrincipalPattern {
117    /// Statement carried neither `Principal` nor `NotPrincipal`.
118    /// Used by all identity-policy statements and by any resource-policy
119    /// statement that forgets to name a principal (AWS rejects the
120    /// latter at validation time, but the evaluator should not grant
121    /// silently if it somehow makes it in).
122    None,
123    /// Statement carried `Principal` naming the accepted principals.
124    /// A request is accepted iff it matches at least one entry.
125    Principal(Vec<PrincipalRef>),
126    /// Statement carried `NotPrincipal` naming the excluded principals.
127    /// A statement with `NotPrincipal` applies to all callers **except**
128    /// those matching any entry in the list — the inverse of `Principal`.
129    /// If the caller matches ANY entry, the statement does NOT apply.
130    /// If the caller matches NONE, the statement applies.
131    ///
132    /// An empty ref list (all entries were unrecognized principal types)
133    /// causes the statement to be skipped with a debug log — we never
134    /// silently grant by falling through to "matches everyone".
135    NotPrincipal(Vec<PrincipalRef>),
136}
137
138/// A single principal reference parsed from a statement's `Principal`
139/// key. AWS accepts several shapes; we implement the subset S3 bucket
140/// policies actually use in practice.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub(crate) enum PrincipalRef {
143    /// `"Principal": "*"` or `"Principal": {"AWS": "*"}`. Matches any
144    /// authenticated principal (including cross-account). The
145    /// public-bucket idiom.
146    AnyAws,
147    /// `"Principal": {"AWS": "arn:aws:iam::ACCOUNT:root"}`. Matches any
148    /// principal whose `account_id` equals `ACCOUNT`.
149    AwsAccountRoot(String),
150    /// `"Principal": {"AWS": "arn:aws:iam::ACCOUNT:user/name"}` (or
151    /// `role/name`, `assumed-role/...`, etc). Matches a principal
152    /// whose ARN equals this string exactly.
153    AwsArn(String),
154    /// `"Principal": {"Service": "lambda.amazonaws.com"}`. Matches a
155    /// principal whose ARN was produced by the named service
156    /// assuming a service-linked role (approximated by the role name
157    /// including the service host, matching how AWS builds
158    /// service-linked role ARNs).
159    Service(String),
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub(crate) enum Effect {
164    Allow,
165    Deny,
166}
167
168/// Action / NotAction patterns. `Allow` lists are positive matches;
169/// `Deny` lists are negative matches (NotAction).
170#[derive(Debug, Clone)]
171pub(crate) enum ActionMatch {
172    Action(Vec<String>),
173    NotAction(Vec<String>),
174}
175
176/// Resource / NotResource patterns.
177#[derive(Debug, Clone)]
178pub(crate) enum ResourceMatch {
179    Resource(Vec<String>),
180    NotResource(Vec<String>),
181    /// Statement omitted both `Resource` and `NotResource`. AWS treats
182    /// this as "applies to all resources" only inside trust policies; for
183    /// identity policies it's a validation error. We treat missing as
184    /// wildcard-all to match how some Terraform-generated policies look
185    /// in practice, but the evaluator never silently grants more than
186    /// the policy text actually says — this maps to the same behavior
187    /// as `Resource: ["*"]`.
188    Implicit,
189}
190
191/// Parsed policy document — only the fields the evaluator needs. Any
192/// statement that fails to parse (wrong shape, unknown effect, etc.) is
193/// dropped with a warn-level log and the rest of the document is still
194/// usable, matching how AWS behaves with invalid statements (the broken
195/// statement is ignored, not the whole policy).
196#[derive(Debug, Clone, Default)]
197pub struct PolicyDocument {
198    pub(crate) statements: Vec<ParsedStatement>,
199}
200
201impl PolicyDocument {
202    /// Parse a policy document from its JSON string form. Returns an
203    /// empty document on JSON errors so the caller can fall through to
204    /// implicit-deny rather than panicking on malformed state.
205    pub fn parse(json: &str) -> Self {
206        let value: Value = match serde_json::from_str(json) {
207            Ok(v) => v,
208            Err(e) => {
209                tracing::warn!(error = %e, "failed to parse policy document JSON; ignoring");
210                return Self::default();
211            }
212        };
213        Self::from_value(&value)
214    }
215
216    /// Parse a policy document from a `serde_json::Value`. Used by both
217    /// [`PolicyDocument::parse`] and tests that build inline `serde_json!`
218    /// values.
219    pub fn from_value(value: &Value) -> Self {
220        let statements = match value.get("Statement") {
221            Some(Value::Array(arr)) => arr.iter().filter_map(parse_statement).collect::<Vec<_>>(),
222            Some(obj @ Value::Object(_)) => parse_statement(obj).into_iter().collect(),
223            _ => Vec::new(),
224        };
225        Self { statements }
226    }
227
228    /// Number of parsed statements in this document. Used by tests as a
229    /// proxy for "did this statement parse successfully?" without exposing
230    /// the internal representation.
231    pub fn statement_count(&self) -> usize {
232        self.statements.len()
233    }
234}
235
236fn parse_statement(value: &Value) -> Option<ParsedStatement> {
237    let obj = value.as_object()?;
238    let effect = match obj.get("Effect")?.as_str()? {
239        "Allow" => Effect::Allow,
240        "Deny" => Effect::Deny,
241        other => {
242            tracing::warn!(effect = other, "unknown Effect; ignoring statement");
243            return None;
244        }
245    };
246    let action = if let Some(a) = obj.get("Action") {
247        ActionMatch::Action(coerce_string_list(a))
248    } else if let Some(na) = obj.get("NotAction") {
249        ActionMatch::NotAction(coerce_string_list(na))
250    } else {
251        tracing::warn!("statement has no Action or NotAction; ignoring");
252        return None;
253    };
254    let resource = if let Some(r) = obj.get("Resource") {
255        ResourceMatch::Resource(coerce_string_list(r))
256    } else if let Some(nr) = obj.get("NotResource") {
257        ResourceMatch::NotResource(coerce_string_list(nr))
258    } else {
259        ResourceMatch::Implicit
260    };
261    let condition = obj.get("Condition").map(CompiledCondition::parse);
262    let principal = if let Some(np) = obj.get("NotPrincipal") {
263        PrincipalPattern::NotPrincipal(parse_principal(np))
264    } else if let Some(p) = obj.get("Principal") {
265        PrincipalPattern::Principal(parse_principal(p))
266    } else {
267        PrincipalPattern::None
268    };
269    Some(ParsedStatement {
270        effect,
271        action,
272        resource,
273        condition,
274        principal,
275    })
276}
277
278/// Parse a `Principal` JSON value into the list of refs the evaluator
279/// can match against a request principal.
280///
281/// AWS accepts any of:
282/// - `"Principal": "*"`
283/// - `"Principal": {"AWS": "*"}` or `{"AWS": ["..."]}`
284/// - `"Principal": {"Service": "lambda.amazonaws.com"}` (string or array)
285/// - `"Principal": {"Federated": "..."}` (unhandled — debug log, drop)
286/// - `"Principal": {"CanonicalUser": "..."}` (unhandled — debug log, drop)
287///
288/// Unknown shapes fall through to an empty ref list, which the matcher
289/// treats as "doesn't match" — never silently grant.
290fn parse_principal(value: &Value) -> Vec<PrincipalRef> {
291    let mut out = Vec::new();
292    match value {
293        Value::String(s) if s == "*" => out.push(PrincipalRef::AnyAws),
294        Value::String(other) => {
295            tracing::debug!(
296                target: "fakecloud::iam::audit",
297                principal = %other,
298                "Principal string other than \"*\" is not a recognized shape; skipping"
299            );
300        }
301        Value::Object(map) => {
302            for (key, v) in map {
303                match key.as_str() {
304                    "AWS" => {
305                        for s in coerce_string_list(v) {
306                            out.push(classify_aws_principal(&s));
307                        }
308                    }
309                    "Service" => {
310                        for s in coerce_string_list(v) {
311                            out.push(PrincipalRef::Service(s));
312                        }
313                    }
314                    other => {
315                        tracing::debug!(
316                            target: "fakecloud::iam::audit",
317                            principal_type = %other,
318                            "Principal type not implemented in this rollout; skipping entry"
319                        );
320                    }
321                }
322            }
323        }
324        _ => {
325            tracing::debug!(
326                target: "fakecloud::iam::audit",
327                "Principal has an unexpected JSON shape; skipping"
328            );
329        }
330    }
331    out
332}
333
334fn classify_aws_principal(s: &str) -> PrincipalRef {
335    if s == "*" {
336        return PrincipalRef::AnyAws;
337    }
338    // `arn:aws:iam::<account>:root` → account root
339    if let Some(rest) = s.strip_prefix("arn:aws:iam::") {
340        if let Some((account, tail)) = rest.split_once(':') {
341            if tail == "root" && !account.is_empty() {
342                return PrincipalRef::AwsAccountRoot(account.to_string());
343            }
344        }
345    }
346    // A bare 12-digit account ID is shorthand for `<account>:root`.
347    if s.len() == 12 && s.chars().all(|c| c.is_ascii_digit()) {
348        return PrincipalRef::AwsAccountRoot(s.to_string());
349    }
350    PrincipalRef::AwsArn(s.to_string())
351}
352
353/// Coerce a JSON value into a list of strings. AWS policy schema accepts
354/// either a single string or an array of strings for `Action`/`Resource`.
355/// Non-string entries are dropped.
356fn coerce_string_list(value: &Value) -> Vec<String> {
357    match value {
358        Value::String(s) => vec![s.clone()],
359        Value::Array(arr) => arr
360            .iter()
361            .filter_map(|v| v.as_str().map(|s| s.to_string()))
362            .collect(),
363        _ => Vec::new(),
364    }
365}
366
367/// Evaluate a request against a set of policy documents.
368///
369/// Implements AWS's standard identity-policy evaluation logic for Phase 1
370/// features only. See the module-level docstring for the exhaustive list
371/// of what is and isn't covered.
372///
373/// # Algorithm
374///
375/// 1. Walk every statement in every policy.
376/// 2. For each statement that matches the request's action *and* resource:
377///    - If the statement has a `Condition` block, evaluate it against
378///      [`EvalRequest::context`]; skip the statement if the condition
379///      does not match.
380///    - If `Effect: Deny` → return [`Decision::ExplicitDeny`] immediately.
381///    - If `Effect: Allow` → record that we saw an allow.
382/// 3. After all statements are scanned: return [`Decision::Allow`] if any
383///    allow matched, otherwise [`Decision::ImplicitDeny`].
384pub fn evaluate(policies: &[PolicyDocument], request: &EvalRequest<'_>) -> Decision {
385    evaluate_with_gates(policies, None, None, request)
386}
387
388/// Evaluate `request` against a principal's identity policies plus
389/// optional permission-boundary, session-policy, and SCP layers.
390///
391/// Intersection semantics (applies identically to every gate):
392///
393/// - `boundary = None` / `session = None` / `scps = None` → the layer
394///   is absent and does not gate the decision (pass-through).
395/// - `Some(&[])` → the layer is present but empty, which evaluates to
396///   `ImplicitDeny` and therefore denies the request. This is how
397///   dangling boundary ARNs, empty session policies, and empty SCP
398///   sets (e.g. every policy detached from a target) are represented.
399/// - Any layer returning `ExplicitDeny` wins immediately (Deny
400///   precedence applies across layers, not just within one).
401/// - Otherwise the request is allowed iff **every present layer**
402///   evaluates to `Allow`. A layer with `ImplicitDeny` caps the
403///   intersection to `ImplicitDeny`.
404///
405/// When `scps` is `Some`, each document in the slice is treated as a
406/// separate gate that must allow — the caller already assembled the
407/// ordered list (root OU first, account-direct last) via
408/// [`crate::scp_resolver`] or equivalent.
409pub fn evaluate_with_gates(
410    identity: &[PolicyDocument],
411    boundary: Option<&[PolicyDocument]>,
412    session: Option<&[PolicyDocument]>,
413    request: &EvalRequest<'_>,
414) -> Decision {
415    evaluate_with_gates_and_scps(identity, boundary, session, None, request)
416}
417
418/// Full-chain variant of [`evaluate_with_gates`] that also applies an
419/// SCP ceiling. See the top-of-module docs for the intersection
420/// semantics. Batch 4 added this alongside the 4-arg form so existing
421/// callers (and tests) don't have to thread an extra `None` through
422/// every evaluation site.
423pub fn evaluate_with_gates_and_scps(
424    identity: &[PolicyDocument],
425    boundary: Option<&[PolicyDocument]>,
426    session: Option<&[PolicyDocument]>,
427    scps: Option<&[PolicyDocument]>,
428    request: &EvalRequest<'_>,
429) -> Decision {
430    let identity_decision = evaluate_inner(identity, request, false);
431    intersect_layers(identity_decision, boundary, session, scps, request)
432}
433
434/// Combine an already-computed identity-side decision with the optional
435/// boundary, session-policy, and SCP layers. Factored out so the
436/// resource-policy variant can apply the same intersection to the
437/// identity side before OR/ANDing with the resource-policy side.
438fn intersect_layers(
439    identity_decision: Decision,
440    boundary: Option<&[PolicyDocument]>,
441    session: Option<&[PolicyDocument]>,
442    scps: Option<&[PolicyDocument]>,
443    request: &EvalRequest<'_>,
444) -> Decision {
445    if matches!(identity_decision, Decision::ExplicitDeny) {
446        return Decision::ExplicitDeny;
447    }
448    // SCP gate sits at the top of the ceiling stack. Each SCP
449    // document is a separate layer that must allow (AWS intersects
450    // SCPs across the OU path). A single explicit Deny in any SCP
451    // short-circuits the evaluation.
452    let scp_decision = scps.map(|docs| evaluate_scp_chain(docs, request));
453    if matches!(scp_decision, Some(Decision::ExplicitDeny)) {
454        if let Some(scps_slice) = scps {
455            tracing::debug!(
456                target: "fakecloud::iam::audit",
457                action = %request.action,
458                principal_arn = %request.principal.arn,
459                scp_count = scps_slice.len(),
460                "SCP ceiling produced ExplicitDeny"
461            );
462        }
463        return Decision::ExplicitDeny;
464    }
465    let boundary_decision = boundary.map(|policies| evaluate_inner(policies, request, false));
466    if matches!(boundary_decision, Some(Decision::ExplicitDeny)) {
467        return Decision::ExplicitDeny;
468    }
469    let session_decision = session.map(|policies| evaluate_inner(policies, request, false));
470    if matches!(session_decision, Some(Decision::ExplicitDeny)) {
471        return Decision::ExplicitDeny;
472    }
473    // Intersection: every present layer must allow.
474    let identity_allows = matches!(identity_decision, Decision::Allow);
475    let boundary_allows = boundary_decision
476        .map(|d| matches!(d, Decision::Allow))
477        .unwrap_or(true);
478    let session_allows = session_decision
479        .map(|d| matches!(d, Decision::Allow))
480        .unwrap_or(true);
481    let scp_allows = scp_decision
482        .map(|d| matches!(d, Decision::Allow))
483        .unwrap_or(true);
484    if identity_allows && boundary_allows && session_allows && scp_allows {
485        Decision::Allow
486    } else {
487        if scps.is_some() && !scp_allows {
488            tracing::debug!(
489                target: "fakecloud::iam::audit",
490                action = %request.action,
491                principal_arn = %request.principal.arn,
492                "SCP ceiling did not allow action; capped to ImplicitDeny"
493            );
494        }
495        Decision::ImplicitDeny
496    }
497}
498
499/// Walk an ordered SCP chain (root OU -> descendant OUs -> account)
500/// and intersect the per-document decisions. Each document is its own
501/// gate: an explicit Deny anywhere wins, otherwise every document
502/// must evaluate to Allow for the chain to allow.
503fn evaluate_scp_chain(scps: &[PolicyDocument], request: &EvalRequest<'_>) -> Decision {
504    if scps.is_empty() {
505        // `Some(&[])` means the org exists and applies but no SCPs
506        // are attached up the chain. Preserve AWS's deny-by-default
507        // ceiling semantics: nothing allowed.
508        return Decision::ImplicitDeny;
509    }
510    let mut all_allow = true;
511    for doc in scps {
512        match evaluate_inner(std::slice::from_ref(doc), request, false) {
513            Decision::ExplicitDeny => return Decision::ExplicitDeny,
514            Decision::Allow => {}
515            Decision::ImplicitDeny => all_allow = false,
516        }
517    }
518    if all_allow {
519        Decision::Allow
520    } else {
521        Decision::ImplicitDeny
522    }
523}
524
525/// Evaluate `request` against the principal's identity policies and an
526/// optional resource-based policy, combining the two with AWS's
527/// cross-account semantics.
528///
529/// - Either side returning an explicit `Deny` wins immediately.
530/// - Same-account (`principal.account_id == resource_account_id`):
531///   the request is allowed if identity OR resource grants it.
532/// - Cross-account: the request is allowed only if identity AND
533///   resource both grant it.
534///
535/// `resource_account_id` is the 12-digit account that owns the target
536/// resource. For S3 bucket policies, dispatch parses this from the
537/// resource ARN; S3 ARNs have an empty account field, so the caller
538/// is expected to fall back to the server's configured account ID in
539/// that case (#381 multi-account alignment).
540pub fn evaluate_with_resource_policy(
541    identity_policies: &[PolicyDocument],
542    resource_policy: Option<&PolicyDocument>,
543    request: &EvalRequest<'_>,
544    resource_account_id: &str,
545) -> Decision {
546    evaluate_with_resource_policy_and_gates(
547        identity_policies,
548        None,
549        None,
550        resource_policy,
551        request,
552        resource_account_id,
553    )
554}
555
556/// Resource-policy variant of [`evaluate_with_gates`].
557///
558/// The boundary and session policies gate the **identity side** only —
559/// they never apply to the resource-policy branch. Rationale: the
560/// resource policy is evaluated in the resource's account, and a
561/// caller's permission boundary has no authority in another account
562/// (this is also how AWS describes it). That shows up here as two
563/// separate combinators:
564///
565/// - Same-account: `(identity ∩ boundary ∩ session) OR resource`.
566///   Boundary/session cap the identity side, but a resource-policy
567///   grant in the same account still allows the request on its own.
568/// - Cross-account: `(identity ∩ boundary ∩ session) AND resource`.
569///   Both sides must allow; boundary/session still cap the identity
570///   side.
571///
572/// Explicit Deny from any layer — identity, boundary, session, or
573/// resource — wins immediately.
574pub fn evaluate_with_resource_policy_and_gates(
575    identity_policies: &[PolicyDocument],
576    boundary: Option<&[PolicyDocument]>,
577    session: Option<&[PolicyDocument]>,
578    resource_policy: Option<&PolicyDocument>,
579    request: &EvalRequest<'_>,
580    resource_account_id: &str,
581) -> Decision {
582    evaluate_with_resource_policy_and_gates_and_scps(
583        identity_policies,
584        boundary,
585        session,
586        None,
587        resource_policy,
588        request,
589        resource_account_id,
590    )
591}
592
593/// Full-chain variant of
594/// [`evaluate_with_resource_policy_and_gates`] that also applies an
595/// SCP ceiling on the identity side. SCPs never apply to the
596/// resource-policy branch — AWS evaluates the resource policy in the
597/// resource's account, and the caller's SCPs have no authority there.
598pub fn evaluate_with_resource_policy_and_gates_and_scps(
599    identity_policies: &[PolicyDocument],
600    boundary: Option<&[PolicyDocument]>,
601    session: Option<&[PolicyDocument]>,
602    scps: Option<&[PolicyDocument]>,
603    resource_policy: Option<&PolicyDocument>,
604    request: &EvalRequest<'_>,
605    resource_account_id: &str,
606) -> Decision {
607    let identity_raw = evaluate_inner(identity_policies, request, false);
608    if matches!(identity_raw, Decision::ExplicitDeny) {
609        return Decision::ExplicitDeny;
610    }
611    // Apply boundary, session, and SCP gates to the identity side.
612    // SCPs only apply to the identity side (never to the resource
613    // policy branch) — they are the caller-account ceiling, and AWS
614    // evaluates the resource policy in the resource's account.
615    let identity_gated = intersect_layers(identity_raw, boundary, session, scps, request);
616    if matches!(identity_gated, Decision::ExplicitDeny) {
617        return Decision::ExplicitDeny;
618    }
619
620    let same_account = request.principal.account_id == resource_account_id;
621    // Same-account with no resource policy: preserve the identity-only
622    // path so rollouts without a bucket/topic policy behave as before.
623    if resource_policy.is_none() && same_account {
624        return identity_gated;
625    }
626    let resource = match resource_policy {
627        Some(policy) => evaluate_inner(std::slice::from_ref(policy), request, true),
628        None => Decision::ImplicitDeny,
629    };
630    if matches!(resource, Decision::ExplicitDeny) {
631        return Decision::ExplicitDeny;
632    }
633    let identity_allows = matches!(identity_gated, Decision::Allow);
634    let resource_allows = matches!(resource, Decision::Allow);
635    let allowed = if same_account {
636        identity_allows || resource_allows
637    } else {
638        identity_allows && resource_allows
639    };
640    if allowed {
641        Decision::Allow
642    } else {
643        Decision::ImplicitDeny
644    }
645}
646
647fn evaluate_inner(
648    policies: &[PolicyDocument],
649    request: &EvalRequest<'_>,
650    is_resource_policy: bool,
651) -> Decision {
652    let mut allowed = false;
653    for policy in policies {
654        for statement in &policy.statements {
655            // Principal / NotPrincipal gate. Identity policies never
656            // carry these keys; resource policies must, and a
657            // statement without a matching Principal does not apply.
658            match &statement.principal {
659                PrincipalPattern::None => {
660                    if is_resource_policy {
661                        // Resource-policy statement with no Principal
662                        // does not apply — AWS treats this as a
663                        // validation error and we will not silently
664                        // grant.
665                        tracing::debug!(
666                            target: "fakecloud::iam::audit",
667                            action = %request.action,
668                            "resource policy statement has no Principal; skipping"
669                        );
670                        continue;
671                    }
672                }
673                PrincipalPattern::Principal(refs) => {
674                    if !principal_matches(refs, request.principal) {
675                        continue;
676                    }
677                }
678                PrincipalPattern::NotPrincipal(refs) => {
679                    if refs.is_empty() {
680                        tracing::debug!(
681                            target: "fakecloud::iam::audit",
682                            action = %request.action,
683                            "NotPrincipal has no recognized principal types; statement does not apply"
684                        );
685                        continue;
686                    }
687                    // NotPrincipal: statement applies when caller does NOT match any entry.
688                    if principal_matches(refs, request.principal) {
689                        continue;
690                    }
691                }
692            }
693            if !action_matches(&statement.action, &request.action) {
694                continue;
695            }
696            if !resource_matches(&statement.resource, &request.resource) {
697                continue;
698            }
699            if let Some(condition) = &statement.condition {
700                if !condition.matches(&request.context) {
701                    tracing::debug!(
702                        target: "fakecloud::iam::audit",
703                        action = %request.action,
704                        "condition did not match; statement does not apply"
705                    );
706                    continue;
707                }
708            }
709            match statement.effect {
710                Effect::Deny => return Decision::ExplicitDeny,
711                Effect::Allow => allowed = true,
712            }
713        }
714    }
715    if allowed {
716        Decision::Allow
717    } else {
718        Decision::ImplicitDeny
719    }
720}
721
722/// Check whether any entry in a parsed `Principal` list matches the
723/// calling principal. An empty list never matches — that's how we
724/// keep unimplemented principal types (`Federated`, `CanonicalUser`)
725/// from silently granting.
726fn principal_matches(refs: &[PrincipalRef], principal: &Principal) -> bool {
727    refs.iter().any(|r| match r {
728        PrincipalRef::AnyAws => true,
729        PrincipalRef::AwsAccountRoot(account) => &principal.account_id == account,
730        PrincipalRef::AwsArn(arn) => &principal.arn == arn,
731        PrincipalRef::Service(service) => principal_is_service(principal, service),
732    })
733}
734
735/// Approximate match for a `"Service"` principal. AWS represents a
736/// request made by a service (e.g. Lambda invoking something via a
737/// service-linked role) as an assumed-role principal whose role ARN
738/// contains the service host. We match conservatively: the principal
739/// must be an `AssumedRole` whose ARN contains the literal service
740/// host string. False matches are avoided because unrelated role
741/// names would have to happen to contain `lambda.amazonaws.com` —
742/// unlikely in practice and never silently grant to user principals.
743fn principal_is_service(principal: &Principal, service: &str) -> bool {
744    matches!(principal.principal_type, PrincipalType::AssumedRole)
745        && principal.arn.contains(service)
746}
747
748fn action_matches(action: &ActionMatch, request_action: &str) -> bool {
749    match action {
750        ActionMatch::Action(patterns) => patterns
751            .iter()
752            .any(|p| iam_glob_match(p, request_action, true)),
753        ActionMatch::NotAction(patterns) => patterns
754            .iter()
755            .all(|p| !iam_glob_match(p, request_action, true)),
756    }
757}
758
759fn resource_matches(resource: &ResourceMatch, request_resource: &str) -> bool {
760    match resource {
761        ResourceMatch::Resource(patterns) => patterns
762            .iter()
763            .any(|p| iam_glob_match(p, request_resource, false)),
764        ResourceMatch::NotResource(patterns) => patterns
765            .iter()
766            .all(|p| !iam_glob_match(p, request_resource, false)),
767        ResourceMatch::Implicit => true,
768    }
769}
770
771/// IAM-style glob match supporting `*` (any sequence) and `?` (single
772/// character). When `case_insensitive_service_prefix` is true and the
773/// pattern looks like an action (`service:Action`), the service prefix is
774/// matched case-insensitively while the action name is matched as-is —
775/// matches how AWS evaluates Action patterns.
776fn iam_glob_match(pattern: &str, value: &str, case_insensitive_service_prefix: bool) -> bool {
777    if case_insensitive_service_prefix {
778        if let (Some((p_svc, p_act)), Some((v_svc, v_act))) =
779            (pattern.split_once(':'), value.split_once(':'))
780        {
781            if !glob_match(&p_svc.to_ascii_lowercase(), &v_svc.to_ascii_lowercase()) {
782                return false;
783            }
784            return glob_match(p_act, v_act);
785        }
786    }
787    glob_match(pattern, value)
788}
789
790/// Plain glob matcher with `*` (zero or more) and `?` (exactly one).
791/// Iterative two-pointer implementation — runs in `O(pattern.len() *
792/// value.len())` worst case, no backtracking explosions.
793fn glob_match(pattern: &str, value: &str) -> bool {
794    let p: Vec<char> = pattern.chars().collect();
795    let v: Vec<char> = value.chars().collect();
796    let mut pi = 0usize;
797    let mut vi = 0usize;
798    let mut star: Option<usize> = None;
799    let mut star_v: usize = 0;
800    while vi < v.len() {
801        if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
802            pi += 1;
803            vi += 1;
804        } else if pi < p.len() && p[pi] == '*' {
805            star = Some(pi);
806            star_v = vi;
807            pi += 1;
808        } else if let Some(s) = star {
809            pi = s + 1;
810            star_v += 1;
811            vi = star_v;
812        } else {
813            return false;
814        }
815    }
816    while pi < p.len() && p[pi] == '*' {
817        pi += 1;
818    }
819    pi == p.len()
820}
821
822/// Collect every identity policy that should be considered when
823/// evaluating a request from `principal`.
824///
825/// Phase 1 walks identity policies only (user inline + managed, group
826/// inline + managed via membership, role inline + managed). Resource
827/// policies, permission boundaries, and SCPs are not consulted —
828/// see the module-level scope notes.
829///
830/// The returned vector is the **deduplicated** set of policy documents,
831/// parsed and ready to feed into [`evaluate`]. Unknown managed policy
832/// ARNs are skipped with a debug log.
833pub fn collect_identity_policies(state: &IamState, principal: &Principal) -> Vec<PolicyDocument> {
834    let mut docs = Vec::new();
835    let mut seen_managed: HashSet<String> = HashSet::new();
836    match principal.principal_type {
837        PrincipalType::User => {
838            if let Some(user_name) = user_name_from_arn(&principal.arn) {
839                collect_user_policies(state, user_name, &mut docs, &mut seen_managed);
840            }
841        }
842        PrincipalType::AssumedRole => {
843            if let Some(role_name) = role_name_from_assumed_role_arn(&principal.arn) {
844                collect_role_policies(state, role_name, &mut docs, &mut seen_managed);
845            }
846        }
847        PrincipalType::Root => {
848            // Root bypasses evaluation; the caller (dispatch) should
849            // short-circuit via `Principal::is_root` before reaching here.
850            // Returning an empty vec means an explicit `Allow` is required,
851            // which is the safe default if a caller forgets to bypass.
852        }
853        PrincipalType::FederatedUser | PrincipalType::Unknown => {
854            // No identity-policy story for these in Phase 1.
855        }
856    }
857    docs
858}
859
860fn collect_user_policies(
861    state: &IamState,
862    user_name: &str,
863    docs: &mut Vec<PolicyDocument>,
864    seen_managed: &mut HashSet<String>,
865) {
866    if let Some(inline) = state.user_inline_policies.get(user_name) {
867        for doc in inline.values() {
868            docs.push(PolicyDocument::parse(doc));
869        }
870    }
871    if let Some(arns) = state.user_policies.get(user_name) {
872        for arn in arns {
873            if !seen_managed.insert(arn.clone()) {
874                continue;
875            }
876            if let Some(doc) = managed_policy_default_document(state, arn) {
877                docs.push(PolicyDocument::parse(&doc));
878            }
879        }
880    }
881    // Group memberships: walk every group whose members include the user.
882    for (group_name, group) in &state.groups {
883        if !group.members.iter().any(|m| m == user_name) {
884            continue;
885        }
886        for doc in group.inline_policies.values() {
887            docs.push(PolicyDocument::parse(doc));
888        }
889        for arn in &group.attached_policies {
890            if !seen_managed.insert(arn.clone()) {
891                continue;
892            }
893            if let Some(doc) = managed_policy_default_document(state, arn) {
894                docs.push(PolicyDocument::parse(&doc));
895            }
896        }
897        let _ = group_name;
898    }
899}
900
901fn collect_role_policies(
902    state: &IamState,
903    role_name: &str,
904    docs: &mut Vec<PolicyDocument>,
905    seen_managed: &mut HashSet<String>,
906) {
907    if let Some(inline) = state.role_inline_policies.get(role_name) {
908        for doc in inline.values() {
909            docs.push(PolicyDocument::parse(doc));
910        }
911    }
912    if let Some(arns) = state.role_policies.get(role_name) {
913        for arn in arns {
914            if !seen_managed.insert(arn.clone()) {
915                continue;
916            }
917            if let Some(doc) = managed_policy_default_document(state, arn) {
918                docs.push(PolicyDocument::parse(&doc));
919            }
920        }
921    }
922}
923
924/// Look up the permission-boundary policy document attached to
925/// `principal`, if any.
926///
927/// Returns:
928/// - `None` — the principal has no boundary set, OR the principal is
929///   exempt from boundary evaluation (account root, service-linked
930///   role, or an unhandled principal type like a federated user). The
931///   caller should treat this as "boundary layer absent"
932///   (pass-through) when calling [`evaluate_with_gates`].
933/// - `Some(vec![])` — a boundary ARN is set but does not resolve to
934///   a known managed policy (dangling ARN, or the user/role was found
935///   but its boundary points at a deleted policy). The caller must
936///   treat this as **deny-all** — matching AWS's behavior when a
937///   permission boundary is deleted, the principal can no longer
938///   perform any action until the boundary is re-attached or removed.
939///   Emits a `fakecloud::iam::audit` debug log.
940/// - `Some(vec![doc])` — the boundary resolves to a policy document.
941///
942/// Service-linked roles are detected by the `AWSServiceRoleFor` name
943/// prefix (AWS rejects attaching boundaries to SLRs at the API layer
944/// anyway; this is defense-in-depth).
945pub fn collect_boundary_policies(
946    state: &IamState,
947    principal: &Principal,
948) -> Option<Vec<PolicyDocument>> {
949    if principal.is_root() {
950        return None;
951    }
952    let boundary_arn = match principal.principal_type {
953        PrincipalType::User => {
954            let user_name = user_name_from_arn(&principal.arn)?;
955            let user = state.users.get(user_name)?;
956            user.permissions_boundary.clone()?
957        }
958        PrincipalType::AssumedRole => {
959            let role_name = role_name_from_assumed_role_arn(&principal.arn)?;
960            if role_name.starts_with("AWSServiceRoleFor") {
961                // Service-linked roles are exempt from boundary
962                // evaluation — AWS rejects attaching one at the API
963                // layer, but if state has been force-injected we
964                // still bypass to match documented semantics.
965                return None;
966            }
967            let role = state.roles.get(role_name)?;
968            role.permissions_boundary.clone()?
969        }
970        // No boundary story for root / federated / unknown.
971        _ => return None,
972    };
973    match managed_policy_default_document(state, &boundary_arn) {
974        Some(doc) => Some(vec![PolicyDocument::parse(&doc)]),
975        None => {
976            tracing::debug!(
977                target: "fakecloud::iam::audit",
978                principal_arn = %principal.arn,
979                boundary_arn = %boundary_arn,
980                "permission boundary ARN does not resolve to a known managed policy; denying all actions"
981            );
982            Some(Vec::new())
983        }
984    }
985}
986
987fn managed_policy_default_document(state: &IamState, arn: &str) -> Option<String> {
988    let policy = state.policies.get(arn)?;
989    policy
990        .versions
991        .iter()
992        .find(|v| v.is_default)
993        .or_else(|| policy.versions.first())
994        .map(|v| v.document.clone())
995}
996
997/// Extract the bare `user_name` component from an IAM user ARN.
998///
999/// IAM users can be created with a non-default path (e.g. `/engineering/`),
1000/// which produces ARNs of the form
1001/// `arn:aws:iam::123456789012:user/engineering/alice`. `IamState` indexes
1002/// users by the bare name (`alice`), so returning the full
1003/// `engineering/alice` would silently miss the user and make
1004/// `collect_user_policies` return an empty set — the evaluator would then
1005/// issue an incorrect implicit deny for every pathed user.
1006/// (Identified by cubic on PR #392.)
1007fn user_name_from_arn(arn: &str) -> Option<&str> {
1008    let after = arn.rsplit_once(":user/").map(|(_, name)| name)?;
1009    // Bare name is the last segment; the rest is the path.
1010    Some(after.rsplit('/').next().unwrap_or(after))
1011}
1012
1013fn role_name_from_assumed_role_arn(arn: &str) -> Option<&str> {
1014    // `arn:aws:sts::<account>:assumed-role/<role-name>/<session>`
1015    let after = arn.rsplit_once(":assumed-role/")?.1;
1016    Some(after.split('/').next().unwrap_or(after))
1017}
1018
1019#[cfg(test)]
1020#[allow(clippy::cloned_ref_to_slice_refs)]
1021mod tests {
1022    use super::*;
1023    use serde_json::json;
1024
1025    fn principal_user(arn: &str) -> Principal {
1026        Principal {
1027            arn: arn.to_string(),
1028            user_id: "AIDA".into(),
1029            account_id: "123456789012".into(),
1030            principal_type: PrincipalType::User,
1031            source_identity: None,
1032            tags: None,
1033        }
1034    }
1035
1036    fn req<'a>(principal: &'a Principal, action: &str, resource: &str) -> EvalRequest<'a> {
1037        EvalRequest {
1038            principal,
1039            action: action.to_string(),
1040            resource: resource.to_string(),
1041            context: RequestContext::default(),
1042        }
1043    }
1044
1045    fn doc(json: serde_json::Value) -> PolicyDocument {
1046        PolicyDocument::from_value(&json)
1047    }
1048
1049    // --- glob_match -----------------------------------------------------
1050
1051    #[test]
1052    fn glob_literal_match() {
1053        assert!(glob_match("foo", "foo"));
1054        assert!(!glob_match("foo", "bar"));
1055    }
1056
1057    #[test]
1058    fn glob_star_matches_any() {
1059        assert!(glob_match("*", "foo"));
1060        assert!(glob_match("*", ""));
1061        assert!(glob_match("foo*", "foobar"));
1062        assert!(glob_match("*bar", "foobar"));
1063        assert!(glob_match("f*r", "foobar"));
1064        assert!(!glob_match("foo*", "fo"));
1065    }
1066
1067    #[test]
1068    fn glob_question_mark_matches_one() {
1069        assert!(glob_match("f?o", "foo"));
1070        assert!(!glob_match("f?o", "fo"));
1071        assert!(!glob_match("f?o", "foo!"));
1072    }
1073
1074    #[test]
1075    fn glob_no_backtracking_explosion() {
1076        // Pattern that would blow up a naive recursive matcher.
1077        assert!(!glob_match("a*a*a*a*a*b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
1078    }
1079
1080    // --- iam_glob_match (action specifics) ------------------------------
1081
1082    #[test]
1083    fn iam_action_service_prefix_is_case_insensitive() {
1084        assert!(iam_glob_match("S3:GetObject", "s3:GetObject", true));
1085        assert!(iam_glob_match("s3:GetObject", "S3:GetObject", true));
1086    }
1087
1088    #[test]
1089    fn iam_action_name_is_case_sensitive() {
1090        // Action name is case-sensitive in AWS.
1091        assert!(!iam_glob_match("s3:getobject", "s3:GetObject", true));
1092        assert!(iam_glob_match("s3:GetObject", "s3:GetObject", true));
1093    }
1094
1095    #[test]
1096    fn iam_action_supports_wildcards() {
1097        assert!(iam_glob_match("s3:Get*", "s3:GetObject", true));
1098        assert!(iam_glob_match("s3:*", "s3:DeleteObject", true));
1099        assert!(iam_glob_match("*", "s3:GetObject", true));
1100        assert!(!iam_glob_match("s3:Get*", "s3:PutObject", true));
1101    }
1102
1103    // --- evaluate -------------------------------------------------------
1104
1105    #[test]
1106    fn empty_policy_set_is_implicit_deny() {
1107        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1108        assert_eq!(
1109            evaluate(&[], &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")),
1110            Decision::ImplicitDeny
1111        );
1112    }
1113
1114    #[test]
1115    fn allow_with_matching_action_and_resource() {
1116        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1117        let policy = doc(json!({
1118            "Version": "2012-10-17",
1119            "Statement": [{
1120                "Effect": "Allow",
1121                "Action": "s3:GetObject",
1122                "Resource": "arn:aws:s3:::bucket/key"
1123            }]
1124        }));
1125        assert_eq!(
1126            evaluate(
1127                &[policy],
1128                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
1129            ),
1130            Decision::Allow
1131        );
1132    }
1133
1134    #[test]
1135    fn deny_takes_precedence_over_allow() {
1136        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1137        let allow = doc(json!({
1138            "Statement": [{
1139                "Effect": "Allow",
1140                "Action": "*",
1141                "Resource": "*"
1142            }]
1143        }));
1144        let deny = doc(json!({
1145            "Statement": [{
1146                "Effect": "Deny",
1147                "Action": "s3:DeleteObject",
1148                "Resource": "*"
1149            }]
1150        }));
1151        assert_eq!(
1152            evaluate(
1153                &[allow.clone(), deny.clone()],
1154                &req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
1155            ),
1156            Decision::ExplicitDeny
1157        );
1158        // Order doesn't matter — Deny still wins when listed first.
1159        assert_eq!(
1160            evaluate(
1161                &[deny, allow],
1162                &req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
1163            ),
1164            Decision::ExplicitDeny
1165        );
1166    }
1167
1168    #[test]
1169    fn allow_with_wrong_action_is_implicit_deny() {
1170        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1171        let policy = doc(json!({
1172            "Statement": [{
1173                "Effect": "Allow",
1174                "Action": "s3:GetObject",
1175                "Resource": "*"
1176            }]
1177        }));
1178        assert_eq!(
1179            evaluate(
1180                &[policy],
1181                &req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
1182            ),
1183            Decision::ImplicitDeny
1184        );
1185    }
1186
1187    #[test]
1188    fn allow_with_wrong_resource_is_implicit_deny() {
1189        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1190        let policy = doc(json!({
1191            "Statement": [{
1192                "Effect": "Allow",
1193                "Action": "s3:GetObject",
1194                "Resource": "arn:aws:s3:::other-bucket/*"
1195            }]
1196        }));
1197        assert_eq!(
1198            evaluate(
1199                &[policy],
1200                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
1201            ),
1202            Decision::ImplicitDeny
1203        );
1204    }
1205
1206    #[test]
1207    fn resource_wildcard_matches_arn_path() {
1208        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1209        let policy = doc(json!({
1210            "Statement": [{
1211                "Effect": "Allow",
1212                "Action": "s3:GetObject",
1213                "Resource": "arn:aws:s3:::bucket/*"
1214            }]
1215        }));
1216        assert_eq!(
1217            evaluate(
1218                &[policy],
1219                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/path/to/key")
1220            ),
1221            Decision::Allow
1222        );
1223    }
1224
1225    #[test]
1226    fn not_action_excludes_listed_actions() {
1227        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1228        let policy = doc(json!({
1229            "Statement": [{
1230                "Effect": "Allow",
1231                "NotAction": "s3:DeleteObject",
1232                "Resource": "*"
1233            }]
1234        }));
1235        // Allowed because GetObject is not in NotAction.
1236        assert_eq!(
1237            evaluate(
1238                &[policy.clone()],
1239                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
1240            ),
1241            Decision::Allow
1242        );
1243        // Implicit-denied because DeleteObject is in NotAction (no allow matches).
1244        assert_eq!(
1245            evaluate(
1246                &[policy],
1247                &req(&p, "s3:DeleteObject", "arn:aws:s3:::bucket/key")
1248            ),
1249            Decision::ImplicitDeny
1250        );
1251    }
1252
1253    #[test]
1254    fn not_resource_excludes_listed_resources() {
1255        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1256        let policy = doc(json!({
1257            "Statement": [{
1258                "Effect": "Allow",
1259                "Action": "s3:GetObject",
1260                "NotResource": "arn:aws:s3:::secret-bucket/*"
1261            }]
1262        }));
1263        assert_eq!(
1264            evaluate(
1265                &[policy.clone()],
1266                &req(&p, "s3:GetObject", "arn:aws:s3:::public-bucket/key")
1267            ),
1268            Decision::Allow
1269        );
1270        assert_eq!(
1271            evaluate(
1272                &[policy],
1273                &req(&p, "s3:GetObject", "arn:aws:s3:::secret-bucket/key")
1274            ),
1275            Decision::ImplicitDeny
1276        );
1277    }
1278
1279    fn req_with_ctx<'a>(
1280        principal: &'a Principal,
1281        action: &str,
1282        resource: &str,
1283        context: RequestContext,
1284    ) -> EvalRequest<'a> {
1285        EvalRequest {
1286            principal,
1287            action: action.to_string(),
1288            resource: resource.to_string(),
1289            context,
1290        }
1291    }
1292
1293    fn ctx_alice() -> RequestContext {
1294        RequestContext {
1295            aws_username: Some("alice".into()),
1296            aws_principal_arn: Some("arn:aws:iam::123456789012:user/alice".into()),
1297            aws_principal_account: Some("123456789012".into()),
1298            aws_principal_type: Some("User".into()),
1299            aws_userid: Some("AIDA".into()),
1300            ..Default::default()
1301        }
1302    }
1303
1304    #[test]
1305    fn condition_string_equals_username_allows_match() {
1306        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1307        let policy = doc(json!({
1308            "Statement": [{
1309                "Effect": "Allow",
1310                "Action": "*",
1311                "Resource": "*",
1312                "Condition": { "StringEquals": { "aws:username": "alice" } }
1313            }]
1314        }));
1315        assert_eq!(
1316            evaluate(
1317                &[policy],
1318                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
1319            ),
1320            Decision::Allow
1321        );
1322    }
1323
1324    #[test]
1325    fn condition_string_equals_username_denies_mismatch() {
1326        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1327        let policy = doc(json!({
1328            "Statement": [{
1329                "Effect": "Allow",
1330                "Action": "*",
1331                "Resource": "*",
1332                "Condition": { "StringEquals": { "aws:username": "bob" } }
1333            }]
1334        }));
1335        assert_eq!(
1336            evaluate(
1337                &[policy],
1338                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
1339            ),
1340            Decision::ImplicitDeny
1341        );
1342    }
1343
1344    #[test]
1345    fn deny_with_condition_fires_when_condition_matches() {
1346        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1347        // Deny-MFA-absent + unconditional Allow => the Deny only fires
1348        // when the SecureTransport context value is false. Deny precedence
1349        // beats the unconditional Allow.
1350        let policy = doc(json!({
1351            "Statement": [
1352                {
1353                    "Effect": "Deny",
1354                    "Action": "*",
1355                    "Resource": "*",
1356                    "Condition": { "Bool": { "aws:SecureTransport": "false" } }
1357                },
1358                {
1359                    "Effect": "Allow",
1360                    "Action": "s3:GetObject",
1361                    "Resource": "*"
1362                }
1363            ]
1364        }));
1365        let mut ctx = ctx_alice();
1366        ctx.aws_secure_transport = Some(false);
1367        assert_eq!(
1368            evaluate(
1369                &[policy.clone()],
1370                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx)
1371            ),
1372            Decision::ExplicitDeny
1373        );
1374        // When the request IS secure, the conditional Deny should not
1375        // fire and the Allow wins.
1376        let mut ctx_secure = ctx_alice();
1377        ctx_secure.aws_secure_transport = Some(true);
1378        assert_eq!(
1379            evaluate(
1380                &[policy],
1381                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_secure)
1382            ),
1383            Decision::Allow
1384        );
1385    }
1386
1387    #[test]
1388    fn condition_ip_address_allows_within_cidr() {
1389        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1390        let policy = doc(json!({
1391            "Statement": [{
1392                "Effect": "Allow",
1393                "Action": "s3:GetObject",
1394                "Resource": "*",
1395                "Condition": { "IpAddress": { "aws:SourceIp": "10.0.0.0/24" } }
1396            }]
1397        }));
1398        let mut ctx = ctx_alice();
1399        ctx.aws_source_ip = Some("10.0.0.17".parse().unwrap());
1400        assert_eq!(
1401            evaluate(
1402                &[policy.clone()],
1403                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx)
1404            ),
1405            Decision::Allow
1406        );
1407        let mut wrong = ctx_alice();
1408        wrong.aws_source_ip = Some("192.168.1.1".parse().unwrap());
1409        assert_eq!(
1410            evaluate(
1411                &[policy],
1412                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", wrong)
1413            ),
1414            Decision::ImplicitDeny
1415        );
1416    }
1417
1418    #[test]
1419    fn condition_date_less_than_blocks_expired() {
1420        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1421        let policy = doc(json!({
1422            "Statement": [{
1423                "Effect": "Allow",
1424                "Action": "s3:GetObject",
1425                "Resource": "*",
1426                "Condition": {
1427                    "DateLessThan": { "aws:CurrentTime": "2020-01-01T00:00:00Z" }
1428                }
1429            }]
1430        }));
1431        let mut ctx = ctx_alice();
1432        ctx.aws_current_time = Some(
1433            chrono::DateTime::parse_from_rfc3339("2024-06-15T12:00:00Z")
1434                .unwrap()
1435                .with_timezone(&chrono::Utc),
1436        );
1437        assert_eq!(
1438            evaluate(
1439                &[policy],
1440                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx)
1441            ),
1442            Decision::ImplicitDeny
1443        );
1444    }
1445
1446    #[test]
1447    fn condition_missing_key_without_if_exists_denies() {
1448        // Context has no SourceIp; the IpAddress operator should
1449        // safe-fail, making the statement not apply.
1450        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1451        let policy = doc(json!({
1452            "Statement": [{
1453                "Effect": "Allow",
1454                "Action": "*",
1455                "Resource": "*",
1456                "Condition": { "IpAddress": { "aws:SourceIp": "10.0.0.0/8" } }
1457            }]
1458        }));
1459        assert_eq!(
1460            evaluate(
1461                &[policy],
1462                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
1463            ),
1464            Decision::ImplicitDeny
1465        );
1466    }
1467
1468    #[test]
1469    fn condition_if_exists_passes_on_missing_key() {
1470        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1471        let policy = doc(json!({
1472            "Statement": [{
1473                "Effect": "Allow",
1474                "Action": "*",
1475                "Resource": "*",
1476                "Condition": {
1477                    "IpAddressIfExists": { "aws:SourceIp": "10.0.0.0/8" }
1478                }
1479            }]
1480        }));
1481        // SourceIp not populated; IfExists => condition passes.
1482        assert_eq!(
1483            evaluate(
1484                &[policy],
1485                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
1486            ),
1487            Decision::Allow
1488        );
1489    }
1490
1491    #[test]
1492    fn condition_multiple_operators_all_must_match() {
1493        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1494        let policy = doc(json!({
1495            "Statement": [{
1496                "Effect": "Allow",
1497                "Action": "*",
1498                "Resource": "*",
1499                "Condition": {
1500                    "StringEquals": { "aws:username": "alice" },
1501                    "IpAddress":    { "aws:SourceIp": "10.0.0.0/24" }
1502                }
1503            }]
1504        }));
1505        let mut ctx = ctx_alice();
1506        ctx.aws_source_ip = Some("10.0.0.1".parse().unwrap());
1507        assert_eq!(
1508            evaluate(
1509                &[policy.clone()],
1510                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx)
1511            ),
1512            Decision::Allow
1513        );
1514        let mut wrong_ip = ctx_alice();
1515        wrong_ip.aws_source_ip = Some("192.168.1.1".parse().unwrap());
1516        assert_eq!(
1517            evaluate(
1518                &[policy],
1519                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", wrong_ip)
1520            ),
1521            Decision::ImplicitDeny
1522        );
1523    }
1524
1525    #[test]
1526    fn condition_unknown_operator_fails_closed() {
1527        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1528        let policy = doc(json!({
1529            "Statement": [{
1530                "Effect": "Allow",
1531                "Action": "*",
1532                "Resource": "*",
1533                "Condition": { "NotARealOperator": { "aws:username": "alice" } }
1534            }]
1535        }));
1536        assert_eq!(
1537            evaluate(
1538                &[policy],
1539                &req_with_ctx(&p, "s3:GetObject", "arn:aws:s3:::bucket/key", ctx_alice())
1540            ),
1541            Decision::ImplicitDeny
1542        );
1543    }
1544
1545    #[test]
1546    fn array_action_matches_any_entry() {
1547        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1548        let policy = doc(json!({
1549            "Statement": [{
1550                "Effect": "Allow",
1551                "Action": ["s3:GetObject", "s3:PutObject"],
1552                "Resource": "*"
1553            }]
1554        }));
1555        assert_eq!(
1556            evaluate(
1557                &[policy.clone()],
1558                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
1559            ),
1560            Decision::Allow
1561        );
1562        assert_eq!(
1563            evaluate(
1564                &[policy],
1565                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
1566            ),
1567            Decision::Allow
1568        );
1569    }
1570
1571    #[test]
1572    fn statement_without_effect_is_dropped() {
1573        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1574        let policy = doc(json!({
1575            "Statement": [
1576                { "Action": "s3:GetObject", "Resource": "*" },
1577                { "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*" }
1578            ]
1579        }));
1580        // The dropped statement doesn't contribute, but the second
1581        // valid one still grants the request.
1582        assert_eq!(policy.statement_count(), 1);
1583        assert_eq!(
1584            evaluate(
1585                &[policy],
1586                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
1587            ),
1588            Decision::Allow
1589        );
1590    }
1591
1592    #[test]
1593    fn statement_without_action_is_dropped() {
1594        let policy = doc(json!({
1595            "Statement": [{ "Effect": "Allow", "Resource": "*" }]
1596        }));
1597        assert_eq!(policy.statement_count(), 0);
1598    }
1599
1600    #[test]
1601    fn implicit_resource_acts_like_wildcard() {
1602        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1603        let policy = doc(json!({
1604            "Statement": [{ "Effect": "Allow", "Action": "s3:GetObject" }]
1605        }));
1606        assert_eq!(
1607            evaluate(
1608                &[policy],
1609                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
1610            ),
1611            Decision::Allow
1612        );
1613    }
1614
1615    #[test]
1616    fn malformed_policy_json_is_implicit_deny() {
1617        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1618        let policy = PolicyDocument::parse("{ this is not valid json");
1619        assert_eq!(policy.statement_count(), 0);
1620        assert_eq!(
1621            evaluate(
1622                &[policy],
1623                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
1624            ),
1625            Decision::ImplicitDeny
1626        );
1627    }
1628
1629    #[test]
1630    fn deny_short_circuits_after_match() {
1631        let p = principal_user("arn:aws:iam::123456789012:user/alice");
1632        let policy = doc(json!({
1633            "Statement": [
1634                { "Effect": "Deny", "Action": "*", "Resource": "*" },
1635                { "Effect": "Allow", "Action": "s3:GetObject", "Resource": "*" }
1636            ]
1637        }));
1638        assert_eq!(
1639            evaluate(
1640                &[policy],
1641                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
1642            ),
1643            Decision::ExplicitDeny
1644        );
1645    }
1646
1647    #[test]
1648    fn user_name_from_arn_strips_iam_path() {
1649        // Default path — bare user name.
1650        assert_eq!(
1651            user_name_from_arn("arn:aws:iam::123456789012:user/alice"),
1652            Some("alice")
1653        );
1654        // Non-default path — must return the bare name, not
1655        // `engineering/alice`. IamState indexes users by the bare name,
1656        // so returning the path would silently drop pathed users from
1657        // policy evaluation (identified by cubic on PR #392).
1658        assert_eq!(
1659            user_name_from_arn("arn:aws:iam::123456789012:user/engineering/alice"),
1660            Some("alice")
1661        );
1662        assert_eq!(
1663            user_name_from_arn("arn:aws:iam::123456789012:user/path/to/alice"),
1664            Some("alice")
1665        );
1666        assert_eq!(user_name_from_arn("arn:aws:iam::123456789012:role/r"), None);
1667    }
1668
1669    #[test]
1670    fn collect_identity_policies_resolves_pathed_user() {
1671        // Regression guard for the pathed-user bug: a user created under
1672        // `/engineering/` must still have their inline policies picked up
1673        // by the evaluator.
1674        use crate::state::IamUser;
1675        use chrono::Utc;
1676        let mut state = IamState::new("123456789012");
1677        state.users.insert(
1678            "alice".to_string(),
1679            IamUser {
1680                user_name: "alice".into(),
1681                user_id: "AIDAALICE".into(),
1682                arn: "arn:aws:iam::123456789012:user/engineering/alice".into(),
1683                path: "/engineering/".into(),
1684                created_at: Utc::now(),
1685                tags: Vec::new(),
1686                permissions_boundary: None,
1687            },
1688        );
1689        let mut inline = std::collections::HashMap::new();
1690        inline.insert(
1691            "AllowGet".to_string(),
1692            r#"{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#
1693                .to_string(),
1694        );
1695        state
1696            .user_inline_policies
1697            .insert("alice".to_string(), inline);
1698
1699        let principal = Principal {
1700            arn: "arn:aws:iam::123456789012:user/engineering/alice".to_string(),
1701            user_id: "AIDAALICE".to_string(),
1702            account_id: "123456789012".to_string(),
1703            principal_type: PrincipalType::User,
1704            source_identity: None,
1705            tags: None,
1706        };
1707        let docs = collect_identity_policies(&state, &principal);
1708        assert_eq!(docs.len(), 1, "pathed user's inline policy was missed");
1709        assert_eq!(
1710            evaluate(
1711                &docs,
1712                &req(&principal, "s3:GetObject", "arn:aws:s3:::bucket/key")
1713            ),
1714            Decision::Allow
1715        );
1716    }
1717
1718    #[test]
1719    fn role_name_from_assumed_role_arn_strips_session() {
1720        assert_eq!(
1721            role_name_from_assumed_role_arn("arn:aws:sts::123456789012:assumed-role/ops/session-1"),
1722            Some("ops")
1723        );
1724    }
1725
1726    // --- collect_identity_policies --------------------------------------
1727
1728    #[test]
1729    fn collect_identity_policies_picks_up_user_inline() {
1730        use crate::state::IamUser;
1731        use chrono::Utc;
1732        let mut state = IamState::new("123456789012");
1733        state.users.insert(
1734            "alice".to_string(),
1735            IamUser {
1736                user_name: "alice".into(),
1737                user_id: "AIDAALICE".into(),
1738                arn: "arn:aws:iam::123456789012:user/alice".into(),
1739                path: "/".into(),
1740                created_at: Utc::now(),
1741                tags: Vec::new(),
1742                permissions_boundary: None,
1743            },
1744        );
1745        let mut inline = std::collections::HashMap::new();
1746        inline.insert(
1747            "AllowGet".to_string(),
1748            r#"{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#
1749                .to_string(),
1750        );
1751        state
1752            .user_inline_policies
1753            .insert("alice".to_string(), inline);
1754
1755        let principal = principal_user("arn:aws:iam::123456789012:user/alice");
1756        let docs = collect_identity_policies(&state, &principal);
1757        assert_eq!(docs.len(), 1);
1758        assert_eq!(
1759            evaluate(
1760                &docs,
1761                &req(&principal, "s3:GetObject", "arn:aws:s3:::bucket/key")
1762            ),
1763            Decision::Allow
1764        );
1765    }
1766
1767    #[test]
1768    fn collect_identity_policies_picks_up_managed_via_groups() {
1769        use crate::state::{IamGroup, IamPolicy, IamUser, PolicyVersion};
1770        use chrono::Utc;
1771        let mut state = IamState::new("123456789012");
1772        state.users.insert(
1773            "alice".to_string(),
1774            IamUser {
1775                user_name: "alice".into(),
1776                user_id: "AIDAALICE".into(),
1777                arn: "arn:aws:iam::123456789012:user/alice".into(),
1778                path: "/".into(),
1779                created_at: Utc::now(),
1780                tags: Vec::new(),
1781                permissions_boundary: None,
1782            },
1783        );
1784        let policy_arn = "arn:aws:iam::123456789012:policy/AllowGet".to_string();
1785        state.policies.insert(
1786            policy_arn.clone(),
1787            IamPolicy {
1788                policy_name: "AllowGet".into(),
1789                policy_id: "ANPA1".into(),
1790                arn: policy_arn.clone(),
1791                path: "/".into(),
1792                description: "".into(),
1793                created_at: Utc::now(),
1794                tags: Vec::new(),
1795                default_version_id: "v1".into(),
1796                versions: vec![PolicyVersion {
1797                    version_id: "v1".into(),
1798                    document: r#"{"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}"#.into(),
1799                    is_default: true,
1800                    created_at: Utc::now(),
1801                }],
1802                next_version_num: 2,
1803                attachment_count: 1,
1804            },
1805        );
1806        state.groups.insert(
1807            "readers".to_string(),
1808            IamGroup {
1809                group_name: "readers".into(),
1810                group_id: "AGPA1".into(),
1811                arn: "arn:aws:iam::123456789012:group/readers".into(),
1812                path: "/".into(),
1813                created_at: Utc::now(),
1814                members: vec!["alice".into()],
1815                inline_policies: std::collections::HashMap::new(),
1816                attached_policies: vec![policy_arn],
1817            },
1818        );
1819        let principal = principal_user("arn:aws:iam::123456789012:user/alice");
1820        let docs = collect_identity_policies(&state, &principal);
1821        assert_eq!(docs.len(), 1);
1822        assert_eq!(
1823            evaluate(
1824                &docs,
1825                &req(&principal, "s3:GetObject", "arn:aws:s3:::bucket/key")
1826            ),
1827            Decision::Allow
1828        );
1829    }
1830
1831    #[test]
1832    fn collect_identity_policies_for_root_returns_empty() {
1833        let state = IamState::new("123456789012");
1834        let principal = Principal {
1835            arn: "arn:aws:iam::123456789012:root".into(),
1836            user_id: "ROOT".into(),
1837            account_id: "123456789012".into(),
1838            principal_type: PrincipalType::Root,
1839            source_identity: None,
1840            tags: None,
1841        };
1842        // Root short-circuits via Principal::is_root in dispatch; here we
1843        // just assert collect_identity_policies doesn't synthesize a
1844        // wildcard allow on its behalf.
1845        assert!(collect_identity_policies(&state, &principal).is_empty());
1846    }
1847
1848    // --- resource-policy cross-account evaluation -----------------------
1849
1850    const ACCT_A: &str = "111111111111";
1851    const ACCT_B: &str = "222222222222";
1852
1853    fn principal_in(account: &str, user: &str) -> Principal {
1854        Principal {
1855            arn: format!("arn:aws:iam::{account}:user/{user}"),
1856            user_id: format!("AIDA{user}"),
1857            account_id: account.into(),
1858            principal_type: PrincipalType::User,
1859            source_identity: None,
1860            tags: None,
1861        }
1862    }
1863
1864    fn assumed_role_principal(account: &str, role_arn_tail: &str) -> Principal {
1865        Principal {
1866            arn: format!("arn:aws:sts::{account}:assumed-role/{role_arn_tail}"),
1867            user_id: "AROAEXAMPLE".into(),
1868            account_id: account.into(),
1869            principal_type: PrincipalType::AssumedRole,
1870            source_identity: None,
1871            tags: None,
1872        }
1873    }
1874
1875    fn eval_cross(
1876        identity: Option<serde_json::Value>,
1877        resource: Option<serde_json::Value>,
1878        principal: &Principal,
1879        resource_account_id: &str,
1880    ) -> Decision {
1881        let identity_docs: Vec<PolicyDocument> = identity.into_iter().map(doc).collect();
1882        let resource_doc = resource.map(doc);
1883        let request = req(principal, "s3:GetObject", "arn:aws:s3:::bucket/key");
1884        evaluate_with_resource_policy(
1885            &identity_docs,
1886            resource_doc.as_ref(),
1887            &request,
1888            resource_account_id,
1889        )
1890    }
1891
1892    fn allow_get_wildcard() -> serde_json::Value {
1893        json!({"Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]})
1894    }
1895
1896    fn deny_get_wildcard() -> serde_json::Value {
1897        json!({"Statement":[{"Effect":"Deny","Action":"s3:GetObject","Resource":"*"}]})
1898    }
1899
1900    fn resource_allow_for(principal_arn: &str) -> serde_json::Value {
1901        json!({
1902            "Statement": [{
1903                "Effect": "Allow",
1904                "Principal": {"AWS": principal_arn},
1905                "Action": "s3:GetObject",
1906                "Resource": "arn:aws:s3:::bucket/key"
1907            }]
1908        })
1909    }
1910
1911    #[test]
1912    fn same_account_identity_only_allow() {
1913        let p = principal_in(ACCT_A, "alice");
1914        assert_eq!(
1915            eval_cross(Some(allow_get_wildcard()), None, &p, ACCT_A),
1916            Decision::Allow
1917        );
1918    }
1919
1920    #[test]
1921    fn same_account_resource_only_allow_via_user_arn() {
1922        let p = principal_in(ACCT_A, "alice");
1923        let resource = resource_allow_for(&p.arn);
1924        assert_eq!(
1925            eval_cross(None, Some(resource), &p, ACCT_A),
1926            Decision::Allow
1927        );
1928    }
1929
1930    #[test]
1931    fn same_account_both_allow() {
1932        let p = principal_in(ACCT_A, "alice");
1933        assert_eq!(
1934            eval_cross(
1935                Some(allow_get_wildcard()),
1936                Some(resource_allow_for(&p.arn)),
1937                &p,
1938                ACCT_A,
1939            ),
1940            Decision::Allow
1941        );
1942    }
1943
1944    #[test]
1945    fn same_account_neither_allows_is_implicit_deny() {
1946        let p = principal_in(ACCT_A, "alice");
1947        assert_eq!(eval_cross(None, None, &p, ACCT_A), Decision::ImplicitDeny);
1948    }
1949
1950    #[test]
1951    fn identity_deny_blocks_resource_allow() {
1952        let p = principal_in(ACCT_A, "alice");
1953        let resource = resource_allow_for(&p.arn);
1954        assert_eq!(
1955            eval_cross(Some(deny_get_wildcard()), Some(resource), &p, ACCT_A),
1956            Decision::ExplicitDeny
1957        );
1958    }
1959
1960    #[test]
1961    fn resource_deny_blocks_identity_allow() {
1962        let p = principal_in(ACCT_A, "alice");
1963        let resource_deny = json!({
1964            "Statement": [{
1965                "Effect": "Deny",
1966                "Principal": "*",
1967                "Action": "s3:GetObject",
1968                "Resource": "*"
1969            }]
1970        });
1971        assert_eq!(
1972            eval_cross(Some(allow_get_wildcard()), Some(resource_deny), &p, ACCT_A,),
1973            Decision::ExplicitDeny
1974        );
1975    }
1976
1977    #[test]
1978    fn cross_account_identity_only_is_implicit_deny() {
1979        // Resource lives in B, principal in A. Identity grants, resource
1980        // policy silent -> cross-account semantics require both.
1981        let p = principal_in(ACCT_A, "alice");
1982        assert_eq!(
1983            eval_cross(Some(allow_get_wildcard()), None, &p, ACCT_B),
1984            Decision::ImplicitDeny
1985        );
1986    }
1987
1988    #[test]
1989    fn cross_account_resource_only_is_implicit_deny() {
1990        // Resource lives in B and grants via its policy; principal in A
1991        // has no identity policy → cross-account requires identity too.
1992        let p = principal_in(ACCT_A, "alice");
1993        let resource = resource_allow_for(&p.arn);
1994        assert_eq!(
1995            eval_cross(None, Some(resource), &p, ACCT_B),
1996            Decision::ImplicitDeny
1997        );
1998    }
1999
2000    #[test]
2001    fn cross_account_both_allow_succeeds() {
2002        let p = principal_in(ACCT_A, "alice");
2003        let resource = resource_allow_for(&p.arn);
2004        assert_eq!(
2005            eval_cross(Some(allow_get_wildcard()), Some(resource), &p, ACCT_B),
2006            Decision::Allow
2007        );
2008    }
2009
2010    #[test]
2011    fn principal_wildcard_star_matches_any_principal() {
2012        let p = principal_in(ACCT_A, "alice");
2013        let resource = json!({
2014            "Statement": [{
2015                "Effect": "Allow",
2016                "Principal": "*",
2017                "Action": "s3:GetObject",
2018                "Resource": "*"
2019            }]
2020        });
2021        assert_eq!(
2022            eval_cross(None, Some(resource), &p, ACCT_A),
2023            Decision::Allow
2024        );
2025    }
2026
2027    #[test]
2028    fn principal_aws_star_matches_any_principal() {
2029        let p = principal_in(ACCT_A, "alice");
2030        let resource = json!({
2031            "Statement": [{
2032                "Effect": "Allow",
2033                "Principal": {"AWS": "*"},
2034                "Action": "s3:GetObject",
2035                "Resource": "*"
2036            }]
2037        });
2038        assert_eq!(
2039            eval_cross(None, Some(resource), &p, ACCT_A),
2040            Decision::Allow
2041        );
2042    }
2043
2044    #[test]
2045    fn principal_account_root_matches_any_user_in_account() {
2046        let p = principal_in(ACCT_A, "alice");
2047        let resource = resource_allow_for("arn:aws:iam::111111111111:root");
2048        assert_eq!(
2049            eval_cross(None, Some(resource), &p, ACCT_A),
2050            Decision::Allow
2051        );
2052    }
2053
2054    #[test]
2055    fn principal_account_root_does_not_match_other_account() {
2056        let p = principal_in(ACCT_A, "alice");
2057        let resource = resource_allow_for("arn:aws:iam::222222222222:root");
2058        assert_eq!(
2059            eval_cross(None, Some(resource), &p, ACCT_A),
2060            Decision::ImplicitDeny
2061        );
2062    }
2063
2064    #[test]
2065    fn principal_user_arn_exact_match() {
2066        let p = principal_in(ACCT_A, "alice");
2067        let resource = resource_allow_for("arn:aws:iam::111111111111:user/alice");
2068        assert_eq!(
2069            eval_cross(None, Some(resource), &p, ACCT_A),
2070            Decision::Allow
2071        );
2072    }
2073
2074    #[test]
2075    fn principal_user_arn_mismatch_is_deny() {
2076        let p = principal_in(ACCT_A, "alice");
2077        let resource = resource_allow_for("arn:aws:iam::111111111111:user/bob");
2078        assert_eq!(
2079            eval_cross(None, Some(resource), &p, ACCT_A),
2080            Decision::ImplicitDeny
2081        );
2082    }
2083
2084    #[test]
2085    fn principal_service_matches_assumed_role_containing_service_host() {
2086        let p = assumed_role_principal(
2087            ACCT_A,
2088            "AWSServiceRoleForLambda.lambda.amazonaws.com/session",
2089        );
2090        let resource = json!({
2091            "Statement": [{
2092                "Effect": "Allow",
2093                "Principal": {"Service": "lambda.amazonaws.com"},
2094                "Action": "s3:GetObject",
2095                "Resource": "*"
2096            }]
2097        });
2098        assert_eq!(
2099            eval_cross(None, Some(resource), &p, ACCT_A),
2100            Decision::Allow
2101        );
2102    }
2103
2104    #[test]
2105    fn principal_service_does_not_match_unrelated_user() {
2106        let p = principal_in(ACCT_A, "alice");
2107        let resource = json!({
2108            "Statement": [{
2109                "Effect": "Allow",
2110                "Principal": {"Service": "lambda.amazonaws.com"},
2111                "Action": "s3:GetObject",
2112                "Resource": "*"
2113            }]
2114        });
2115        assert_eq!(
2116            eval_cross(None, Some(resource), &p, ACCT_A),
2117            Decision::ImplicitDeny
2118        );
2119    }
2120
2121    #[test]
2122    fn not_principal_deny_excludes_named_user() {
2123        // NotPrincipal + Deny: deny everyone EXCEPT bob.
2124        // Alice is not bob -> deny applies -> ExplicitDeny.
2125        let alice = principal_in(ACCT_A, "alice");
2126        let resource = json!({
2127            "Statement": [
2128                {
2129                    "Effect": "Allow",
2130                    "Principal": "*",
2131                    "Action": "s3:GetObject",
2132                    "Resource": "*"
2133                },
2134                {
2135                    "Effect": "Deny",
2136                    "NotPrincipal": {"AWS": format!("arn:aws:iam::{ACCT_A}:user/bob")},
2137                    "Action": "s3:GetObject",
2138                    "Resource": "*"
2139                }
2140            ]
2141        });
2142        assert_eq!(
2143            eval_cross(None, Some(resource.clone()), &alice, ACCT_A),
2144            Decision::ExplicitDeny
2145        );
2146
2147        // Bob IS the named principal -> deny does NOT apply -> Allow from first statement.
2148        let bob = principal_in(ACCT_A, "bob");
2149        assert_eq!(
2150            eval_cross(None, Some(resource), &bob, ACCT_A),
2151            Decision::Allow
2152        );
2153    }
2154
2155    #[test]
2156    fn not_principal_allow_excludes_named_user() {
2157        // NotPrincipal + Allow: allow everyone EXCEPT bob.
2158        // Alice is not bob -> allow applies.
2159        let alice = principal_in(ACCT_A, "alice");
2160        let resource = json!({
2161            "Statement": [{
2162                "Effect": "Allow",
2163                "NotPrincipal": {"AWS": format!("arn:aws:iam::{ACCT_A}:user/bob")},
2164                "Action": "s3:GetObject",
2165                "Resource": "*"
2166            }]
2167        });
2168        assert_eq!(
2169            eval_cross(None, Some(resource.clone()), &alice, ACCT_A),
2170            Decision::Allow
2171        );
2172
2173        // Bob IS the named principal -> allow does NOT apply -> ImplicitDeny.
2174        let bob = principal_in(ACCT_A, "bob");
2175        assert_eq!(
2176            eval_cross(None, Some(resource), &bob, ACCT_A),
2177            Decision::ImplicitDeny
2178        );
2179    }
2180
2181    #[test]
2182    fn not_principal_with_star_never_applies() {
2183        // NotPrincipal: "*" matches everyone, so the statement never applies.
2184        let alice = principal_in(ACCT_A, "alice");
2185        let resource = json!({
2186            "Statement": [{
2187                "Effect": "Allow",
2188                "NotPrincipal": "*",
2189                "Action": "s3:GetObject",
2190                "Resource": "*"
2191            }]
2192        });
2193        assert_eq!(
2194            eval_cross(None, Some(resource), &alice, ACCT_A),
2195            Decision::ImplicitDeny
2196        );
2197    }
2198
2199    #[test]
2200    fn not_principal_with_account_root() {
2201        // NotPrincipal names account root. AwsAccountRoot matches
2202        // any principal in that account, so alice (in ACCT_A) matches
2203        // the NotPrincipal list and the statement does NOT apply.
2204        let alice = principal_in(ACCT_A, "alice");
2205        let resource = json!({
2206            "Statement": [{
2207                "Effect": "Allow",
2208                "NotPrincipal": {"AWS": format!("arn:aws:iam::{ACCT_A}:root")},
2209                "Action": "s3:GetObject",
2210                "Resource": "*"
2211            }]
2212        });
2213        assert_eq!(
2214            eval_cross(None, Some(resource.clone()), &alice, ACCT_A),
2215            Decision::ImplicitDeny
2216        );
2217
2218        // A user in a DIFFERENT account does NOT match ACCT_A root,
2219        // so the Deny statement applies. With a Deny+NotPrincipal pattern
2220        // this means the cross-account user gets denied.
2221        let eve = principal_in(ACCT_B, "eve");
2222        let resource_deny = json!({
2223            "Statement": [
2224                {
2225                    "Effect": "Allow",
2226                    "Principal": "*",
2227                    "Action": "s3:GetObject",
2228                    "Resource": "*"
2229                },
2230                {
2231                    "Effect": "Deny",
2232                    "NotPrincipal": {"AWS": format!("arn:aws:iam::{ACCT_A}:root")},
2233                    "Action": "s3:GetObject",
2234                    "Resource": "*"
2235                }
2236            ]
2237        });
2238        // Eve (ACCT_B) doesn't match ACCT_A root, so Deny applies.
2239        assert_eq!(
2240            eval_cross(None, Some(resource_deny.clone()), &eve, ACCT_A),
2241            Decision::ExplicitDeny
2242        );
2243        // Alice (ACCT_A) matches ACCT_A root, so Deny does NOT apply -> Allow.
2244        assert_eq!(
2245            eval_cross(None, Some(resource_deny), &alice, ACCT_A),
2246            Decision::Allow
2247        );
2248    }
2249
2250    #[test]
2251    fn not_principal_with_unrecognized_type_safe_skips() {
2252        // NotPrincipal with only Federated type (unrecognized) ->
2253        // empty refs list -> statement skipped safely.
2254        let alice = principal_in(ACCT_A, "alice");
2255        let resource = json!({
2256            "Statement": [{
2257                "Effect": "Allow",
2258                "NotPrincipal": {"Federated": "cognito-identity.amazonaws.com"},
2259                "Action": "s3:GetObject",
2260                "Resource": "*"
2261            }]
2262        });
2263        assert_eq!(
2264            eval_cross(None, Some(resource), &alice, ACCT_A),
2265            Decision::ImplicitDeny
2266        );
2267    }
2268
2269    #[test]
2270    fn not_principal_with_multiple_entries() {
2271        // NotPrincipal with multiple users. Statement applies only
2272        // to callers matching NONE of the entries.
2273        let alice = principal_in(ACCT_A, "alice");
2274        let bob = principal_in(ACCT_A, "bob");
2275        let charlie = principal_in(ACCT_A, "charlie");
2276        let resource = json!({
2277            "Statement": [{
2278                "Effect": "Deny",
2279                "NotPrincipal": {"AWS": [
2280                    format!("arn:aws:iam::{ACCT_A}:user/alice"),
2281                    format!("arn:aws:iam::{ACCT_A}:user/bob")
2282                ]},
2283                "Action": "s3:GetObject",
2284                "Resource": "*"
2285            }]
2286        });
2287        // Alice and bob are in the list -> deny does NOT apply
2288        assert_eq!(
2289            eval_cross(None, Some(resource.clone()), &alice, ACCT_A),
2290            Decision::ImplicitDeny
2291        );
2292        assert_eq!(
2293            eval_cross(None, Some(resource.clone()), &bob, ACCT_A),
2294            Decision::ImplicitDeny
2295        );
2296        // Charlie is NOT in the list -> deny applies
2297        assert_eq!(
2298            eval_cross(None, Some(resource), &charlie, ACCT_A),
2299            Decision::ExplicitDeny
2300        );
2301    }
2302
2303    #[test]
2304    fn resource_policy_statement_without_principal_is_skipped() {
2305        // Malformed resource policy (missing Principal entirely) must
2306        // not silently grant to everyone.
2307        let p = principal_in(ACCT_A, "alice");
2308        let resource = json!({
2309            "Statement": [{
2310                "Effect": "Allow",
2311                "Action": "s3:GetObject",
2312                "Resource": "*"
2313            }]
2314        });
2315        assert_eq!(
2316            eval_cross(None, Some(resource), &p, ACCT_A),
2317            Decision::ImplicitDeny
2318        );
2319    }
2320
2321    #[test]
2322    fn resource_policy_condition_block_gates_access() {
2323        // Regression guard: Phase 2 condition evaluation still applies
2324        // to resource-policy statements.
2325        use crate::condition::ConditionContext;
2326        use std::net::IpAddr;
2327
2328        let p = principal_in(ACCT_A, "alice");
2329        let resource = json!({
2330            "Statement": [{
2331                "Effect": "Allow",
2332                "Principal": "*",
2333                "Action": "s3:GetObject",
2334                "Resource": "*",
2335                "Condition": {
2336                    "IpAddress": {"aws:SourceIp": "10.0.0.0/8"}
2337                }
2338            }]
2339        });
2340        let resource_doc = doc(resource);
2341
2342        let ctx_ok = ConditionContext {
2343            aws_source_ip: Some("10.1.2.3".parse::<IpAddr>().unwrap()),
2344            ..ConditionContext::default()
2345        };
2346        let req_ok = EvalRequest {
2347            principal: &p,
2348            action: "s3:GetObject".to_string(),
2349            resource: "arn:aws:s3:::bucket/key".to_string(),
2350            context: ctx_ok,
2351        };
2352        assert_eq!(
2353            evaluate_with_resource_policy(&[], Some(&resource_doc), &req_ok, ACCT_A),
2354            Decision::Allow
2355        );
2356
2357        let ctx_bad = ConditionContext {
2358            aws_source_ip: Some("8.8.8.8".parse::<IpAddr>().unwrap()),
2359            ..ConditionContext::default()
2360        };
2361        let req_bad = EvalRequest {
2362            principal: &p,
2363            action: "s3:GetObject".to_string(),
2364            resource: "arn:aws:s3:::bucket/key".to_string(),
2365            context: ctx_bad,
2366        };
2367        assert_eq!(
2368            evaluate_with_resource_policy(&[], Some(&resource_doc), &req_bad, ACCT_A),
2369            Decision::ImplicitDeny
2370        );
2371    }
2372
2373    #[test]
2374    fn classify_aws_principal_recognizes_bare_account_id() {
2375        assert_eq!(
2376            classify_aws_principal("111111111111"),
2377            PrincipalRef::AwsAccountRoot("111111111111".to_string())
2378        );
2379    }
2380
2381    #[test]
2382    fn classify_aws_principal_recognizes_root_arn() {
2383        assert_eq!(
2384            classify_aws_principal("arn:aws:iam::111111111111:root"),
2385            PrincipalRef::AwsAccountRoot("111111111111".to_string())
2386        );
2387    }
2388
2389    #[test]
2390    fn classify_aws_principal_keeps_user_arn_as_arn() {
2391        assert_eq!(
2392            classify_aws_principal("arn:aws:iam::111111111111:user/alice"),
2393            PrincipalRef::AwsArn("arn:aws:iam::111111111111:user/alice".to_string())
2394        );
2395    }
2396
2397    // --- evaluate_with_gates (Phase 3) ---------------------------------
2398
2399    fn allow_all() -> PolicyDocument {
2400        doc(json!({
2401            "Statement": [{
2402                "Effect": "Allow",
2403                "Action": "*",
2404                "Resource": "*"
2405            }]
2406        }))
2407    }
2408
2409    fn allow_get_object() -> PolicyDocument {
2410        doc(json!({
2411            "Statement": [{
2412                "Effect": "Allow",
2413                "Action": "s3:GetObject",
2414                "Resource": "*"
2415            }]
2416        }))
2417    }
2418
2419    fn deny_put_object() -> PolicyDocument {
2420        doc(json!({
2421            "Statement": [{
2422                "Effect": "Deny",
2423                "Action": "s3:PutObject",
2424                "Resource": "*"
2425            }]
2426        }))
2427    }
2428
2429    #[test]
2430    fn gates_absent_behaves_like_phase2_allow() {
2431        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2432        let identity = [allow_all()];
2433        assert_eq!(
2434            evaluate_with_gates(
2435                &identity,
2436                None,
2437                None,
2438                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
2439            ),
2440            Decision::Allow
2441        );
2442    }
2443
2444    #[test]
2445    fn gates_absent_behaves_like_phase2_implicit_deny() {
2446        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2447        assert_eq!(
2448            evaluate_with_gates(
2449                &[],
2450                None,
2451                None,
2452                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
2453            ),
2454            Decision::ImplicitDeny
2455        );
2456    }
2457
2458    #[test]
2459    fn boundary_caps_identity_allow() {
2460        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2461        let identity = [allow_all()];
2462        let boundary = [allow_get_object()];
2463        // Action covered by both identity and boundary → Allow.
2464        assert_eq!(
2465            evaluate_with_gates(
2466                &identity,
2467                Some(&boundary),
2468                None,
2469                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
2470            ),
2471            Decision::Allow
2472        );
2473        // Action covered by identity but not boundary → ImplicitDeny.
2474        assert_eq!(
2475            evaluate_with_gates(
2476                &identity,
2477                Some(&boundary),
2478                None,
2479                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
2480            ),
2481            Decision::ImplicitDeny
2482        );
2483    }
2484
2485    #[test]
2486    fn empty_boundary_denies_everything() {
2487        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2488        let identity = [allow_all()];
2489        let boundary: [PolicyDocument; 0] = [];
2490        // Dangling / unresolved boundary ARN → caller passes Some(&[])
2491        // which must deny everything.
2492        assert_eq!(
2493            evaluate_with_gates(
2494                &identity,
2495                Some(&boundary),
2496                None,
2497                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
2498            ),
2499            Decision::ImplicitDeny
2500        );
2501    }
2502
2503    #[test]
2504    fn explicit_deny_in_boundary_wins() {
2505        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2506        let identity = [allow_all()];
2507        let boundary = [deny_put_object()];
2508        assert_eq!(
2509            evaluate_with_gates(
2510                &identity,
2511                Some(&boundary),
2512                None,
2513                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
2514            ),
2515            Decision::ExplicitDeny
2516        );
2517    }
2518
2519    #[test]
2520    fn identity_implicit_with_boundary_allow_is_implicit_deny() {
2521        // Boundary doesn't grant — only caps. If identity is silent,
2522        // the request must still deny.
2523        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2524        let boundary = [allow_all()];
2525        assert_eq!(
2526            evaluate_with_gates(
2527                &[],
2528                Some(&boundary),
2529                None,
2530                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
2531            ),
2532            Decision::ImplicitDeny
2533        );
2534    }
2535
2536    #[test]
2537    fn session_policy_caps_identity_allow() {
2538        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2539        let identity = [allow_all()];
2540        let session = [allow_get_object()];
2541        assert_eq!(
2542            evaluate_with_gates(
2543                &identity,
2544                None,
2545                Some(&session),
2546                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
2547            ),
2548            Decision::ImplicitDeny
2549        );
2550        assert_eq!(
2551            evaluate_with_gates(
2552                &identity,
2553                None,
2554                Some(&session),
2555                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
2556            ),
2557            Decision::Allow
2558        );
2559    }
2560
2561    #[test]
2562    fn session_policy_explicit_deny_wins() {
2563        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2564        let identity = [allow_all()];
2565        let session = [deny_put_object()];
2566        assert_eq!(
2567            evaluate_with_gates(
2568                &identity,
2569                None,
2570                Some(&session),
2571                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
2572            ),
2573            Decision::ExplicitDeny
2574        );
2575    }
2576
2577    #[test]
2578    fn boundary_and_session_must_both_allow() {
2579        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2580        let identity = [allow_all()];
2581        let boundary = [allow_all()];
2582        let session = [allow_get_object()];
2583        // Session caps to GetObject only.
2584        assert_eq!(
2585            evaluate_with_gates(
2586                &identity,
2587                Some(&boundary),
2588                Some(&session),
2589                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key")
2590            ),
2591            Decision::ImplicitDeny
2592        );
2593        assert_eq!(
2594            evaluate_with_gates(
2595                &identity,
2596                Some(&boundary),
2597                Some(&session),
2598                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key")
2599            ),
2600            Decision::Allow
2601        );
2602    }
2603
2604    // --- evaluate_with_resource_policy_and_gates -----------------------
2605
2606    #[test]
2607    fn resource_policy_gated_same_account_resource_bypasses_boundary() {
2608        // Same-account grant via a resource policy does NOT need the
2609        // identity side (or the boundary/session gates) to allow —
2610        // resource policies in the same account stand on their own.
2611        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2612        let identity: [PolicyDocument; 0] = [];
2613        let boundary: [PolicyDocument; 0] = []; // deny-all boundary
2614        let resource = doc(json!({
2615            "Statement": [{
2616                "Effect": "Allow",
2617                "Principal": {"AWS": "arn:aws:iam::123456789012:user/alice"},
2618                "Action": "s3:GetObject",
2619                "Resource": "arn:aws:s3:::bucket/key"
2620            }]
2621        }));
2622        assert_eq!(
2623            evaluate_with_resource_policy_and_gates(
2624                &identity,
2625                Some(&boundary),
2626                None,
2627                Some(&resource),
2628                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
2629                "123456789012"
2630            ),
2631            Decision::Allow
2632        );
2633    }
2634
2635    #[test]
2636    fn resource_policy_gated_cross_account_identity_must_allow() {
2637        // Cross-account: identity AND resource must both allow. Even
2638        // with a resource-policy grant, if identity is implicit-deny
2639        // the call is denied.
2640        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2641        let identity: [PolicyDocument; 0] = [];
2642        let resource = doc(json!({
2643            "Statement": [{
2644                "Effect": "Allow",
2645                "Principal": "*",
2646                "Action": "s3:GetObject",
2647                "Resource": "arn:aws:s3:::bucket/key"
2648            }]
2649        }));
2650        assert_eq!(
2651            evaluate_with_resource_policy_and_gates(
2652                &identity,
2653                None,
2654                None,
2655                Some(&resource),
2656                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
2657                "999999999999"
2658            ),
2659            Decision::ImplicitDeny
2660        );
2661    }
2662
2663    #[test]
2664    fn resource_policy_gated_cross_account_boundary_caps_identity_side() {
2665        // Cross-account, identity allows, resource allows, but the
2666        // caller's boundary is empty (deny-all) → identity side is
2667        // gated to ImplicitDeny and the AND denies the call.
2668        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2669        let identity = [allow_all()];
2670        let boundary: [PolicyDocument; 0] = [];
2671        let resource = doc(json!({
2672            "Statement": [{
2673                "Effect": "Allow",
2674                "Principal": "*",
2675                "Action": "s3:GetObject",
2676                "Resource": "arn:aws:s3:::bucket/key"
2677            }]
2678        }));
2679        assert_eq!(
2680            evaluate_with_resource_policy_and_gates(
2681                &identity,
2682                Some(&boundary),
2683                None,
2684                Some(&resource),
2685                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
2686                "999999999999"
2687            ),
2688            Decision::ImplicitDeny
2689        );
2690    }
2691
2692    #[test]
2693    fn resource_policy_gated_explicit_deny_in_session_wins() {
2694        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2695        let identity = [allow_all()];
2696        let session = [deny_put_object()];
2697        let resource = doc(json!({
2698            "Statement": [{
2699                "Effect": "Allow",
2700                "Principal": "*",
2701                "Action": "s3:PutObject",
2702                "Resource": "arn:aws:s3:::bucket/*"
2703            }]
2704        }));
2705        assert_eq!(
2706            evaluate_with_resource_policy_and_gates(
2707                &identity,
2708                None,
2709                Some(&session),
2710                Some(&resource),
2711                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
2712                "123456789012"
2713            ),
2714            Decision::ExplicitDeny
2715        );
2716    }
2717
2718    // --- Batch 4: SCP ceiling layer -------------------------------------
2719
2720    #[test]
2721    fn scp_caps_identity_allow_all() {
2722        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2723        let identity = [allow_all()];
2724        let scps = [allow_get_object()];
2725        assert_eq!(
2726            evaluate_with_gates_and_scps(
2727                &identity,
2728                None,
2729                None,
2730                Some(&scps),
2731                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
2732            ),
2733            Decision::Allow
2734        );
2735        assert_eq!(
2736            evaluate_with_gates_and_scps(
2737                &identity,
2738                None,
2739                None,
2740                Some(&scps),
2741                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
2742            ),
2743            Decision::ImplicitDeny
2744        );
2745    }
2746
2747    #[test]
2748    fn scp_explicit_deny_wins() {
2749        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2750        let identity = [allow_all()];
2751        let scps = [deny_put_object()];
2752        assert_eq!(
2753            evaluate_with_gates_and_scps(
2754                &identity,
2755                None,
2756                None,
2757                Some(&scps),
2758                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
2759            ),
2760            Decision::ExplicitDeny
2761        );
2762    }
2763
2764    #[test]
2765    fn scp_empty_chain_denies_everything() {
2766        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2767        let identity = [allow_all()];
2768        let scps: [PolicyDocument; 0] = [];
2769        // Some(&[]) means the org applies but no SCP allow-all reaches
2770        // the account path (e.g. FullAWSAccess detached and nothing
2771        // else attached). Deny-by-default.
2772        assert_eq!(
2773            evaluate_with_gates_and_scps(
2774                &identity,
2775                None,
2776                None,
2777                Some(&scps),
2778                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
2779            ),
2780            Decision::ImplicitDeny
2781        );
2782    }
2783
2784    #[test]
2785    fn scp_none_preserves_identity_only_decision() {
2786        // None = off-by-default. Evaluation must match the no-SCP
2787        // path bit-for-bit, preserving the zero-behavior-change
2788        // contract when no organization exists.
2789        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2790        let identity = [allow_all()];
2791        let with_scps = evaluate_with_gates_and_scps(
2792            &identity,
2793            None,
2794            None,
2795            None,
2796            &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
2797        );
2798        let without = evaluate_with_gates(
2799            &identity,
2800            None,
2801            None,
2802            &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
2803        );
2804        assert_eq!(with_scps, without);
2805        assert_eq!(with_scps, Decision::Allow);
2806    }
2807
2808    #[test]
2809    fn scp_chain_intersects_across_ancestors() {
2810        // Two SCPs up the path: outer Allow *, inner Allow only
2811        // s3:GetObject. AWS intersects — action must be in every one.
2812        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2813        let identity = [allow_all()];
2814        let scps = [allow_all(), allow_get_object()];
2815        assert_eq!(
2816            evaluate_with_gates_and_scps(
2817                &identity,
2818                None,
2819                None,
2820                Some(&scps),
2821                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
2822            ),
2823            Decision::Allow
2824        );
2825        assert_eq!(
2826            evaluate_with_gates_and_scps(
2827                &identity,
2828                None,
2829                None,
2830                Some(&scps),
2831                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
2832            ),
2833            Decision::ImplicitDeny
2834        );
2835    }
2836
2837    #[test]
2838    fn scp_intersects_with_boundary_and_session() {
2839        let p = principal_user("arn:aws:iam::123456789012:user/alice");
2840        let identity = [allow_all()];
2841        let boundary = [allow_all()];
2842        let session = [allow_all()];
2843        let scps = [allow_get_object()];
2844        assert_eq!(
2845            evaluate_with_gates_and_scps(
2846                &identity,
2847                Some(&boundary),
2848                Some(&session),
2849                Some(&scps),
2850                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
2851            ),
2852            Decision::ImplicitDeny
2853        );
2854        assert_eq!(
2855            evaluate_with_gates_and_scps(
2856                &identity,
2857                Some(&boundary),
2858                Some(&session),
2859                Some(&scps),
2860                &req(&p, "s3:GetObject", "arn:aws:s3:::bucket/key"),
2861            ),
2862            Decision::Allow
2863        );
2864    }
2865
2866    #[test]
2867    fn scp_caps_identity_side_of_resource_policy() {
2868        // Cross-account resource policy grants PutObject; caller's SCP
2869        // allows only GetObject. Identity side is gated by SCP →
2870        // cross-account AND means the whole thing denies.
2871        let p = principal_user("arn:aws:iam::111111111111:user/alice");
2872        let identity = [allow_all()];
2873        let resource = doc(serde_json::json!({
2874            "Statement": [{
2875                "Effect": "Allow",
2876                "Principal": "*",
2877                "Action": "s3:PutObject",
2878                "Resource": "arn:aws:s3:::bucket/*"
2879            }]
2880        }));
2881        let scps = [allow_get_object()];
2882        assert_eq!(
2883            evaluate_with_resource_policy_and_gates_and_scps(
2884                &identity,
2885                None,
2886                None,
2887                Some(&scps),
2888                Some(&resource),
2889                &req(&p, "s3:PutObject", "arn:aws:s3:::bucket/key"),
2890                "222222222222",
2891            ),
2892            Decision::ImplicitDeny
2893        );
2894    }
2895}