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    /// `"Principal": {"Federated": "arn:aws:iam::ACCOUNT:saml-provider/Idp"}`
161    /// or `{"Federated": "accounts.google.com"}` /
162    /// `{"Federated": "cognito-identity.amazonaws.com"}`. Matches a
163    /// federated principal whose ARN equals the named SAML/OIDC
164    /// provider — STS sets the principal ARN to the provider when
165    /// minting the trust-policy evaluation request for
166    /// AssumeRoleWithSAML / AssumeRoleWithWebIdentity.
167    Federated(String),
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub(crate) enum Effect {
172    Allow,
173    Deny,
174}
175
176/// Action / NotAction patterns. `Allow` lists are positive matches;
177/// `Deny` lists are negative matches (NotAction).
178#[derive(Debug, Clone)]
179pub(crate) enum ActionMatch {
180    Action(Vec<String>),
181    NotAction(Vec<String>),
182}
183
184/// Resource / NotResource patterns.
185#[derive(Debug, Clone)]
186pub(crate) enum ResourceMatch {
187    Resource(Vec<String>),
188    NotResource(Vec<String>),
189    /// Statement omitted both `Resource` and `NotResource`. AWS treats
190    /// this as "applies to all resources" only inside trust policies; for
191    /// identity policies it's a validation error. We treat missing as
192    /// wildcard-all to match how some Terraform-generated policies look
193    /// in practice, but the evaluator never silently grants more than
194    /// the policy text actually says — this maps to the same behavior
195    /// as `Resource: ["*"]`.
196    Implicit,
197}
198
199/// Parsed policy document — only the fields the evaluator needs. Any
200/// statement that fails to parse (wrong shape, unknown effect, etc.) is
201/// dropped with a warn-level log and the rest of the document is still
202/// usable, matching how AWS behaves with invalid statements (the broken
203/// statement is ignored, not the whole policy).
204#[derive(Debug, Clone, Default)]
205pub struct PolicyDocument {
206    pub(crate) statements: Vec<ParsedStatement>,
207}
208
209impl PolicyDocument {
210    /// Parse a policy document from its JSON string form. Returns an
211    /// empty document on JSON errors so the caller can fall through to
212    /// implicit-deny rather than panicking on malformed state.
213    pub fn parse(json: &str) -> Self {
214        let value: Value = match serde_json::from_str(json) {
215            Ok(v) => v,
216            Err(e) => {
217                tracing::warn!(error = %e, "failed to parse policy document JSON; ignoring");
218                return Self::default();
219            }
220        };
221        Self::from_value(&value)
222    }
223
224    /// Parse a policy document from a `serde_json::Value`. Used by both
225    /// [`PolicyDocument::parse`] and tests that build inline `serde_json!`
226    /// values.
227    pub fn from_value(value: &Value) -> Self {
228        let statements = match value.get("Statement") {
229            Some(Value::Array(arr)) => arr.iter().filter_map(parse_statement).collect::<Vec<_>>(),
230            Some(obj @ Value::Object(_)) => parse_statement(obj).into_iter().collect(),
231            _ => Vec::new(),
232        };
233        Self { statements }
234    }
235
236    /// Number of parsed statements in this document. Used by tests as a
237    /// proxy for "did this statement parse successfully?" without exposing
238    /// the internal representation.
239    pub fn statement_count(&self) -> usize {
240        self.statements.len()
241    }
242}
243
244fn parse_statement(value: &Value) -> Option<ParsedStatement> {
245    let obj = value.as_object()?;
246    let effect = match obj.get("Effect")?.as_str()? {
247        "Allow" => Effect::Allow,
248        "Deny" => Effect::Deny,
249        other => {
250            tracing::warn!(effect = other, "unknown Effect; ignoring statement");
251            return None;
252        }
253    };
254    let action = if let Some(a) = obj.get("Action") {
255        ActionMatch::Action(coerce_string_list(a))
256    } else if let Some(na) = obj.get("NotAction") {
257        ActionMatch::NotAction(coerce_string_list(na))
258    } else {
259        tracing::warn!("statement has no Action or NotAction; ignoring");
260        return None;
261    };
262    let resource = if let Some(r) = obj.get("Resource") {
263        ResourceMatch::Resource(coerce_string_list(r))
264    } else if let Some(nr) = obj.get("NotResource") {
265        ResourceMatch::NotResource(coerce_string_list(nr))
266    } else {
267        ResourceMatch::Implicit
268    };
269    let condition = obj.get("Condition").map(CompiledCondition::parse);
270    let principal = if let Some(np) = obj.get("NotPrincipal") {
271        PrincipalPattern::NotPrincipal(parse_principal(np))
272    } else if let Some(p) = obj.get("Principal") {
273        PrincipalPattern::Principal(parse_principal(p))
274    } else {
275        PrincipalPattern::None
276    };
277    Some(ParsedStatement {
278        effect,
279        action,
280        resource,
281        condition,
282        principal,
283    })
284}
285
286/// Parse a `Principal` JSON value into the list of refs the evaluator
287/// can match against a request principal.
288///
289/// AWS accepts any of:
290/// - `"Principal": "*"`
291/// - `"Principal": {"AWS": "*"}` or `{"AWS": ["..."]}`
292/// - `"Principal": {"Service": "lambda.amazonaws.com"}` (string or array)
293/// - `"Principal": {"Federated": "..."}` (matched via [`principal_is_federated`])
294/// - `"Principal": {"CanonicalUser": "..."}` (unhandled — warn log, drop)
295///
296/// Unknown shapes fall through to an empty ref list, which the matcher
297/// treats as "doesn't match" — never silently grant. The drop is logged at
298/// `warn` so callers can see when their policy uses an unsupported
299/// principal type rather than discovering the silent skip in production.
300fn parse_principal(value: &Value) -> Vec<PrincipalRef> {
301    let mut out = Vec::new();
302    match value {
303        Value::String(s) if s == "*" => out.push(PrincipalRef::AnyAws),
304        Value::String(other) => {
305            tracing::warn!(
306                target: "fakecloud::iam::audit",
307                principal = %other,
308                "Principal string other than \"*\" is not a recognized shape; statement will not match"
309            );
310        }
311        Value::Object(map) => {
312            for (key, v) in map {
313                match key.as_str() {
314                    "AWS" => {
315                        for s in coerce_string_list(v) {
316                            out.push(classify_aws_principal(&s));
317                        }
318                    }
319                    "Service" => {
320                        for s in coerce_string_list(v) {
321                            out.push(PrincipalRef::Service(s));
322                        }
323                    }
324                    "Federated" => {
325                        for s in coerce_string_list(v) {
326                            out.push(PrincipalRef::Federated(s));
327                        }
328                    }
329                    other => {
330                        tracing::warn!(
331                            target: "fakecloud::iam::audit",
332                            principal_type = %other,
333                            "Principal type not recognized; entries dropped — statement \
334                             will not match unless other Principal entries cover the caller"
335                        );
336                    }
337                }
338            }
339        }
340        _ => {
341            tracing::warn!(
342                target: "fakecloud::iam::audit",
343                "Principal has an unexpected JSON shape; statement will not match"
344            );
345        }
346    }
347    out
348}
349
350fn classify_aws_principal(s: &str) -> PrincipalRef {
351    if s == "*" {
352        return PrincipalRef::AnyAws;
353    }
354    // `arn:aws:iam::<account>:root` → account root
355    if let Some(rest) = s.strip_prefix("arn:aws:iam::") {
356        if let Some((account, tail)) = rest.split_once(':') {
357            if tail == "root" && !account.is_empty() {
358                return PrincipalRef::AwsAccountRoot(account.to_string());
359            }
360        }
361    }
362    // A bare 12-digit account ID is shorthand for `<account>:root`.
363    if s.len() == 12 && s.chars().all(|c| c.is_ascii_digit()) {
364        return PrincipalRef::AwsAccountRoot(s.to_string());
365    }
366    PrincipalRef::AwsArn(s.to_string())
367}
368
369/// Coerce a JSON value into a list of strings. AWS policy schema accepts
370/// either a single string or an array of strings for `Action`/`Resource`.
371/// Non-string entries are dropped.
372fn coerce_string_list(value: &Value) -> Vec<String> {
373    match value {
374        Value::String(s) => vec![s.clone()],
375        Value::Array(arr) => arr
376            .iter()
377            .filter_map(|v| v.as_str().map(|s| s.to_string()))
378            .collect(),
379        _ => Vec::new(),
380    }
381}
382
383/// Evaluate a request against a set of policy documents.
384///
385/// Implements AWS's standard identity-policy evaluation logic for Phase 1
386/// features only. See the module-level docstring for the exhaustive list
387/// of what is and isn't covered.
388///
389/// # Algorithm
390///
391/// 1. Walk every statement in every policy.
392/// 2. For each statement that matches the request's action *and* resource:
393///    - If the statement has a `Condition` block, evaluate it against
394///      [`EvalRequest::context`]; skip the statement if the condition
395///      does not match.
396///    - If `Effect: Deny` → return [`Decision::ExplicitDeny`] immediately.
397///    - If `Effect: Allow` → record that we saw an allow.
398/// 3. After all statements are scanned: return [`Decision::Allow`] if any
399///    allow matched, otherwise [`Decision::ImplicitDeny`].
400pub fn evaluate(policies: &[PolicyDocument], request: &EvalRequest<'_>) -> Decision {
401    evaluate_with_gates(policies, None, None, request)
402}
403
404/// Evaluate `request` against a single resource-style policy in
405/// isolation — no identity-side gating. Use this for trust policies
406/// (the only thing that gates `sts:AssumeRole`) and any other
407/// scenario where the policy itself is the sole authorization source
408/// and `Principal` matching is meaningful.
409pub fn evaluate_resource_policy_only(
410    policy: &PolicyDocument,
411    request: &EvalRequest<'_>,
412) -> Decision {
413    evaluate_inner(std::slice::from_ref(policy), request, true)
414}
415
416/// Evaluate `request` against a principal's identity policies plus
417/// optional permission-boundary, session-policy, and SCP layers.
418///
419/// Intersection semantics (applies identically to every gate):
420///
421/// - `boundary = None` / `session = None` / `scps = None` → the layer
422///   is absent and does not gate the decision (pass-through).
423/// - `Some(&[])` → the layer is present but empty, which evaluates to
424///   `ImplicitDeny` and therefore denies the request. This is how
425///   dangling boundary ARNs, empty session policies, and empty SCP
426///   sets (e.g. every policy detached from a target) are represented.
427/// - Any layer returning `ExplicitDeny` wins immediately (Deny
428///   precedence applies across layers, not just within one).
429/// - Otherwise the request is allowed iff **every present layer**
430///   evaluates to `Allow`. A layer with `ImplicitDeny` caps the
431///   intersection to `ImplicitDeny`.
432///
433/// When `scps` is `Some`, each document in the slice is treated as a
434/// separate gate that must allow — the caller already assembled the
435/// ordered list (root OU first, account-direct last) via
436/// [`crate::scp_resolver`] or equivalent.
437pub fn evaluate_with_gates(
438    identity: &[PolicyDocument],
439    boundary: Option<&[PolicyDocument]>,
440    session: Option<&[PolicyDocument]>,
441    request: &EvalRequest<'_>,
442) -> Decision {
443    evaluate_with_gates_and_scps(identity, boundary, session, None, request)
444}
445
446/// Full-chain variant of [`evaluate_with_gates`] that also applies an
447/// SCP ceiling. See the top-of-module docs for the intersection
448/// semantics. Batch 4 added this alongside the 4-arg form so existing
449/// callers (and tests) don't have to thread an extra `None` through
450/// every evaluation site.
451pub fn evaluate_with_gates_and_scps(
452    identity: &[PolicyDocument],
453    boundary: Option<&[PolicyDocument]>,
454    session: Option<&[PolicyDocument]>,
455    scps: Option<&[PolicyDocument]>,
456    request: &EvalRequest<'_>,
457) -> Decision {
458    let identity_decision = evaluate_inner(identity, request, false);
459    intersect_layers(identity_decision, boundary, session, scps, request)
460}
461
462/// Combine an already-computed identity-side decision with the optional
463/// boundary, session-policy, and SCP layers. Factored out so the
464/// resource-policy variant can apply the same intersection to the
465/// identity side before OR/ANDing with the resource-policy side.
466fn intersect_layers(
467    identity_decision: Decision,
468    boundary: Option<&[PolicyDocument]>,
469    session: Option<&[PolicyDocument]>,
470    scps: Option<&[PolicyDocument]>,
471    request: &EvalRequest<'_>,
472) -> Decision {
473    if matches!(identity_decision, Decision::ExplicitDeny) {
474        return Decision::ExplicitDeny;
475    }
476    // SCP gate sits at the top of the ceiling stack. Each SCP
477    // document is a separate layer that must allow (AWS intersects
478    // SCPs across the OU path). A single explicit Deny in any SCP
479    // short-circuits the evaluation.
480    let scp_decision = scps.map(|docs| evaluate_scp_chain(docs, request));
481    if matches!(scp_decision, Some(Decision::ExplicitDeny)) {
482        if let Some(scps_slice) = scps {
483            tracing::debug!(
484                target: "fakecloud::iam::audit",
485                action = %request.action,
486                principal_arn = %request.principal.arn,
487                scp_count = scps_slice.len(),
488                "SCP ceiling produced ExplicitDeny"
489            );
490        }
491        return Decision::ExplicitDeny;
492    }
493    let boundary_decision = boundary.map(|policies| evaluate_inner(policies, request, false));
494    if matches!(boundary_decision, Some(Decision::ExplicitDeny)) {
495        return Decision::ExplicitDeny;
496    }
497    let session_decision = session.map(|policies| evaluate_inner(policies, request, false));
498    if matches!(session_decision, Some(Decision::ExplicitDeny)) {
499        return Decision::ExplicitDeny;
500    }
501    // Intersection: every present layer must allow.
502    let identity_allows = matches!(identity_decision, Decision::Allow);
503    let boundary_allows = boundary_decision
504        .map(|d| matches!(d, Decision::Allow))
505        .unwrap_or(true);
506    let session_allows = session_decision
507        .map(|d| matches!(d, Decision::Allow))
508        .unwrap_or(true);
509    let scp_allows = scp_decision
510        .map(|d| matches!(d, Decision::Allow))
511        .unwrap_or(true);
512    if identity_allows && boundary_allows && session_allows && scp_allows {
513        Decision::Allow
514    } else {
515        if scps.is_some() && !scp_allows {
516            tracing::debug!(
517                target: "fakecloud::iam::audit",
518                action = %request.action,
519                principal_arn = %request.principal.arn,
520                "SCP ceiling did not allow action; capped to ImplicitDeny"
521            );
522        }
523        Decision::ImplicitDeny
524    }
525}
526
527/// Walk an ordered SCP chain (root OU -> descendant OUs -> account)
528/// and intersect the per-document decisions. Each document is its own
529/// gate: an explicit Deny anywhere wins, otherwise every document
530/// must evaluate to Allow for the chain to allow.
531fn evaluate_scp_chain(scps: &[PolicyDocument], request: &EvalRequest<'_>) -> Decision {
532    if scps.is_empty() {
533        // `Some(&[])` means the org exists and applies but no SCPs
534        // are attached up the chain. Preserve AWS's deny-by-default
535        // ceiling semantics: nothing allowed.
536        return Decision::ImplicitDeny;
537    }
538    let mut all_allow = true;
539    for doc in scps {
540        match evaluate_inner(std::slice::from_ref(doc), request, false) {
541            Decision::ExplicitDeny => return Decision::ExplicitDeny,
542            Decision::Allow => {}
543            Decision::ImplicitDeny => all_allow = false,
544        }
545    }
546    if all_allow {
547        Decision::Allow
548    } else {
549        Decision::ImplicitDeny
550    }
551}
552
553/// Evaluate `request` against the principal's identity policies and an
554/// optional resource-based policy, combining the two with AWS's
555/// cross-account semantics.
556///
557/// - Either side returning an explicit `Deny` wins immediately.
558/// - Same-account (`principal.account_id == resource_account_id`):
559///   the request is allowed if identity OR resource grants it.
560/// - Cross-account: the request is allowed only if identity AND
561///   resource both grant it.
562///
563/// `resource_account_id` is the 12-digit account that owns the target
564/// resource. For S3 bucket policies, dispatch parses this from the
565/// resource ARN; S3 ARNs have an empty account field, so the caller
566/// is expected to fall back to the server's configured account ID in
567/// that case (#381 multi-account alignment).
568pub fn evaluate_with_resource_policy(
569    identity_policies: &[PolicyDocument],
570    resource_policy: Option<&PolicyDocument>,
571    request: &EvalRequest<'_>,
572    resource_account_id: &str,
573) -> Decision {
574    evaluate_with_resource_policy_and_gates(
575        identity_policies,
576        None,
577        None,
578        resource_policy,
579        request,
580        resource_account_id,
581    )
582}
583
584/// Resource-policy variant of [`evaluate_with_gates`].
585///
586/// The boundary and session policies gate the **identity side** only —
587/// they never apply to the resource-policy branch. Rationale: the
588/// resource policy is evaluated in the resource's account, and a
589/// caller's permission boundary has no authority in another account
590/// (this is also how AWS describes it). That shows up here as two
591/// separate combinators:
592///
593/// - Same-account: `(identity ∩ boundary ∩ session) OR resource`.
594///   Boundary/session cap the identity side, but a resource-policy
595///   grant in the same account still allows the request on its own.
596/// - Cross-account: `(identity ∩ boundary ∩ session) AND resource`.
597///   Both sides must allow; boundary/session still cap the identity
598///   side.
599///
600/// Explicit Deny from any layer — identity, boundary, session, or
601/// resource — wins immediately.
602pub fn evaluate_with_resource_policy_and_gates(
603    identity_policies: &[PolicyDocument],
604    boundary: Option<&[PolicyDocument]>,
605    session: Option<&[PolicyDocument]>,
606    resource_policy: Option<&PolicyDocument>,
607    request: &EvalRequest<'_>,
608    resource_account_id: &str,
609) -> Decision {
610    evaluate_with_resource_policy_and_gates_and_scps(
611        identity_policies,
612        boundary,
613        session,
614        None,
615        resource_policy,
616        request,
617        resource_account_id,
618    )
619}
620
621/// Full-chain variant of
622/// [`evaluate_with_resource_policy_and_gates`] that also applies an
623/// SCP ceiling on the identity side. SCPs never apply to the
624/// resource-policy branch — AWS evaluates the resource policy in the
625/// resource's account, and the caller's SCPs have no authority there.
626pub fn evaluate_with_resource_policy_and_gates_and_scps(
627    identity_policies: &[PolicyDocument],
628    boundary: Option<&[PolicyDocument]>,
629    session: Option<&[PolicyDocument]>,
630    scps: Option<&[PolicyDocument]>,
631    resource_policy: Option<&PolicyDocument>,
632    request: &EvalRequest<'_>,
633    resource_account_id: &str,
634) -> Decision {
635    let identity_raw = evaluate_inner(identity_policies, request, false);
636    if matches!(identity_raw, Decision::ExplicitDeny) {
637        return Decision::ExplicitDeny;
638    }
639    // Apply boundary, session, and SCP gates to the identity side.
640    // SCPs only apply to the identity side (never to the resource
641    // policy branch) — they are the caller-account ceiling, and AWS
642    // evaluates the resource policy in the resource's account.
643    let identity_gated = intersect_layers(identity_raw, boundary, session, scps, request);
644    if matches!(identity_gated, Decision::ExplicitDeny) {
645        return Decision::ExplicitDeny;
646    }
647
648    let same_account = request.principal.account_id == resource_account_id;
649    // Same-account with no resource policy: preserve the identity-only
650    // path so rollouts without a bucket/topic policy behave as before.
651    if resource_policy.is_none() && same_account {
652        return identity_gated;
653    }
654    let resource = match resource_policy {
655        Some(policy) => evaluate_inner(std::slice::from_ref(policy), request, true),
656        None => Decision::ImplicitDeny,
657    };
658    if matches!(resource, Decision::ExplicitDeny) {
659        return Decision::ExplicitDeny;
660    }
661    let identity_allows = matches!(identity_gated, Decision::Allow);
662    let resource_allows = matches!(resource, Decision::Allow);
663    let allowed = if same_account {
664        identity_allows || resource_allows
665    } else {
666        identity_allows && resource_allows
667    };
668    if allowed {
669        Decision::Allow
670    } else {
671        Decision::ImplicitDeny
672    }
673}
674
675fn evaluate_inner(
676    policies: &[PolicyDocument],
677    request: &EvalRequest<'_>,
678    is_resource_policy: bool,
679) -> Decision {
680    let mut allowed = false;
681    for policy in policies {
682        for statement in &policy.statements {
683            // Principal / NotPrincipal gate. Identity policies never
684            // carry these keys; resource policies must, and a
685            // statement without a matching Principal does not apply.
686            match &statement.principal {
687                PrincipalPattern::None => {
688                    if is_resource_policy {
689                        // Resource-policy statement with no Principal
690                        // does not apply — AWS treats this as a
691                        // validation error and we will not silently
692                        // grant.
693                        tracing::debug!(
694                            target: "fakecloud::iam::audit",
695                            action = %request.action,
696                            "resource policy statement has no Principal; skipping"
697                        );
698                        continue;
699                    }
700                }
701                PrincipalPattern::Principal(refs) => {
702                    if !principal_matches(refs, request.principal) {
703                        continue;
704                    }
705                }
706                PrincipalPattern::NotPrincipal(refs) => {
707                    if refs.is_empty() {
708                        tracing::debug!(
709                            target: "fakecloud::iam::audit",
710                            action = %request.action,
711                            "NotPrincipal has no recognized principal types; statement does not apply"
712                        );
713                        continue;
714                    }
715                    // NotPrincipal: statement applies when caller does NOT match any entry.
716                    if principal_matches(refs, request.principal) {
717                        continue;
718                    }
719                }
720            }
721            if !action_matches(&statement.action, &request.action) {
722                continue;
723            }
724            if !resource_matches(&statement.resource, &request.resource) {
725                continue;
726            }
727            if let Some(condition) = &statement.condition {
728                if !condition.matches(&request.context) {
729                    tracing::debug!(
730                        target: "fakecloud::iam::audit",
731                        action = %request.action,
732                        "condition did not match; statement does not apply"
733                    );
734                    continue;
735                }
736            }
737            match statement.effect {
738                Effect::Deny => return Decision::ExplicitDeny,
739                Effect::Allow => allowed = true,
740            }
741        }
742    }
743    if allowed {
744        Decision::Allow
745    } else {
746        Decision::ImplicitDeny
747    }
748}
749
750/// Check whether any entry in a parsed `Principal` list matches the
751/// calling principal. An empty list never matches — that's how we
752/// keep unimplemented principal types (`Federated`, `CanonicalUser`)
753/// from silently granting.
754fn principal_matches(refs: &[PrincipalRef], principal: &Principal) -> bool {
755    refs.iter().any(|r| match r {
756        PrincipalRef::AnyAws => true,
757        PrincipalRef::AwsAccountRoot(account) => &principal.account_id == account,
758        PrincipalRef::AwsArn(arn) => &principal.arn == arn,
759        PrincipalRef::Service(service) => principal_is_service(principal, service),
760        PrincipalRef::Federated(provider) => principal_is_federated(principal, provider),
761    })
762}
763
764/// Match a `"Federated"` principal. STS injects the federated provider
765/// (SAML provider ARN, OIDC issuer URL, or `cognito-identity.amazonaws.com`)
766/// as the principal ARN when evaluating trust policies for
767/// `AssumeRoleWithSAML` / `AssumeRoleWithWebIdentity`. We require the
768/// principal to be of type `FederatedUser` and its ARN to equal the
769/// provider — never silently grant.
770fn principal_is_federated(principal: &Principal, provider: &str) -> bool {
771    matches!(principal.principal_type, PrincipalType::FederatedUser) && principal.arn == provider
772}
773
774/// Approximate match for a `"Service"` principal. AWS represents a
775/// request made by a service (e.g. Lambda invoking something via a
776/// service-linked role) as an assumed-role principal whose role ARN
777/// contains the service host. We match conservatively: the principal
778/// must be an `AssumedRole` whose ARN contains the literal service
779/// host string. False matches are avoided because unrelated role
780/// names would have to happen to contain `lambda.amazonaws.com` —
781/// unlikely in practice and never silently grant to user principals.
782fn principal_is_service(principal: &Principal, service: &str) -> bool {
783    matches!(principal.principal_type, PrincipalType::AssumedRole)
784        && principal.arn.contains(service)
785}
786
787fn action_matches(action: &ActionMatch, request_action: &str) -> bool {
788    match action {
789        ActionMatch::Action(patterns) => patterns
790            .iter()
791            .any(|p| iam_glob_match(p, request_action, true)),
792        ActionMatch::NotAction(patterns) => patterns
793            .iter()
794            .all(|p| !iam_glob_match(p, request_action, true)),
795    }
796}
797
798fn resource_matches(resource: &ResourceMatch, request_resource: &str) -> bool {
799    match resource {
800        ResourceMatch::Resource(patterns) => patterns
801            .iter()
802            .any(|p| iam_glob_match(p, request_resource, false)),
803        ResourceMatch::NotResource(patterns) => patterns
804            .iter()
805            .all(|p| !iam_glob_match(p, request_resource, false)),
806        ResourceMatch::Implicit => true,
807    }
808}
809
810/// IAM-style glob match supporting `*` (any sequence) and `?` (single
811/// character). When `case_insensitive_service_prefix` is true and the
812/// pattern looks like an action (`service:Action`), the service prefix is
813/// matched case-insensitively while the action name is matched as-is —
814/// matches how AWS evaluates Action patterns.
815fn iam_glob_match(pattern: &str, value: &str, case_insensitive_service_prefix: bool) -> bool {
816    if case_insensitive_service_prefix {
817        if let (Some((p_svc, p_act)), Some((v_svc, v_act))) =
818            (pattern.split_once(':'), value.split_once(':'))
819        {
820            if !glob_match(&p_svc.to_ascii_lowercase(), &v_svc.to_ascii_lowercase()) {
821                return false;
822            }
823            return glob_match(p_act, v_act);
824        }
825    }
826    glob_match(pattern, value)
827}
828
829/// Plain glob matcher with `*` (zero or more) and `?` (exactly one).
830/// Iterative two-pointer implementation — runs in `O(pattern.len() *
831/// value.len())` worst case, no backtracking explosions.
832fn glob_match(pattern: &str, value: &str) -> bool {
833    let p: Vec<char> = pattern.chars().collect();
834    let v: Vec<char> = value.chars().collect();
835    let mut pi = 0usize;
836    let mut vi = 0usize;
837    let mut star: Option<usize> = None;
838    let mut star_v: usize = 0;
839    while vi < v.len() {
840        if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
841            pi += 1;
842            vi += 1;
843        } else if pi < p.len() && p[pi] == '*' {
844            star = Some(pi);
845            star_v = vi;
846            pi += 1;
847        } else if let Some(s) = star {
848            pi = s + 1;
849            star_v += 1;
850            vi = star_v;
851        } else {
852            return false;
853        }
854    }
855    while pi < p.len() && p[pi] == '*' {
856        pi += 1;
857    }
858    pi == p.len()
859}
860
861/// Collect every identity policy that should be considered when
862/// evaluating a request from `principal`.
863///
864/// Phase 1 walks identity policies only (user inline + managed, group
865/// inline + managed via membership, role inline + managed). Resource
866/// policies, permission boundaries, and SCPs are not consulted —
867/// see the module-level scope notes.
868///
869/// The returned vector is the **deduplicated** set of policy documents,
870/// parsed and ready to feed into [`evaluate`]. Unknown managed policy
871/// ARNs are skipped with a debug log.
872pub fn collect_identity_policies(state: &IamState, principal: &Principal) -> Vec<PolicyDocument> {
873    let mut docs = Vec::new();
874    let mut seen_managed: HashSet<String> = HashSet::new();
875    match principal.principal_type {
876        PrincipalType::User => {
877            if let Some(user_name) = user_name_from_arn(&principal.arn) {
878                collect_user_policies(state, user_name, &mut docs, &mut seen_managed);
879            }
880        }
881        PrincipalType::AssumedRole => {
882            if let Some(role_name) = role_name_from_assumed_role_arn(&principal.arn) {
883                collect_role_policies(state, role_name, &mut docs, &mut seen_managed);
884            }
885        }
886        PrincipalType::Root => {
887            // Root bypasses evaluation; the caller (dispatch) should
888            // short-circuit via `Principal::is_root` before reaching here.
889            // Returning an empty vec means an explicit `Allow` is required,
890            // which is the safe default if a caller forgets to bypass.
891        }
892        PrincipalType::FederatedUser | PrincipalType::Unknown => {
893            // No identity-policy story for these in Phase 1.
894        }
895    }
896    docs
897}
898
899fn collect_user_policies(
900    state: &IamState,
901    user_name: &str,
902    docs: &mut Vec<PolicyDocument>,
903    seen_managed: &mut HashSet<String>,
904) {
905    if let Some(inline) = state.user_inline_policies.get(user_name) {
906        for doc in inline.values() {
907            docs.push(PolicyDocument::parse(doc));
908        }
909    }
910    if let Some(arns) = state.user_policies.get(user_name) {
911        for arn in arns {
912            if !seen_managed.insert(arn.clone()) {
913                continue;
914            }
915            if let Some(doc) = managed_policy_default_document(state, arn) {
916                docs.push(PolicyDocument::parse(&doc));
917            }
918        }
919    }
920    // Group memberships: walk every group whose members include the user.
921    for (group_name, group) in &state.groups {
922        if !group.members.iter().any(|m| m == user_name) {
923            continue;
924        }
925        for doc in group.inline_policies.values() {
926            docs.push(PolicyDocument::parse(doc));
927        }
928        for arn in &group.attached_policies {
929            if !seen_managed.insert(arn.clone()) {
930                continue;
931            }
932            if let Some(doc) = managed_policy_default_document(state, arn) {
933                docs.push(PolicyDocument::parse(&doc));
934            }
935        }
936        let _ = group_name;
937    }
938}
939
940fn collect_role_policies(
941    state: &IamState,
942    role_name: &str,
943    docs: &mut Vec<PolicyDocument>,
944    seen_managed: &mut HashSet<String>,
945) {
946    if let Some(inline) = state.role_inline_policies.get(role_name) {
947        for doc in inline.values() {
948            docs.push(PolicyDocument::parse(doc));
949        }
950    }
951    if let Some(arns) = state.role_policies.get(role_name) {
952        for arn in arns {
953            if !seen_managed.insert(arn.clone()) {
954                continue;
955            }
956            if let Some(doc) = managed_policy_default_document(state, arn) {
957                docs.push(PolicyDocument::parse(&doc));
958            }
959        }
960    }
961}
962
963/// Look up the permission-boundary policy document attached to
964/// `principal`, if any.
965///
966/// Returns:
967/// - `None` — the principal has no boundary set, OR the principal is
968///   exempt from boundary evaluation (account root, service-linked
969///   role, or an unhandled principal type like a federated user). The
970///   caller should treat this as "boundary layer absent"
971///   (pass-through) when calling [`evaluate_with_gates`].
972/// - `Some(vec![])` — a boundary ARN is set but does not resolve to
973///   a known managed policy (dangling ARN, or the user/role was found
974///   but its boundary points at a deleted policy). The caller must
975///   treat this as **deny-all** — matching AWS's behavior when a
976///   permission boundary is deleted, the principal can no longer
977///   perform any action until the boundary is re-attached or removed.
978///   Emits a `fakecloud::iam::audit` debug log.
979/// - `Some(vec![doc])` — the boundary resolves to a policy document.
980///
981/// Service-linked roles are detected by the `AWSServiceRoleFor` name
982/// prefix (AWS rejects attaching boundaries to SLRs at the API layer
983/// anyway; this is defense-in-depth).
984pub fn collect_boundary_policies(
985    state: &IamState,
986    principal: &Principal,
987) -> Option<Vec<PolicyDocument>> {
988    if principal.is_root() {
989        return None;
990    }
991    let boundary_arn = match principal.principal_type {
992        PrincipalType::User => {
993            let user_name = user_name_from_arn(&principal.arn)?;
994            let user = state.users.get(user_name)?;
995            user.permissions_boundary.clone()?
996        }
997        PrincipalType::AssumedRole => {
998            let role_name = role_name_from_assumed_role_arn(&principal.arn)?;
999            if role_name.starts_with("AWSServiceRoleFor") {
1000                // Service-linked roles are exempt from boundary
1001                // evaluation — AWS rejects attaching one at the API
1002                // layer, but if state has been force-injected we
1003                // still bypass to match documented semantics.
1004                return None;
1005            }
1006            let role = state.roles.get(role_name)?;
1007            role.permissions_boundary.clone()?
1008        }
1009        // No boundary story for root / federated / unknown.
1010        _ => return None,
1011    };
1012    match managed_policy_default_document(state, &boundary_arn) {
1013        Some(doc) => Some(vec![PolicyDocument::parse(&doc)]),
1014        None => {
1015            tracing::debug!(
1016                target: "fakecloud::iam::audit",
1017                principal_arn = %principal.arn,
1018                boundary_arn = %boundary_arn,
1019                "permission boundary ARN does not resolve to a known managed policy; denying all actions"
1020            );
1021            Some(Vec::new())
1022        }
1023    }
1024}
1025
1026fn managed_policy_default_document(state: &IamState, arn: &str) -> Option<String> {
1027    let policy = state.policies.get(arn)?;
1028    policy
1029        .versions
1030        .iter()
1031        .find(|v| v.is_default)
1032        .or_else(|| policy.versions.first())
1033        .map(|v| v.document.clone())
1034}
1035
1036/// Extract the bare `user_name` component from an IAM user ARN.
1037///
1038/// IAM users can be created with a non-default path (e.g. `/engineering/`),
1039/// which produces ARNs of the form
1040/// `arn:aws:iam::123456789012:user/engineering/alice`. `IamState` indexes
1041/// users by the bare name (`alice`), so returning the full
1042/// `engineering/alice` would silently miss the user and make
1043/// `collect_user_policies` return an empty set — the evaluator would then
1044/// issue an incorrect implicit deny for every pathed user.
1045/// (Identified by cubic on PR #392.)
1046fn user_name_from_arn(arn: &str) -> Option<&str> {
1047    let after = arn.rsplit_once(":user/").map(|(_, name)| name)?;
1048    // Bare name is the last segment; the rest is the path.
1049    Some(after.rsplit('/').next().unwrap_or(after))
1050}
1051
1052fn role_name_from_assumed_role_arn(arn: &str) -> Option<&str> {
1053    // `arn:aws:sts::<account>:assumed-role/<role-name>/<session>`
1054    let after = arn.rsplit_once(":assumed-role/")?.1;
1055    Some(after.split('/').next().unwrap_or(after))
1056}
1057
1058#[cfg(test)]
1059#[allow(clippy::cloned_ref_to_slice_refs)]
1060#[path = "evaluator_tests.rs"]
1061mod tests;