From 97014d90db38752ff0c12428d95c9211118a4659 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Mon, 14 Sep 2026 09:49:29 -0300 Subject: [PATCH 1/3] feat(iam): policy variables and ForAllValues over a missing key - Version 2012-10-17 policies expand ${...} policy variables in the resource part of Resource / NotResource and in String* / Arn* condition values: any single-valued context key (aws:username, aws:userid, aws:PrincipalTag/, ...), ${key, 'default'} defaults, and ${*} / ${?} / ${$} for literal characters. Substituted values are literal. A variable with no value matches no resource, fails positive operators and satisfies inverted ones. Other policy versions read ${...} literally. - ForAllValues is true when the request has no value for the key, as AWS documents; it evaluated to false. --- .../tests/iam_enforcement_abac.rs | 73 ++++++ crates/fakecloud-iam/src/condition.rs | 88 ++++++- crates/fakecloud-iam/src/evaluator.rs | 50 ++-- crates/fakecloud-iam/src/evaluator_tests.rs | 190 +++++++++++++++ crates/fakecloud-iam/src/lib.rs | 1 + crates/fakecloud-iam/src/policy_variables.rs | 228 ++++++++++++++++++ website/content/docs/reference/security.md | 3 +- website/content/docs/services/iam.md | 2 +- 8 files changed, 615 insertions(+), 20 deletions(-) create mode 100644 crates/fakecloud-iam/src/policy_variables.rs diff --git a/crates/fakecloud-e2e/tests/iam_enforcement_abac.rs b/crates/fakecloud-e2e/tests/iam_enforcement_abac.rs index a6cccc97c..42a4138ad 100644 --- a/crates/fakecloud-e2e/tests/iam_enforcement_abac.rs +++ b/crates/fakecloud-e2e/tests/iam_enforcement_abac.rs @@ -571,3 +571,76 @@ async fn iam_resource_tag_denies_get_user_without_matching_tag() { "expected AccessDenied for dev-tagged user" ); } + +// ====================================================================== +// Policy variables +// ====================================================================== + +fn s3_for(cfg: &aws_config::SdkConfig) -> S3Client { + S3Client::from_conf( + aws_sdk_s3::config::Builder::from(cfg) + .force_path_style(true) + .build(), + ) +} + +/// `${aws:username}` in a Resource and `${aws:PrincipalTag/team}` in a +/// condition are replaced per caller, so one policy scopes each user to their +/// own prefix and team. +#[tokio::test] +async fn policy_variables_scope_one_policy_per_caller() { + let server = start_strict().await; + let admin = s3_for(&sdk_config_with(&server, "test", "test").await); + admin.create_bucket().bucket("homes").send().await.unwrap(); + for key in [ + "alice/doc.txt", + "bob/doc.txt", + "blue/plan.txt", + "red/plan.txt", + ] { + admin + .put_object() + .bucket("homes") + .key(key) + .body(aws_sdk_s3::primitives::ByteStream::from_static(b"x")) + .send() + .await + .unwrap(); + } + + let policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::homes/${aws:username}/*" + }, + { + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::homes/*", + "Condition": {"StringLike": {"s3:ExistingObjectTag/none": "x"}} + }, + { + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::homes/${aws:PrincipalTag/team, 'no-team'}/*" + } + ] + }) + .to_string(); + let (akid, secret) = bootstrap_tagged_user(&server, "alice", &[("team", "blue")]).await; + attach_inline_policy(&server, "alice", "homes", &policy).await; + let alice = s3_for(&sdk_config_with(&server, &akid, &secret).await); + + let get = |key: &'static str| alice.get_object().bucket("homes").key(key).send(); + get("alice/doc.txt").await.expect("own username prefix"); + get("blue/plan.txt").await.expect("own team prefix"); + let err = get("bob/doc.txt").await.expect_err("another user's prefix"); + assert!(format!("{err:?}").contains("AccessDenied"), "{err:?}"); + let err = get("red/plan.txt") + .await + .expect_err("another team's prefix"); + assert!(format!("{err:?}").contains("AccessDenied"), "{err:?}"); +} diff --git a/crates/fakecloud-iam/src/condition.rs b/crates/fakecloud-iam/src/condition.rs index c2195de63..cfec316c2 100644 --- a/crates/fakecloud-iam/src/condition.rs +++ b/crates/fakecloud-iam/src/condition.rs @@ -201,6 +201,9 @@ pub struct ParsedCondition { pub operator: ParsedOperatorName, pub key: String, pub values: Vec, + /// Whether the policy's language version expands `${...}` policy + /// variables in `values` (`2012-10-17`). + pub policy_variables: bool, } /// A statement's fully-parsed `Condition` block. Multiple entries are @@ -242,6 +245,7 @@ impl CompiledCondition { }, key: format!("__unknown_operator__:{op_name}"), values: Vec::new(), + policy_variables: false, }); continue; }; @@ -254,12 +258,22 @@ impl CompiledCondition { operator, key: key.clone(), values, + policy_variables: false, }); } } out } + /// Mark whether the block's policy expands `${...}` policy variables + /// (only `Version: 2012-10-17` policies do). + pub fn with_policy_variables(mut self, enabled: bool) -> Self { + for entry in &mut self.entries { + entry.policy_variables = enabled; + } + self + } + /// Evaluate this condition block against a [`ConditionContext`]. /// Returns `true` iff every entry matches (AND semantics). pub fn matches(&self, ctx: &ConditionContext) -> bool { @@ -314,9 +328,11 @@ pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool { let context_values = match context_values { Some(vs) if !vs.is_empty() => vs, _ => { - // Key not populated. `IfExists` -> vacuously true. Otherwise - // this is a safe-fail to false. - if entry.operator.if_exists { + // Key not populated. `IfExists` -> vacuously true. So is + // `ForAllValues`: AWS documents it as true when the request has no + // value for the key ("every value matches" holds for none). + // Otherwise this is a safe-fail to false. + if entry.operator.if_exists || entry.operator.qualifier == Qualifier::ForAllValues { return true; } if ctx.lookup(&entry.key).is_none() { @@ -331,6 +347,25 @@ pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool { } }; + if entry.policy_variables + && supports_policy_variables(entry.operator.op) + && entry + .values + .iter() + .any(|v| crate::policy_variables::has_variables(v)) + { + let expanded: Vec>> = entry + .values + .iter() + .map(|v| crate::policy_variables::expand(v, ctx)) + .collect(); + let one = |cv: &String| match_expanded(entry.operator.op, &expanded, cv); + return match entry.operator.qualifier { + Qualifier::Single | Qualifier::ForAnyValue => context_values.iter().any(one), + Qualifier::ForAllValues => context_values.iter().all(one), + }; + } + match entry.operator.qualifier { Qualifier::Single | Qualifier::ForAnyValue => { // ANY context value satisfies the operator against the @@ -349,6 +384,53 @@ pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool { } } +/// Policy variables are expanded only in string and ARN comparisons. +fn supports_policy_variables(op: ConditionOperator) -> bool { + use ConditionOperator::*; + matches!( + op, + StringEquals + | StringNotEquals + | StringEqualsIgnoreCase + | StringNotEqualsIgnoreCase + | StringLike + | StringNotLike + | ArnEquals + | ArnLike + | ArnNotEquals + | ArnNotLike + ) +} + +/// [`match_values`] for policy values with variables expanded. A value whose +/// variable has no value (`None`) is null: no positive operator matches it, +/// and every inverted one does. +fn match_expanded( + op: ConditionOperator, + values: &[Option>], + context_value: &str, +) -> bool { + use crate::policy_variables::{glob as piece_glob, to_text}; + use ConditionOperator::*; + let positive = |pred: &dyn Fn(&[crate::policy_variables::Piece]) -> bool| { + values.iter().any(|v| v.as_deref().is_some_and(pred)) + }; + let inverted = |pred: &dyn Fn(&[crate::policy_variables::Piece]) -> bool| { + values.iter().all(|v| v.as_deref().is_none_or(|p| !pred(p))) + }; + match op { + StringEquals => positive(&|p| to_text(p) == context_value), + StringNotEquals => inverted(&|p| to_text(p) == context_value), + StringEqualsIgnoreCase => positive(&|p| to_text(p).eq_ignore_ascii_case(context_value)), + StringNotEqualsIgnoreCase => inverted(&|p| to_text(p).eq_ignore_ascii_case(context_value)), + StringLike | ArnEquals | ArnLike => positive(&|p| piece_glob(p, context_value, false)), + StringNotLike | ArnNotEquals | ArnNotLike => { + inverted(&|p| piece_glob(p, context_value, false)) + } + _ => false, + } +} + /// `Null` operator: `{ "Null": { "aws:username": "true" } }` -> passes /// iff the key is missing. `"false"` -> passes iff the key is present. fn evaluate_null(entry: &ParsedCondition, ctx: &ConditionContext) -> bool { diff --git a/crates/fakecloud-iam/src/evaluator.rs b/crates/fakecloud-iam/src/evaluator.rs index 791cd38e4..32047033e 100644 --- a/crates/fakecloud-iam/src/evaluator.rs +++ b/crates/fakecloud-iam/src/evaluator.rs @@ -104,6 +104,9 @@ pub(crate) struct ParsedStatement { /// Identity policies always parse as [`PrincipalPattern::None`]; /// resource policies may carry a `Principal` or `NotPrincipal` key. pub principal: PrincipalPattern, + /// Whether the statement's policy is `Version: 2012-10-17`, the only + /// policy language that expands `${...}` policy variables. + pub variables: bool, } /// `Principal` / `NotPrincipal` pattern on a parsed statement. @@ -225,9 +228,13 @@ impl PolicyDocument { /// [`PolicyDocument::parse`] and tests that build inline `serde_json!` /// values. pub fn from_value(value: &Value) -> Self { + let variables = value.get("Version").and_then(Value::as_str) == Some("2012-10-17"); let statements = match value.get("Statement") { - Some(Value::Array(arr)) => arr.iter().filter_map(parse_statement).collect::>(), - Some(obj @ Value::Object(_)) => parse_statement(obj).into_iter().collect(), + Some(Value::Array(arr)) => arr + .iter() + .filter_map(|s| parse_statement(s, variables)) + .collect::>(), + Some(obj @ Value::Object(_)) => parse_statement(obj, variables).into_iter().collect(), _ => Vec::new(), }; Self { statements } @@ -254,7 +261,7 @@ impl PolicyDocument { .filter(|s| matches!(s.principal, PrincipalPattern::None)) .filter(|s| s.effect == want) .filter(|s| action_matches(&s.action, &request.action)) - .filter(|s| resource_matches(&s.resource, &request.resource)) + .filter(|s| resource_matches(s, &request.resource, &request.context)) .filter(|s| { s.condition .as_ref() @@ -264,7 +271,7 @@ impl PolicyDocument { } } -fn parse_statement(value: &Value) -> Option { +fn parse_statement(value: &Value, variables: bool) -> Option { let obj = value.as_object()?; let effect = match obj.get("Effect")?.as_str()? { "Allow" => Effect::Allow, @@ -289,7 +296,9 @@ fn parse_statement(value: &Value) -> Option { } else { ResourceMatch::Implicit }; - let condition = obj.get("Condition").map(CompiledCondition::parse); + let condition = obj + .get("Condition") + .map(|c| CompiledCondition::parse(c).with_policy_variables(variables)); let principal = if let Some(np) = obj.get("NotPrincipal") { PrincipalPattern::NotPrincipal(parse_principal(np)) } else if let Some(p) = obj.get("Principal") { @@ -303,6 +312,7 @@ fn parse_statement(value: &Value) -> Option { resource, condition, principal, + variables, }) } @@ -810,7 +820,7 @@ fn evaluate_inner_scoped( if !action_matches(&statement.action, &request.action) { continue; } - if !resource_matches(&statement.resource, &request.resource) { + if !resource_matches(statement, &request.resource, &request.context) { continue; } if let Some(condition) = &statement.condition { @@ -920,17 +930,27 @@ fn action_matches(action: &ActionMatch, request_action: &str) -> bool { } } -fn resource_matches(resource: &ResourceMatch, request_resource: &str) -> bool { - match resource { - ResourceMatch::Resource(patterns) => patterns - .iter() - .any(|p| iam_glob_match(p, request_resource, false)), +fn resource_matches( + statement: &ParsedStatement, + request_resource: &str, + ctx: &fakecloud_core::auth::ConditionContext, +) -> bool { + // A pattern with a policy variable that has no value matches no + // resource -- in `Resource` and `NotResource` alike. + let pattern_matches = |p: &str| { + if statement.variables && crate::policy_variables::has_variables(p) { + crate::policy_variables::resource_pattern(p, ctx).is_some_and(|pieces| { + crate::policy_variables::glob(&pieces, request_resource, false) + }) + } else { + iam_glob_match(p, request_resource, false) + } + }; + match &statement.resource { + ResourceMatch::Resource(patterns) => patterns.iter().any(|p| pattern_matches(p)), // Empty NotResource matches nothing (see action_matches, 5.2). ResourceMatch::NotResource(patterns) => { - !patterns.is_empty() - && patterns - .iter() - .all(|p| !iam_glob_match(p, request_resource, false)) + !patterns.is_empty() && patterns.iter().all(|p| !pattern_matches(p)) } ResourceMatch::Implicit => true, } diff --git a/crates/fakecloud-iam/src/evaluator_tests.rs b/crates/fakecloud-iam/src/evaluator_tests.rs index fef74f935..ddf3ddc79 100644 --- a/crates/fakecloud-iam/src/evaluator_tests.rs +++ b/crates/fakecloud-iam/src/evaluator_tests.rs @@ -2240,3 +2240,193 @@ fn arn_denotes_service_matches_only_reserved_path() { "ecs.amazonaws.com" )); } + +// --- policy variables ---------------------------------------------------- + +fn alice_request<'a>(principal: &'a Principal, action: &str, resource: &str) -> EvalRequest<'a> { + let mut r = req(principal, action, resource); + r.context.aws_username = Some("alice".to_string()); + r.context.principal_tags = Some(std::collections::HashMap::from([( + "team".to_string(), + "blue".to_string(), + )])); + r +} + +#[test] +fn resource_variables_expand_per_principal() { + let alice = principal_user("arn:aws:iam::123456789012:user/alice"); + let policy = doc(json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::home/${aws:username}/*" + }] + })); + assert_eq!( + evaluate( + &[policy.clone()], + &alice_request(&alice, "s3:GetObject", "arn:aws:s3:::home/alice/notes.txt") + ), + Decision::Allow + ); + assert_eq!( + evaluate( + &[policy], + &alice_request(&alice, "s3:GetObject", "arn:aws:s3:::home/bob/notes.txt") + ), + Decision::ImplicitDeny + ); +} + +/// Only the 2012-10-17 policy language expands variables; older policies +/// read `${...}` literally. +#[test] +fn variables_are_literal_in_older_policy_versions() { + let alice = principal_user("arn:aws:iam::123456789012:user/alice"); + let policy = |version: &str| { + doc(json!({ + "Version": version, + "Statement": [{ + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::home/${aws:username}" + }] + })) + }; + assert_eq!( + evaluate( + &[policy("2008-10-17")], + &alice_request(&alice, "s3:GetObject", "arn:aws:s3:::home/alice") + ), + Decision::ImplicitDeny + ); + assert_eq!( + evaluate( + &[policy("2008-10-17")], + &alice_request(&alice, "s3:GetObject", "arn:aws:s3:::home/${aws:username}") + ), + Decision::Allow + ); +} + +/// A variable with no value matches no resource, fails positive condition +/// operators and satisfies inverted ones; a default fills it in. +#[test] +fn variables_without_a_value_are_null() { + let alice = principal_user("arn:aws:iam::123456789012:user/alice"); + let resource = "arn:aws:s3:::bucket/x"; + let with_condition = |effect: &str, op: &str, value: &str| { + doc(json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": effect, + "Action": "s3:GetObject", + "Resource": "*", + "Condition": {op: {"aws:username": value}} + }] + })) + }; + let run = |policy: PolicyDocument| { + evaluate(&[policy], &alice_request(&alice, "s3:GetObject", resource)) + }; + // Positive operators never match a null value. + assert_eq!( + run(with_condition( + "Allow", + "StringEquals", + "${aws:PrincipalTag/nope}" + )), + Decision::ImplicitDeny + ); + assert_eq!( + run(with_condition( + "Allow", + "StringLike", + "${aws:PrincipalTag/nope}*" + )), + Decision::ImplicitDeny + ); + // Inverted operators match it, so this Deny applies. + assert_eq!( + run(with_condition( + "Deny", + "StringNotEquals", + "${aws:PrincipalTag/nope}" + )), + Decision::ExplicitDeny + ); + // A default stands in for the missing value. + assert_eq!( + run(with_condition( + "Allow", + "StringEquals", + "${aws:PrincipalTag/nope, 'alice'}" + )), + Decision::Allow + ); + // A present value compares normally. + assert_eq!( + run(with_condition("Allow", "StringEquals", "${aws:username}")), + Decision::Allow + ); + + let null_resource = doc(json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::bucket/${aws:PrincipalTag/nope}*" + }] + })); + assert_eq!(run(null_resource), Decision::ImplicitDeny); +} + +/// `ForAllValues` holds when the request has no value for the key: every one +/// of zero values matches. It used to fail the condition instead. +#[test] +fn for_all_values_is_true_when_the_key_is_absent() { + let alice = principal_user("arn:aws:iam::123456789012:user/alice"); + let policy = doc(json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "dynamodb:Scan", + "Resource": "*", + "Condition": {"ForAllValues:StringEquals": {"dynamodb:LeadingKeys": ["alice"]}} + }] + })); + assert_eq!( + evaluate( + &[policy.clone()], + &req( + &alice, + "dynamodb:Scan", + "arn:aws:dynamodb:us-east-1:123456789012:table/T" + ) + ), + Decision::Allow + ); + let for_any = doc(json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "dynamodb:Scan", + "Resource": "*", + "Condition": {"ForAnyValue:StringEquals": {"dynamodb:LeadingKeys": ["alice"]}} + }] + })); + assert_eq!( + evaluate( + &[for_any], + &req( + &alice, + "dynamodb:Scan", + "arn:aws:dynamodb:us-east-1:123456789012:table/T" + ) + ), + Decision::ImplicitDeny, + "ForAnyValue still needs a value" + ); +} diff --git a/crates/fakecloud-iam/src/lib.rs b/crates/fakecloud-iam/src/lib.rs index 3e8670b1b..d93dbb444 100644 --- a/crates/fakecloud-iam/src/lib.rs +++ b/crates/fakecloud-iam/src/lib.rs @@ -8,6 +8,7 @@ pub mod pass_role; pub mod persistence; pub mod policy_evaluator; pub mod policy_validation; +pub(crate) mod policy_variables; pub mod resource_policy; pub(crate) mod state; pub mod sts_service; diff --git a/crates/fakecloud-iam/src/policy_variables.rs b/crates/fakecloud-iam/src/policy_variables.rs new file mode 100644 index 000000000..8ddb007bd --- /dev/null +++ b/crates/fakecloud-iam/src/policy_variables.rs @@ -0,0 +1,228 @@ +//! IAM policy variables: `${aws:username}`-style placeholders in `Resource` / +//! `NotResource` ARNs and in string and ARN condition values. +//! +//! Semantics follow the IAM User Guide ("IAM policy elements: Variables and +//! tags"): +//! +//! - Only a policy whose `Version` is `2012-10-17` expands variables; in any +//! other policy `${...}` is literal text. +//! - `${key}` is replaced by the request's value for the (case-insensitive) +//! condition key. A key with no value -- absent, or multivalued, which +//! cannot be used as a variable -- makes the string null: it matches no +//! resource, positive operators (`StringEquals`, `StringLike`, `ArnLike`, +//! ...) never match it, and inverted ones (`StringNotEquals`, ...) do. +//! - `${key, 'default'}` falls back to `default` when the key has no value. +//! - `${*}`, `${?}` and `${$}` stand for a literal `*`, `?` and `$`. +//! - A substituted value is literal: a `*` in a principal's tag is not a +//! wildcard, so a tag value cannot widen what a policy grants. + +use fakecloud_core::auth::ConditionContext; + +/// One element of a policy string after expansion. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Piece { + Literal(char), + /// `*` written in the policy: any run of characters. + AnyRun, + /// `?` written in the policy: any one character. + AnyOne, +} + +/// Whether `text` contains a variable reference at all. Strings without one +/// keep their existing matching path. +pub(crate) fn has_variables(text: &str) -> bool { + text.contains("${") +} + +/// Expand `text`, or `None` when a variable in it has no value (and no +/// default). +pub(crate) fn expand(text: &str, ctx: &ConditionContext) -> Option> { + let mut out = Vec::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find("${") { + push_policy_text(&mut out, &rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find('}') else { + // Unterminated: the rest is plain text. + push_policy_text(&mut out, &rest[start..]); + return Some(out); + }; + let inner = &after[..end]; + match inner.trim() { + "*" => out.push(Piece::Literal('*')), + "?" => out.push(Piece::Literal('?')), + "$" => out.push(Piece::Literal('$')), + reference => { + let (key, default) = split_default(reference); + let value = match ctx.lookup(key) { + Some(values) if values.len() == 1 => values.into_iter().next(), + _ => default.map(str::to_string), + }?; + out.extend(value.chars().map(Piece::Literal)); + } + } + rest = &after[end + 1..]; + } + push_policy_text(&mut out, rest); + Some(out) +} + +/// `aws:PrincipalTag/team, 'company-wide'` -> (`aws:PrincipalTag/team`, +/// `Some("company-wide")`). +fn split_default(reference: &str) -> (&str, Option<&str>) { + if let Some((key, default)) = reference.split_once(',') { + let default = default.trim(); + if let Some(quoted) = default + .strip_prefix('\'') + .and_then(|d| d.strip_suffix('\'')) + { + return (key.trim(), Some(quoted)); + } + } + (reference, None) +} + +fn push_policy_text(out: &mut Vec, text: &str) { + out.extend(text.chars().map(|c| match c { + '*' => Piece::AnyRun, + '?' => Piece::AnyOne, + c => Piece::Literal(c), + })); +} + +/// The expanded string as plain text, for exact comparisons: a wildcard +/// written in the policy is just its character there. +pub(crate) fn to_text(pieces: &[Piece]) -> String { + pieces + .iter() + .map(|p| match p { + Piece::Literal(c) => *c, + Piece::AnyRun => '*', + Piece::AnyOne => '?', + }) + .collect() +} + +/// Glob-match `value` against expanded pieces, optionally ignoring ASCII +/// case. +pub(crate) fn glob(pieces: &[Piece], value: &str, ignore_case: bool) -> bool { + let v: Vec = value.chars().collect(); + let eq = |a: char, b: char| { + if ignore_case { + a.eq_ignore_ascii_case(&b) + } else { + a == b + } + }; + let (mut pi, mut vi) = (0usize, 0usize); + let mut star: Option = None; + let mut star_v = 0usize; + while vi < v.len() { + match pieces.get(pi) { + Some(Piece::AnyOne) => { + pi += 1; + vi += 1; + } + Some(Piece::Literal(c)) if eq(*c, v[vi]) => { + pi += 1; + vi += 1; + } + Some(Piece::AnyRun) => { + star = Some(pi); + star_v = vi; + pi += 1; + } + _ => match star { + Some(s) => { + pi = s + 1; + star_v += 1; + vi = star_v; + } + None => return false, + }, + } + } + while matches!(pieces.get(pi), Some(Piece::AnyRun)) { + pi += 1; + } + pi == pieces.len() +} + +/// Match a `Resource` / `NotResource` pattern that carries variables. +/// Variables are expanded only in the resource part of the ARN, after the +/// fifth colon; `None` when a variable there has no value, which matches no +/// resource. +pub(crate) fn resource_pattern(pattern: &str, ctx: &ConditionContext) -> Option> { + let split = pattern + .match_indices(':') + .nth(4) + .map(|(i, _)| i + 1) + .unwrap_or(0); + let mut pieces = Vec::new(); + push_policy_text(&mut pieces, &pattern[..split]); + pieces.extend(expand(&pattern[split..], ctx)?); + Some(pieces) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn ctx() -> ConditionContext { + ConditionContext { + aws_username: Some("alice".to_string()), + principal_tags: Some(HashMap::from([ + ("team".to_string(), "blue".to_string()), + ("weird".to_string(), "a*b".to_string()), + ])), + ..Default::default() + } + } + + #[test] + fn expands_keys_defaults_and_special_characters() { + let c = ctx(); + assert_eq!( + to_text(&expand("home/${aws:username}/*", &c).unwrap()), + "home/alice/*" + ); + assert_eq!(to_text(&expand("${AWS:UserName}", &c).unwrap()), "alice"); + assert_eq!( + to_text(&expand("${aws:PrincipalTag/missing, 'company-wide'}", &c).unwrap()), + "company-wide" + ); + assert_eq!(expand("x-${aws:PrincipalTag/missing}", &c), None); + assert_eq!(to_text(&expand("a${*}b${?}c${$}", &c).unwrap()), "a*b?c$"); + assert_eq!( + to_text(&expand("open ${aws:username", &c).unwrap()), + "open ${aws:username" + ); + } + + #[test] + fn substituted_values_and_special_characters_are_literal() { + let c = ctx(); + let pieces = expand("${aws:PrincipalTag/weird}", &c).unwrap(); + assert!(glob(&pieces, "a*b", false)); + assert!(!glob(&pieces, "aXXb", false), "a tag's * is not a wildcard"); + let pieces = expand("file${*}", &c).unwrap(); + assert!(glob(&pieces, "file*", false)); + assert!(!glob(&pieces, "file1", false)); + let pieces = expand("home/${aws:username}/*", &c).unwrap(); + assert!(glob(&pieces, "home/alice/doc.txt", false)); + assert!(!glob(&pieces, "home/bob/doc.txt", false)); + } + + #[test] + fn resource_patterns_expand_only_the_resource_part() { + let c = ctx(); + let pieces = resource_pattern("arn:aws:s3:::bucket/${aws:username}/*", &c).unwrap(); + assert!(glob(&pieces, "arn:aws:s3:::bucket/alice/x", false)); + assert!(!glob(&pieces, "arn:aws:s3:::bucket/bob/x", false)); + assert_eq!( + resource_pattern("arn:aws:s3:::bucket/${aws:PrincipalTag/none}", &c), + None + ); + } +} diff --git a/website/content/docs/reference/security.md b/website/content/docs/reference/security.md index f16d5a76b..0b0afe876 100644 --- a/website/content/docs/reference/security.md +++ b/website/content/docs/reference/security.md @@ -79,6 +79,7 @@ The policy evaluator implements the essentials of AWS's identity-based policy ev - `Effect: "Allow"` / `Effect: "Deny"` with **Deny precedence** (any matching deny wins). - `Action` / `NotAction` with `*` and `?` wildcards. Service prefix match is case-insensitive; action names are case-sensitive (matches AWS). - `Resource` / `NotResource` with `*` and `?` wildcards. +- **Policy variables** in `Version: "2012-10-17"` policies: `${aws:username}`, `${aws:userid}`, `${aws:PrincipalTag/}` and any other single-valued condition key, in the resource part of `Resource` / `NotResource` ARNs and in `String*` / `Arn*` condition values. `${key, 'default'}` supplies a default; `${*}`, `${?}` and `${$}` stand for a literal `*`, `?` and `$`; a substituted value is literal (a `*` in a tag is not a wildcard). A variable with no value matches no resource, fails positive operators and satisfies inverted ones (`StringNotEquals`, `StringNotLike`, `ArnNotLike`, ...). Policies with any other `Version` read `${...}` literally, as AWS does. - `Condition` blocks: all 28 operators AWS defines, plus the `...IfExists` suffix and the `ForAllValues:` / `ForAnyValue:` qualifiers. See the next section for details. - Identity policies attached to: - IAM users (inline + managed + via group membership, inline and managed) @@ -103,7 +104,7 @@ A statement with a `Condition` block only applies when every entry in the block | ARN | `ArnEquals`, `ArnNotEquals`, `ArnLike`, `ArnNotLike` | | Existence | `Null` | -Every operator supports the `...IfExists` suffix (missing key evaluates to `true`) and the `ForAllValues:` / `ForAnyValue:` set-qualifier prefixes. +Every operator supports the `...IfExists` suffix (missing key evaluates to `true`) and the `ForAllValues:` / `ForAnyValue:` set-qualifier prefixes. As on AWS, `ForAllValues` is also `true` when the request has no value for the key (every one of zero values matches), while `ForAnyValue` then evaluates to `false`. **Supported global condition keys:** diff --git a/website/content/docs/services/iam.md b/website/content/docs/services/iam.md index fd176f8cc..dcc4dab24 100644 --- a/website/content/docs/services/iam.md +++ b/website/content/docs/services/iam.md @@ -49,7 +49,7 @@ Query protocol. Form-encoded body, `Action` parameter, XML responses. ## Gotchas -- **Policies are stored and optionally evaluated.** By default fakecloud records IAM policies without evaluating them. Set `FAKECLOUD_IAM=strict` (or `soft` for log-only) to turn on policy evaluation — Allow/Deny with Deny precedence, Action/Resource wildcards, user/group/role inline and managed policies, `Condition` blocks with all 28 AWS operators and global + service-specific keys, resource-based policies for S3 bucket, SNS topic, Lambda function, and KMS key policies with AWS's cross-account combining semantics, full `Principal` / `NotPrincipal` matching, permission boundaries (`PutUserPermissionsBoundary` / `PutRolePermissionsBoundary`), session policies passed to `AssumeRole` / `AssumeRoleWithWebIdentity` / `AssumeRoleWithSAML` / `GetFederationToken`, ABAC tag conditions (`aws:ResourceTag`, `aws:RequestTag`, `aws:TagKeys`, `aws:PrincipalTag`) on S3, SQS, SNS, and IAM resources, and Organizations SCPs (Service Control Policies) ceiling enforcement across multi-account setups. See [SigV4 verification and IAM enforcement](@/docs/reference/security.md) for the full scope. +- **Policies are stored and optionally evaluated.** By default fakecloud records IAM policies without evaluating them. Set `FAKECLOUD_IAM=strict` (or `soft` for log-only) to turn on policy evaluation — Allow/Deny with Deny precedence, Action/Resource wildcards, user/group/role inline and managed policies, `Condition` blocks with all 28 AWS operators and global + service-specific keys, policy variables (`${aws:username}`, `${aws:PrincipalTag/}`, defaults and `${*}` / `${?}` / `${$}`) in resources and string/ARN conditions, resource-based policies for S3 bucket, SNS topic, Lambda function, and KMS key policies with AWS's cross-account combining semantics, full `Principal` / `NotPrincipal` matching, permission boundaries (`PutUserPermissionsBoundary` / `PutRolePermissionsBoundary`), session policies passed to `AssumeRole` / `AssumeRoleWithWebIdentity` / `AssumeRoleWithSAML` / `GetFederationToken`, ABAC tag conditions (`aws:ResourceTag`, `aws:RequestTag`, `aws:TagKeys`, `aws:PrincipalTag`) on S3, SQS, SNS, and IAM resources, and Organizations SCPs (Service Control Policies) ceiling enforcement across multi-account setups. See [SigV4 verification and IAM enforcement](@/docs/reference/security.md) for the full scope. - **SigV4 verification is opt-in.** By default fakecloud parses signatures for routing but doesn't verify them. Set `FAKECLOUD_VERIFY_SIGV4=true` to turn on cryptographic verification with the standard ±15-minute clock skew window. The reserved `test`/`test` root-bypass convention always passes, matching LocalStack. ## Source From aac3b0bd67a293d69af3acc52703d1e066492ab5 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Mon, 14 Sep 2026 10:01:29 -0300 Subject: [PATCH 2/3] fix(iam): ForAllValues only on populated keys; context-entry fallback - ForAllValues is vacuously true when the service populated the key with no values. A key never populated -- one fakecloud does not extract -- still safe-fails to false, so an unextracted key never grants. - ConditionContext::lookup falls back to plain context entries for a global key with no typed value, so SimulateCustomPolicy / SimulatePrincipalPolicy ContextEntries resolve aws:username and friends (also as policy variables). An empty entry now means "populated, no values" instead of "absent". --- crates/fakecloud-core/src/auth.rs | 32 ++++---- crates/fakecloud-e2e/tests/iam_simulate.rs | 37 +++++++++ crates/fakecloud-iam/src/condition.rs | 14 ++-- crates/fakecloud-iam/src/evaluator_tests.rs | 85 ++++++++++++--------- website/content/docs/reference/security.md | 2 +- 5 files changed, 113 insertions(+), 57 deletions(-) diff --git a/crates/fakecloud-core/src/auth.rs b/crates/fakecloud-core/src/auth.rs index c0c84f2e0..d2f451659 100644 --- a/crates/fakecloud-core/src/auth.rs +++ b/crates/fakecloud-core/src/auth.rs @@ -339,7 +339,7 @@ impl ConditionContext { .map(|tags| tags.keys().cloned().collect()); } - match lower.as_str() { + let typed = match lower.as_str() { "aws:username" => self.aws_username.as_deref().and_then(one), "aws:userid" => self.aws_userid.as_deref().and_then(one), "aws:principalarn" => self.aws_principal_arn.as_deref().and_then(one), @@ -368,21 +368,21 @@ impl ConditionContext { "aws:tokenissuetime" => self .aws_token_issue_time .map(|t| vec![t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)]), - _ => { - if let Some(vs) = self.service_keys.get(&lower) { - if vs.is_empty() { - None - } else { - Some(vs.clone()) - } - } else { - self.service_keys - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(key)) - .map(|(_, vs)| vs.clone()) - } - } - } + _ => None, + }; + // A key with no typed value -- a service-specific key, or a global key + // supplied as a plain entry (a policy simulator's ContextEntries) -- + // comes from `service_keys`. An entry with an empty value list means + // the key applies to the request but carries no values, which set + // operators distinguish from a key that was never populated. + typed.or_else(|| { + self.service_keys.get(&lower).cloned().or_else(|| { + self.service_keys + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, vs)| vs.clone()) + }) + }) } } diff --git a/crates/fakecloud-e2e/tests/iam_simulate.rs b/crates/fakecloud-e2e/tests/iam_simulate.rs index 5364478b7..498ac108c 100644 --- a/crates/fakecloud-e2e/tests/iam_simulate.rs +++ b/crates/fakecloud-e2e/tests/iam_simulate.rs @@ -311,3 +311,40 @@ async fn simulate_principal_policy_via_attached_aws_managed_policy() { "implicitDeny" ); } + +/// A policy variable resolves from a simulator context entry, so +/// `${aws:username}` policies can be tested with SimulateCustomPolicy. +#[tokio::test] +async fn simulate_custom_policy_resolves_policy_variables_from_context_entries() { + let server = TestServer::start().await; + let iam = server.iam_client().await; + let policy = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::home/${aws:username}/*"}]}"#; + let decision = |resource: &'static str| { + let iam = iam.clone(); + async move { + iam.simulate_custom_policy() + .policy_input_list(policy) + .action_names("s3:GetObject") + .resource_arns(resource) + .context_entries( + ContextEntry::builder() + .context_key_name("aws:username") + .context_key_values("alice") + .context_key_type(ContextKeyTypeEnum::String) + .build(), + ) + .send() + .await + .unwrap() + .evaluation_results()[0] + .eval_decision() + .as_str() + .to_string() + } + }; + assert_eq!(decision("arn:aws:s3:::home/alice/notes").await, "allowed"); + assert_eq!( + decision("arn:aws:s3:::home/bob/notes").await, + "implicitDeny" + ); +} diff --git a/crates/fakecloud-iam/src/condition.rs b/crates/fakecloud-iam/src/condition.rs index cfec316c2..0948174a3 100644 --- a/crates/fakecloud-iam/src/condition.rs +++ b/crates/fakecloud-iam/src/condition.rs @@ -327,12 +327,16 @@ pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool { // Missing key handling. let context_values = match context_values { Some(vs) if !vs.is_empty() => vs, + // The service populated the key and the request carries no values for + // it. `ForAllValues` is vacuously true then, as AWS documents ("every + // value matches" holds for none). A key that was never populated is + // not treated the same way: it may be one fakecloud does not extract, + // and granting on it would fail open. + Some(_) if entry.operator.qualifier == Qualifier::ForAllValues => return true, _ => { - // Key not populated. `IfExists` -> vacuously true. So is - // `ForAllValues`: AWS documents it as true when the request has no - // value for the key ("every value matches" holds for none). - // Otherwise this is a safe-fail to false. - if entry.operator.if_exists || entry.operator.qualifier == Qualifier::ForAllValues { + // Key not populated. `IfExists` -> vacuously true. Otherwise + // this is a safe-fail to false. + if entry.operator.if_exists { return true; } if ctx.lookup(&entry.key).is_none() { diff --git a/crates/fakecloud-iam/src/evaluator_tests.rs b/crates/fakecloud-iam/src/evaluator_tests.rs index ddf3ddc79..01089347f 100644 --- a/crates/fakecloud-iam/src/evaluator_tests.rs +++ b/crates/fakecloud-iam/src/evaluator_tests.rs @@ -2383,50 +2383,65 @@ fn variables_without_a_value_are_null() { assert_eq!(run(null_resource), Decision::ImplicitDeny); } -/// `ForAllValues` holds when the request has no value for the key: every one -/// of zero values matches. It used to fail the condition instead. +/// `ForAllValues` holds when the service populated the key and the request +/// carries no values for it: every one of zero values matches. A key that was +/// never populated still fails the condition -- it may be one fakecloud does +/// not extract, and treating it as matched would fail open. #[test] -fn for_all_values_is_true_when_the_key_is_absent() { +fn for_all_values_is_true_for_a_populated_key_with_no_values() { let alice = principal_user("arn:aws:iam::123456789012:user/alice"); - let policy = doc(json!({ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Action": "dynamodb:Scan", - "Resource": "*", - "Condition": {"ForAllValues:StringEquals": {"dynamodb:LeadingKeys": ["alice"]}} - }] - })); + let resource = "arn:aws:dynamodb:us-east-1:123456789012:table/T"; + let policy = |qualifier: &str| { + doc(json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "dynamodb:Scan", + "Resource": "*", + "Condition": {qualifier: {"dynamodb:LeadingKeys": ["alice"]}} + }] + })) + }; + let mut populated = req(&alice, "dynamodb:Scan", resource); + populated + .context + .service_keys + .insert("dynamodb:leadingkeys".to_string(), Vec::new()); + assert_eq!( + evaluate(&[policy("ForAllValues:StringEquals")], &populated), + Decision::Allow + ); + assert_eq!( + evaluate(&[policy("ForAnyValue:StringEquals")], &populated), + Decision::ImplicitDeny, + "ForAnyValue needs a value" + ); assert_eq!( evaluate( - &[policy.clone()], - &req( - &alice, - "dynamodb:Scan", - "arn:aws:dynamodb:us-east-1:123456789012:table/T" - ) + &[policy("ForAllValues:StringEquals")], + &req(&alice, "dynamodb:Scan", resource) ), - Decision::Allow + Decision::ImplicitDeny, + "a key nothing populated is not vacuously matched" ); - let for_any = doc(json!({ +} + +/// A global key supplied as a plain context entry (the policy simulator's +/// ContextEntries) resolves in conditions and as a policy variable. +#[test] +fn global_keys_fall_back_to_plain_context_entries() { + let alice = principal_user("arn:aws:iam::123456789012:user/alice"); + let mut r = req(&alice, "s3:GetObject", "arn:aws:s3:::home/alice/x"); + r.context + .service_keys + .insert("aws:username".to_string(), vec!["alice".to_string()]); + let policy = doc(json!({ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", - "Action": "dynamodb:Scan", - "Resource": "*", - "Condition": {"ForAnyValue:StringEquals": {"dynamodb:LeadingKeys": ["alice"]}} + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::home/${aws:username}/*" }] })); - assert_eq!( - evaluate( - &[for_any], - &req( - &alice, - "dynamodb:Scan", - "arn:aws:dynamodb:us-east-1:123456789012:table/T" - ) - ), - Decision::ImplicitDeny, - "ForAnyValue still needs a value" - ); + assert_eq!(evaluate(&[policy], &r), Decision::Allow); } diff --git a/website/content/docs/reference/security.md b/website/content/docs/reference/security.md index 0b0afe876..ca2e68ace 100644 --- a/website/content/docs/reference/security.md +++ b/website/content/docs/reference/security.md @@ -104,7 +104,7 @@ A statement with a `Condition` block only applies when every entry in the block | ARN | `ArnEquals`, `ArnNotEquals`, `ArnLike`, `ArnNotLike` | | Existence | `Null` | -Every operator supports the `...IfExists` suffix (missing key evaluates to `true`) and the `ForAllValues:` / `ForAnyValue:` set-qualifier prefixes. As on AWS, `ForAllValues` is also `true` when the request has no value for the key (every one of zero values matches), while `ForAnyValue` then evaluates to `false`. +Every operator supports the `...IfExists` suffix (missing key evaluates to `true`) and the `ForAllValues:` / `ForAnyValue:` set-qualifier prefixes. As on AWS, `ForAllValues` is also `true` when the request carries no values for a key the service populates (every one of zero values matches), while `ForAnyValue` then evaluates to `false`. A key fakecloud does not extract for the request still safe-fails to `false`, so an unextracted key never grants. **Supported global condition keys:** From f2f036fcdf87f8c2889d55fa37e71a4c2b7ecb76 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Mon, 14 Sep 2026 10:04:55 -0300 Subject: [PATCH 3/3] fix(iam): tag condition keys fall back to plain context entries --- crates/fakecloud-core/src/auth.rs | 79 ++++++++++++++------- crates/fakecloud-iam/src/evaluator_tests.rs | 38 ++++++++++ 2 files changed, 91 insertions(+), 26 deletions(-) diff --git a/crates/fakecloud-core/src/auth.rs b/crates/fakecloud-core/src/auth.rs index d2f451659..3c503713f 100644 --- a/crates/fakecloud-core/src/auth.rs +++ b/crates/fakecloud-core/src/auth.rs @@ -293,6 +293,20 @@ pub struct ConditionContext { pub principal_tags: Option>, } +/// Whether two condition key names are the same key: the `service:name` +/// part compares case-insensitively, and anything after the first `/` (a tag +/// key in `aws:RequestTag/`) compares exactly. +fn same_condition_key(a: &str, b: &str) -> bool { + fn split(k: &str) -> (&str, &str) { + match k.find('/') { + Some(i) => (&k[..i], &k[i..]), + None => (k, ""), + } + } + let ((a_name, a_tail), (b_name, b_tail)) = (split(a), split(b)); + a_name.eq_ignore_ascii_case(b_name) && a_tail == b_tail +} + impl ConditionContext { /// Resolve a condition key (e.g. `"aws:username"`) to the list of /// context values. Returns `None` if the key is not populated. @@ -308,35 +322,48 @@ impl ConditionContext { // // Prefix lengths: "aws:resourcetag/" = 16, "aws:requesttag/" = 15, // "aws:principaltag/" = 17 - if lower.starts_with("aws:resourcetag/") { + let tagged = if lower.starts_with("aws:resourcetag/") { let tag_key = &key[16..]; // preserve original case - return self - .resource_tags - .as_ref() - .and_then(|tags| tags.get(tag_key)) - .map(|v| vec![v.clone()]); - } - if lower.starts_with("aws:requesttag/") { + Some( + self.resource_tags + .as_ref() + .and_then(|tags| tags.get(tag_key)) + .map(|v| vec![v.clone()]), + ) + } else if lower.starts_with("aws:requesttag/") { let tag_key = &key[15..]; - return self - .request_tags - .as_ref() - .and_then(|tags| tags.get(tag_key)) - .map(|v| vec![v.clone()]); - } - if lower.starts_with("aws:principaltag/") { + Some( + self.request_tags + .as_ref() + .and_then(|tags| tags.get(tag_key)) + .map(|v| vec![v.clone()]), + ) + } else if lower.starts_with("aws:principaltag/") { let tag_key = &key[17..]; - return self - .principal_tags - .as_ref() - .and_then(|tags| tags.get(tag_key)) - .map(|v| vec![v.clone()]); - } - if lower == "aws:tagkeys" { - return self - .request_tags - .as_ref() - .map(|tags| tags.keys().cloned().collect()); + Some( + self.principal_tags + .as_ref() + .and_then(|tags| tags.get(tag_key)) + .map(|v| vec![v.clone()]), + ) + } else if lower == "aws:tagkeys" { + Some( + self.request_tags + .as_ref() + .map(|tags| tags.keys().cloned().collect()), + ) + } else { + None + }; + if let Some(tagged) = tagged { + // Tag keys are case-sensitive after the prefix, so a plain entry + // must match the key exactly. + return tagged.or_else(|| { + self.service_keys + .iter() + .find(|(entry, _)| same_condition_key(entry, key)) + .map(|(_, vs)| vs.clone()) + }); } let typed = match lower.as_str() { diff --git a/crates/fakecloud-iam/src/evaluator_tests.rs b/crates/fakecloud-iam/src/evaluator_tests.rs index 01089347f..9489afe73 100644 --- a/crates/fakecloud-iam/src/evaluator_tests.rs +++ b/crates/fakecloud-iam/src/evaluator_tests.rs @@ -2445,3 +2445,41 @@ fn global_keys_fall_back_to_plain_context_entries() { })); assert_eq!(evaluate(&[policy], &r), Decision::Allow); } + +/// Tag keys given as plain context entries resolve too, with the tag key part +/// compared exactly. +#[test] +fn tag_keys_fall_back_to_plain_context_entries() { + let alice = principal_user("arn:aws:iam::123456789012:user/alice"); + let policy = doc(json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "*", + "Condition": { + "ForAllValues:StringEquals": {"aws:TagKeys": ["team"]}, + "StringEquals": {"aws:RequestTag/team": "red"} + } + }] + })); + let mut r = req(&alice, "s3:PutObject", "arn:aws:s3:::b/k"); + r.context + .service_keys + .insert("aws:tagkeys".to_string(), vec!["team".to_string()]); + r.context + .service_keys + .insert("aws:RequestTag/team".to_string(), vec!["red".to_string()]); + assert_eq!(evaluate(&[policy.clone()], &r), Decision::Allow); + + let mut wrong_case = req(&alice, "s3:PutObject", "arn:aws:s3:::b/k"); + wrong_case + .context + .service_keys + .insert("aws:tagkeys".to_string(), vec!["team".to_string()]); + wrong_case + .context + .service_keys + .insert("aws:RequestTag/Team".to_string(), vec!["red".to_string()]); + assert_eq!(evaluate(&[policy], &wrong_case), Decision::ImplicitDeny); +}