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