diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 1618dfce4..0f29d39df 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -8,8 +8,8 @@ //! set operators ForAllValues/ForAnyValue with optional `IfExists` suffix. use super::context::ConditionContext; -use super::document::{Condition, ConditionOperator}; -use super::matcher::wildcard_match; +use super::document::{Condition, ConditionOperator, base_operator}; +use super::variables::{ConditionValue, arn_value_lacks_fields}; /// Evaluate a single condition against a context. /// @@ -33,7 +33,7 @@ use super::matcher::wildcard_match; /// - `ForAnyValue`: at least one context value must match some policy value. /// Absent key is false (unless wrapped in `IfExists`). /// -/// Policy variables in the values are read as in a `2012-10-17` document; +/// Policy variables in the values are expanded as in a `2012-10-17` document; /// [`evaluate_condition_with`] takes the document's choice. pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext) -> bool { evaluate_condition_with(condition, true, context) @@ -43,8 +43,10 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext /// `2012-10-17` when `version_2012` is true /// ([`super::document::PolicyDocument::expands_policy_variables`]). /// -/// The value says how the text between `${` and `}` in a condition value is -/// read ([`reads_iam_variable_syntax`]); `${key}` expands whatever it is. +/// Only such a document expands policy variables in condition values, and in +/// it only the string and ARN operators do ([`expands_variables`]). A +/// `2008-10-17` document, or one with no `Version`, compares each value as +/// written. pub fn evaluate_condition_with( condition: &Condition, version_2012: bool, @@ -52,13 +54,17 @@ pub fn evaluate_condition_with( ) -> bool { let context_values = context.resolve_key(&condition.key); let is_multivalued = context.is_multivalued_key(&condition.key); - let iam_syntax = reads_iam_variable_syntax(&condition.operator, version_2012); + // Only the string and ARN operators expand policy variables (measured). A + // numeric value written as a variable is compared as written, and a `Bool` + // or `Null` value written as one is read as the boolean `false` + // (`bool_matches`, `null_holds`). + let expand_variables = expands_variables(&condition.operator, version_2012); // Expand policy variables (e.g. `${aws:PrincipalTag/Team}`) in condition values. - let expanded_values: Vec = condition + let expanded_values: Vec = condition .values .iter() - .map(|v| expand_policy_variables(v, iam_syntax, context)) + .map(|v| expand_policy_variables(v, expand_variables, context)) .collect(); match &condition.operator { @@ -66,7 +72,8 @@ pub fn evaluate_condition_with( let key_absent = context_values.is_none(); expanded_values .first() - .is_some_and(|v| (v == "true" && key_absent) || (v == "false" && !key_absent)) + .and_then(ConditionValue::text) + .is_some_and(|v| null_holds(v, key_absent)) } ConditionOperator::ForAllValues(inner) => { let (_absent_passes, base_op) = unwrap_if_exists(inner); @@ -189,28 +196,53 @@ pub enum ConditionMatch { /// A condition is not satisfied; the statement does not apply. NoMatch, /// A condition depends on a key the service populates but this context - /// cannot resolve. The statement fails closed. + /// cannot resolve, or has an `Arn*` value that contains `${` and cannot + /// have the six fields of an ARN, which IAM refuses in a document of any + /// `Version`. The statement fails closed. Unresolvable, } /// Evaluate one condition three-valued. /// /// Returns [`ConditionMatch::Unresolvable`] when the key is unresolved and the -/// context reports it as service-populated (case 3), and when a condition value -/// reads such a key through a `${name}` policy variable, or through a -/// `${name, 'default'}` one where [`reads_iam_variable_syntax`] holds, since -/// the variable has no value to expand to and the service, which has one, -/// would not use the default. Otherwise returns `Match` or `NoMatch` from the -/// ordinary evaluation, so cases 1 and 2 are unchanged. `version_2012` is as -/// in [`evaluate_condition_with`]. +/// context reports it as service-populated (case 3), and, when the value is +/// expanded ([`expands_variables`]: a `2012-10-17` document and a string or +/// ARN operator), when a condition value reads such a key through a `${name}` +/// or `${name, 'default'}` policy variable, since the variable has no value to +/// expand to and the service, which has one, would not use the default. A +/// value that also names a key the request does not carry, with no default, +/// fails closed here although it matches nothing whatever the other key +/// holds; a `Resource` pattern of that shape does not. Where the value is not +/// expanded it is compared as written and reads no key. It also returns +/// `Unresolvable`, whatever `version_2012` says, when a value of an `Arn*` +/// operator, with or without a set qualifier or `IfExists`, contains `${` and +/// has fewer than five `:` written outside its `${...}` references, such as +/// `${aws:ResourceTag/owner}`. Otherwise returns `Match` or `NoMatch` from +/// the ordinary evaluation, so cases 1 and 2 are unchanged. `version_2012` is +/// as in [`evaluate_condition_with`]. pub fn evaluate_condition_match( condition: &Condition, version_2012: bool, context: &impl ConditionContext, ) -> ConditionMatch { - let iam_syntax = reads_iam_variable_syntax(&condition.operator, version_2012); if is_unresolvable(&condition.key, context) - || reads_unresolvable_variable(condition, iam_syntax, context) + || (expands_variables(&condition.operator, version_2012) + && reads_unresolvable_variable(condition, context)) + { + return ConditionMatch::Unresolvable; + } + // The IAM policy simulator refuses a policy with such an ARN value + // (`InvalidInput`) and IAM Access Analyzer reports `MISSING_ARN_FIELD`, so + // IAM gives no decision to match. Failing closed makes a `Deny` apply and + // keeps an `Allow` from granting. IAM checks the value's fields whatever + // the document's `Version`, so the rule does not read `version_2012`. + if matches!( + base_operator(&condition.operator), + ConditionOperator::ArnEquals + | ConditionOperator::ArnNotEquals + | ConditionOperator::ArnLike + | ConditionOperator::ArnNotLike + ) && condition.values.iter().any(|v| arn_value_lacks_fields(v)) { return ConditionMatch::Unresolvable; } @@ -232,53 +264,34 @@ fn is_unresolvable(key: &str, context: &impl ConditionContext) -> bool { /// /// The names come from [`super::variables::variable_key_names`], which reads /// the text between the braces the way [`expand_policy_variables`] expands -/// it with the same `iam_syntax`, so with it a default written beside such a -/// key does not hide the key from this check. -fn reads_unresolvable_variable( - condition: &Condition, - iam_syntax: bool, - context: &impl ConditionContext, -) -> bool { +/// it, so a default written beside such a key does not hide the key from this +/// check. The caller asks only where the value is expanded +/// ([`expands_variables`]). +fn reads_unresolvable_variable(condition: &Condition, context: &impl ConditionContext) -> bool { condition.values.iter().any(|value| { - super::variables::variable_key_names(value, iam_syntax) - .any(|name| is_unresolvable(name, context)) + super::variables::variable_key_names(value).any(|name| is_unresolvable(name, context)) }) } /// Whether the values of a condition under `operator`, in a document whose -/// `Version` is `2012-10-17` when `version_2012` is true, are read with IAM's -/// variable syntax: the `${key, 'default'}` form, and a key name trimmed of -/// the code points up to U+0020. +/// `Version` is `2012-10-17` when `version_2012` is true, expand policy +/// variables: only in such a document, and only under the string and ARN +/// operators ([`operator_expands_variables`]). /// -/// IAM reads that syntax only in a `2012-10-17` document and under the string -/// and ARN operators, with or without a set qualifier or `IfExists` (IAM -/// policy simulator, 2026-11-03, two samples per row). In a `2008-10-17` -/// document and in one with no `Version`, `${aws:username, 'x'}` is compared -/// as written under `StringEquals` and `StringNotEquals`. In a `2012-10-17` -/// document a default does not take part under `NumericEquals`, -/// `DateGreaterThan`, `IpAddress`, `Bool` or `Null`, while it does under -/// `StringEquals` and `ArnLike`. Elsewhere the text between the braces is the -/// key name as written, which is how condition values were read before the -/// default form: `${key}` still expands there, as it did. -fn reads_iam_variable_syntax(operator: &ConditionOperator, version_2012: bool) -> bool { - version_2012 && operator_reads_variables(operator) -} - -/// Whether `operator`, unwrapped from its set qualifier and `IfExists`, is a -/// string or ARN operator. -fn operator_reads_variables(operator: &ConditionOperator) -> bool { - match operator { - ConditionOperator::ForAllValues(inner) - | ConditionOperator::ForAnyValue(inner) - | ConditionOperator::IfExists(inner) => operator_reads_variables(inner), - ConditionOperator::StringEquals - | ConditionOperator::StringNotEquals - | ConditionOperator::StringEqualsIgnoreCase - | ConditionOperator::StringNotEqualsIgnoreCase - | ConditionOperator::StringLike - | ConditionOperator::StringNotLike => true, - other => is_arn_operator(other), - } +/// IAM reads the `${key, 'default'}` form, and a key name trimmed of the code +/// points up to U+0020, in the same places (IAM policy simulator, 2026-11-03, +/// two samples per row): in a `2008-10-17` document and in one with no +/// `Version`, `${aws:username, 'x'}` is compared as written under +/// `StringEquals` and `StringNotEquals`, and in a `2012-10-17` document a +/// default does not take part under `NumericEquals`, `DateGreaterThan`, +/// `IpAddress`, `Bool` or `Null`, while it does under `StringEquals` and +/// `ArnLike`. Where this is false the whole value is compared as written: +/// `${key}` is not expanded either (measured, issue #534: `StringEquals` +/// `${aws:username}` in a document with no `Version` matches the text and not +/// the user name, and `NumericEquals` `${aws:PrincipalTag/age}` in a +/// `2012-10-17` document does not match the tag's value). +fn expands_variables(operator: &ConditionOperator, version_2012: bool) -> bool { + version_2012 && operator_expands_variables(operator) } /// Evaluate a statement's whole condition block (all conditions are ANDed). @@ -311,29 +324,58 @@ pub fn evaluate_statement_conditions( /// Expand IAM policy variables in a string value. /// -/// Replaces `${variable}` and `${variable, 'default'}` patterns with their -/// resolved values from the context (see [`super::variables`]). Supported +/// With `expand_variables`, replaces `${variable}` and `${variable, 'default'}` +/// patterns with their resolved values from the context and `${*}`, `${?}` and +/// `${$}` with the literal character (see [`super::variables`]). Supported /// variables: `aws:PrincipalTag/*` and any single-valued key the context can -/// resolve. A variable whose key the request does not carry, with no default, -/// stays literal, which gives IAM's answer except when the compared value -/// holds the variable's text. A variable that names a multivalued key -/// (`aws:TagKeys`, `dynamodb:LeadingKeys`, `dynamodb:Attributes`) also stays -/// literal: a set of values has no one value to expand to. IAM leaves such a -/// variable as text (measured 2026-10-27: a Deny on `aws:ResourceTag/team` -/// equal to `${aws:TagKeys}` applies to no request, whatever order the -/// request lists its tag keys in, and the same Deny under `StringNotEquals` -/// applies to every request). A variable naming a key the context reports -/// unresolvable also stays literal here, with or without a default; the -/// condition fails closed on it before this expansion is compared -/// ([`evaluate_condition_match`]). Without `iam_syntax` -/// ([`reads_iam_variable_syntax`]) the default form is not read, so -/// `${key, 'default'}` stays literal too. +/// resolve. A variable with no default whose key the request does not carry +/// makes the value match nothing, and so does one that names a multivalued key +/// (`aws:TagKeys`, `dynamodb:LeadingKeys`, `dynamodb:Attributes`) the request +/// carries: a set of values has no one value to expand to. IAM gives such a +/// variable no value (measured 2026-10-27: a Deny on `aws:ResourceTag/team` +/// equal to `${aws:TagKeys}` applies to no request, whatever order the request +/// lists its tag keys in, and the same Deny under `StringNotEquals` applies to +/// every request; measured 2026-10-31: with `aws:TagKeys` holding two values, +/// one value or none, `StringEquals` against `${aws:TagKeys}` is false even +/// for a request value equal to that text). A variable naming a key the +/// context reports unresolvable also makes the value match nothing here, with +/// or without a default; the condition fails closed on it before this +/// expansion is compared ([`evaluate_condition_match`]). See +/// [`super::variables::expand_condition_value`]. Without `expand_variables` +/// the value is compared as written. fn expand_policy_variables( value: &str, - iam_syntax: bool, + expand_variables: bool, context: &impl ConditionContext, -) -> String { - super::variables::expand_condition_value(value, iam_syntax, context) +) -> ConditionValue { + super::variables::expand_condition_value(value, expand_variables, context) +} + +/// Whether `op` expands policy variables in its values: the string and ARN +/// operators, with or without a set qualifier or `IfExists`. +/// +/// IAM's condition operator reference lists policy variables as supported for +/// the string, Boolean and ARN operators and not supported for the numeric, +/// date, binary and IP address operators, and says a variable cannot be used +/// with `Null`. The IAM policy simulator expands a variable for the string and +/// ARN operators only: it compares a `NumericEquals` value written as a +/// variable as written, and reads a `Bool` or `Null` one as the boolean `false` +/// (`bool_matches`, `null_holds`), although IAM Access Analyzer reports those +/// two as `INVALID_VARIABLE_FOR_OPERATOR`. The date and address operators are +/// treated as the numeric ones; that is not measured. +fn operator_expands_variables(op: &ConditionOperator) -> bool { + match op { + ConditionOperator::ForAllValues(inner) + | ConditionOperator::ForAnyValue(inner) + | ConditionOperator::IfExists(inner) => operator_expands_variables(inner), + ConditionOperator::StringEquals + | ConditionOperator::StringNotEquals + | ConditionOperator::StringEqualsIgnoreCase + | ConditionOperator::StringNotEqualsIgnoreCase + | ConditionOperator::StringLike + | ConditionOperator::StringNotLike => true, + other => is_arn_operator(other), + } } /// Unwrap an `IfExists` wrapper if present. @@ -365,7 +407,7 @@ fn unwrap_if_exists(op: &ConditionOperator) -> (bool, &ConditionOperator) { fn evaluate_single_value_condition( op: &ConditionOperator, context_values: &[&str], - policy_values: &[String], + policy_values: &[ConditionValue], ) -> bool { if is_negative_operator(op) { // Negative operators: context value must not match ANY policy value. @@ -438,13 +480,35 @@ fn arn_operator_sees_non_arn(op: &ConditionOperator, context_values: &[&str]) -> /// Compare a single context value against a single policy value. /// -/// Returns `true` if the comparison holds for the given operator. +/// Returns `true` if the comparison holds for the given operator. An +/// unresolved policy value ([`ConditionValue::Unresolved`]) matches nothing: +/// a positive operator is false and a negative operator true, the answer an +/// absent key gets (measured for `StringEquals` and `StringNotEquals`). The +/// `Arn*` operators check the context value before that: one that is not an +/// ARN matches none of the four, whatever the policy value. /// /// # Panics /// /// Does not panic. Returns `false` for set/wrapper operators that should /// have been handled by the caller. -fn compare_single(op: &ConditionOperator, context_value: &str, policy_value: &str) -> bool { +fn compare_single(op: &ConditionOperator, context_value: &str, value: &ConditionValue) -> bool { + // A context value that is not an ARN (fewer than six fields) matches + // neither the positive nor the negative Arn operators, so a Deny on + // ArnNotEquals does not apply to a malformed value; under `ForAnyValue:` + // `arn_operator_sees_non_arn` applies the same rule to the whole set + // before this per-value comparison. The non-ARN rule was measured for all + // four operators on 2026-10-28 with the values `notanarn` and + // `arn:aws:dynamodb` (PR #471), and on 2026-10-31 with a policy value whose + // variable has no value: `ArnNotLike` and `ArnNotEquals` on + // `.../table/${aws:PrincipalTag/team}` with no `team` tag hold for an ARN + // and not for `notanarn` (PR #541). So the check comes before the + // unresolved value's answer. + if is_arn_operator(op) && !super::matcher::is_arn(context_value) { + return false; + } + let Some(policy_value) = value.text() else { + return is_negative_operator(op); + }; match op { ConditionOperator::StringEquals => context_value == policy_value, ConditionOperator::StringNotEquals => context_value != policy_value, @@ -454,8 +518,8 @@ fn compare_single(op: &ConditionOperator, context_value: &str, policy_value: &st ConditionOperator::StringNotEqualsIgnoreCase => { !context_value.eq_ignore_ascii_case(policy_value) } - ConditionOperator::StringLike => wildcard_match(policy_value, context_value), - ConditionOperator::StringNotLike => !wildcard_match(policy_value, context_value), + ConditionOperator::StringLike => value.wildcard_matches(context_value), + ConditionOperator::StringNotLike => !value.wildcard_matches(context_value), ConditionOperator::NumericEquals => parse_f64_cmp(context_value, policy_value, f64::eq), ConditionOperator::NumericNotEquals => { parse_f64_cmp(context_value, policy_value, |a, b| a != b) @@ -486,28 +550,23 @@ fn compare_single(op: &ConditionOperator, context_value: &str, policy_value: &st ConditionOperator::DateGreaterThanEquals => { compare_dates(context_value, policy_value, |a, b| a >= b) } - ConditionOperator::Bool => context_value.eq_ignore_ascii_case(policy_value), + ConditionOperator::Bool => bool_matches(context_value, policy_value), // IAM evaluates ArnEquals exactly as ArnLike and ArnNotEquals exactly // as ArnNotLike: `*` and `?` match in every ARN field, matching is // case sensitive, and both sides need the same number of fields, // except that a bare `*` pattern matches any six-field value // (`arn_match` returns before it compares fields; derived, since the - // simulator refuses `*` against an ARN). - // A context value that is not an ARN (fewer than six fields) matches - // neither the positive nor the negative operator, so a Deny on - // ArnNotEquals does not apply to a malformed value; under - // `ForAnyValue:` `arn_operator_sees_non_arn` applies the same rule to - // the whole set before this per-value comparison. The rows measured - // with simulate-custom-policy are in the description of PR #471; the - // non-ARN rule was measured for all four operators on 2026-10-28 with - // the values `notanarn` and `arn:aws:dynamodb`. + // simulator refuses `*` against an ARN). The rows measured with + // simulate-custom-policy are in the description of PR #471. The + // context value is an ARN here (checked above). The pattern is the + // expanded value with the wildcards the policy wrote, so a `*` or `?` + // that came from a variable value, a default or an escape is matched + // literally (`ConditionValue::arn_matches`). ConditionOperator::ArnEquals | ConditionOperator::ArnLike => { - super::matcher::is_arn(context_value) - && super::matcher::arn_match(policy_value, context_value) + value.arn_matches(context_value) } ConditionOperator::ArnNotEquals | ConditionOperator::ArnNotLike => { - super::matcher::is_arn(context_value) - && !super::matcher::arn_match(policy_value, context_value) + !value.arn_matches(context_value) } // IpAddress matches only when the caller address and the policy range // are the same family and the address is in the range. NotIpAddress @@ -525,6 +584,41 @@ fn compare_single(op: &ConditionOperator, context_value: &str, policy_value: &st } } +/// Whether `Null` with the policy value `policy_value` holds: `true` means the +/// key must be absent and `false` that it must be present, and any other text +/// matches nothing. `Null` does not expand a policy variable. IAM reads a value +/// written as one as a boolean, `false` unless the text is `true` ignoring +/// case, so the key must be present whatever the variable's value or default +/// (measured with the IAM policy simulator, cells N1a to N1f). The cells are +/// `2012-10-17` documents; a `2008-10-17` or no-`Version` document is read the +/// same way, since IAM reads the text without expanding it whatever the +/// `Version` (not measured). +fn null_holds(policy_value: &str, key_absent: bool) -> bool { + if policy_value.contains("${") { + // A text that contains `${` is never `true`, so it reads as `false`: + // the key must be present. + return !key_absent; + } + (policy_value == "true" && key_absent) || (policy_value == "false" && !key_absent) +} + +/// Whether `Bool` with the policy value `policy_value` matches the request +/// value: the two texts are equal ignoring case. `Bool` does not expand a +/// policy variable. IAM reads a value written as one as a boolean, `false` +/// unless the text is `true` ignoring case, so it matches a request value that +/// is not `true` whatever the variable's value (measured with the IAM policy +/// simulator, cells N2a to N2e). The cells are `2012-10-17` documents; a +/// `2008-10-17` or no-`Version` document is read the same way, since IAM reads +/// the text without expanding it whatever the `Version` (not measured). +fn bool_matches(context_value: &str, policy_value: &str) -> bool { + if policy_value.contains("${") { + // A text that contains `${` is never `true`, so it reads as `false` + // and matches a request value that is not `true`. + return !context_value.eq_ignore_ascii_case("true"); + } + context_value.eq_ignore_ascii_case(policy_value) +} + /// Parse two strings as f64 and compare them. /// Returns `false` if either value fails to parse. fn parse_f64_cmp(a: &str, b: &str, cmp: impl FnOnce(&f64, &f64) -> bool) -> bool { diff --git a/crates/auth/src/policy/condition/tests/multivalued.rs b/crates/auth/src/policy/condition/tests/multivalued.rs index aad148ae8..7369deb36 100644 --- a/crates/auth/src/policy/condition/tests/multivalued.rs +++ b/crates/auth/src/policy/condition/tests/multivalued.rs @@ -284,10 +284,11 @@ fn policy_variable_no_expansion_needed() { } #[test] -fn policy_variable_unresolvable_left_literal() { +fn policy_variable_unresolvable_matches_nothing() { let ctx = TestContext::new().with("k", vec!["${aws:PrincipalTag/Missing}"]); - // Unresolvable variable stays literal — matches if context has the literal - assert!(evaluate_condition( + // A variable whose key the request does not carry matches nothing, even + // the literal text of the variable (measured, issue #534 cell E9). + assert!(!evaluate_condition( &cond( ConditionOperator::StringEquals, "k", @@ -295,15 +296,23 @@ fn policy_variable_unresolvable_left_literal() { ), &ctx )); + // The negated operator holds against it (cell E9b). + assert!(evaluate_condition( + &cond( + ConditionOperator::StringNotEquals, + "k", + vec!["${aws:PrincipalTag/Missing}"] + ), + &ctx + )); } #[test] -fn policy_variable_naming_aws_tagkeys_is_left_literal() { - // A multivalued key has no single value to expand to. The condition value - // ${aws:TagKeys} is text that no tag key equals, so a Deny comparing - // aws:ResourceTag/team with it applies to no request, whichever tag key the - // request lists first. IAM answers the same (SimulateCustomPolicy, - // 2026-10-27). +fn policy_variable_naming_aws_tagkeys_has_no_value() { + // A multivalued key has no single value to expand to, so a Deny comparing + // aws:ResourceTag/team with ${aws:TagKeys} applies to no request, whichever + // tag key the request lists first. IAM answers the same + // (SimulateCustomPolicy, 2026-10-27). let team_is_tag_keys = cond( ConditionOperator::StringEquals, "aws:ResourceTag/team", @@ -318,15 +327,39 @@ fn policy_variable_naming_aws_tagkeys_is_left_literal() { "${{aws:TagKeys}} expanded for the keys {keys:?}" ); } - // The variable is text, so it equals the same text. - let ctx = TestContext::new() - .with("aws:ResourceTag/team", vec!["${aws:TagKeys}"]) - .with("aws:TagKeys", vec!["env", "owner"]); - assert!(evaluate_condition(&team_is_tag_keys, &ctx)); + // The variable has no value whether aws:TagKeys holds two values, one or + // none, and its text is not compared: the positive operators are false + // against it and the negated ones true, even for a request value equal to + // the text. These are the rows measured with SimulateCustomPolicy on + // 2026-10-31 (in the description of PR #541). + for (tag_keys, env) in [ + (Some(vec!["a", "b"]), "a"), + (Some(vec!["a", "b"]), "${aws:TagKeys}"), + (Some(vec!["a"]), "a"), + (None, "${aws:TagKeys}"), + ] { + let mut ctx = TestContext::new().with("aws:PrincipalTag/env", vec![env]); + if let Some(keys) = &tag_keys { + ctx = ctx.with("aws:TagKeys", keys.clone()); + } + for (op, holds) in [ + (ConditionOperator::StringEquals, false), + (ConditionOperator::StringLike, false), + (ConditionOperator::StringNotEquals, true), + (ConditionOperator::StringNotLike, true), + ] { + let c = cond(op.clone(), "aws:PrincipalTag/env", vec!["${aws:TagKeys}"]); + assert_eq!( + evaluate_condition(&c, &ctx), + holds, + "{op:?}, aws:TagKeys {tag_keys:?}, env {env}" + ); + } + } } #[test] -fn policy_variables_naming_the_dynamodb_multivalued_keys_are_left_literal() { +fn policy_variables_naming_the_dynamodb_multivalued_keys_have_no_value() { for key in ["dynamodb:LeadingKeys", "dynamodb:Attributes"] { let variable = format!("${{{key}}}"); let ctx = TestContext::new() diff --git a/crates/auth/src/policy/condition/tests/three_case.rs b/crates/auth/src/policy/condition/tests/three_case.rs index 896a5086e..baf63592b 100644 --- a/crates/auth/src/policy/condition/tests/three_case.rs +++ b/crates/auth/src/policy/condition/tests/three_case.rs @@ -81,9 +81,10 @@ fn unresolvable_key_is_unresolvable_for_every_operator() { fn variable_naming_unresolvable_key_is_unresolvable_with_or_without_default() { // Case 3 read through a condition value: the variable has no value to // expand to, and the service, which has one, would not use the default. - // Every operator yields Unresolvable, so a Deny applies and an Allow does - // not grant whatever the operator says. A later value of the condition - // that names the key counts as the first one does. + // Every operator that expands the value (the string and ARN ones) yields + // Unresolvable, so a Deny applies and an Allow does not grant whatever the + // operator says. A later value of the condition that names the key counts + // as the first one does. let ctx = TestContext::new() .with("aws:PrincipalTag/team", vec!["blue"]) .unresolvable("aws:PrincipalOrgID"); diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 85ca0288f..dbc45b0f8 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -131,9 +131,11 @@ fn strip_prefix_ci<'a>(key: &'a str, prefix: &str) -> Option<&'a str> { /// /// Two places read the set, through `is_unresolvable_key`. A condition fails /// closed when its key is listed, or when one of its values names a listed key -/// through a `${key}` or `${key, 'default'}` variable -/// (`condition::evaluate_condition_match`). A `Resource` or `NotResource` -/// pattern whose variable names a listed key fails closed in the same way +/// through a `${key}` or `${key, 'default'}` variable in a `2012-10-17` +/// document under a string or ARN operator, where the value is expanded +/// (`condition::evaluate_condition_match`); a value that is not expanded +/// reads no key. A `Resource` or `NotResource` pattern whose variable names a +/// listed key fails closed in the same way /// (`variables::resource_pattern_is_unresolvable`). In both, a default written /// beside a listed key is not used, because the service would have a value /// for the key. diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index d4bf1539a..b4b9c4aa9 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -186,7 +186,8 @@ fn ambiguous_request_tag_as_a_policy_variable_fails_the_statement_closed() { ), ConditionMatch::Unresolvable ); - // An absent request tag is not ambiguous: the variable stays literal, as before. + // An absent request tag is not ambiguous: with no default the variable has + // no value, so the value matches nothing. assert_eq!( evaluate_statement_conditions(&deny, true, &with_resource_tag(&[("team", "x")])), ConditionMatch::NoMatch @@ -253,13 +254,14 @@ fn ambiguous_request_tag_as_a_defaulted_policy_variable_fails_the_statement_clos } #[test] -fn policy_variable_naming_aws_tagkeys_is_left_literal_for_every_tag_order() { +fn policy_variable_naming_aws_tagkeys_has_no_value_for_every_tag_order() { use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions}; use crate::policy::document::{Condition, ConditionOperator}; // A Deny on aws:ResourceTag/team equal to ${aws:TagKeys} on a table tagged - // team=env. The variable names a multivalued key, so it stays literal and - // the statement applies to neither order of the request's tag keys. + // team=env. The variable names a multivalued key the request carries, so + // it has no value and the statement applies to neither order of the + // request's tag keys. let deny = [Condition { operator: ConditionOperator::StringEquals, key: "aws:ResourceTag/team".to_owned(), @@ -306,36 +308,51 @@ fn assume_role_ctx(principal: &[(&str, &str)], session: &[(&str, &str)]) -> Assu } #[test] -fn trust_policy_variable_naming_aws_tagkeys_is_left_literal() { +fn trust_policy_variable_naming_aws_tagkeys_has_no_value() { use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions}; use crate::policy::document::{Condition, ConditionOperator}; // AssumeRoleContext reports aws:TagKeys as multivalued, so a trust policy - // condition value ${aws:TagKeys} stays literal text. Expanded, it would be - // the first session tag key in the map's iteration order, which a test - // cannot choose. One context holds both session tag keys in one map, and - // each of its two principal tags holds one key, so an expansion to either - // key matches the condition on the principal tag that holds it. - let compare_with_tag_keys = |principal_tag: &str| { + // condition value ${aws:TagKeys} has no value: a positive operator is + // false against it and a negated one true, even for a principal tag that + // holds the variable's text. The IAM policy simulator gives these answers + // for an identity policy (2026-10-31, rows in the description of PR + // #541); the trust policy case was not measured. Expanded, it would be the + // first session tag key in the map's iteration order, which a test cannot + // choose. One context holds both session tag keys in one map, and each of + // its two principal tags holds one key, so an expansion to either key + // matches the condition on the principal tag that holds it. + let compare_with_tag_keys = |operator: ConditionOperator, principal_tag: &str| { [Condition { - operator: ConditionOperator::StringEquals, + operator, key: format!("aws:PrincipalTag/{principal_tag}"), values: vec!["${aws:TagKeys}".to_owned()], }] }; + let equals = |tag: &str| compare_with_tag_keys(ConditionOperator::StringEquals, tag); + let not_equals = |tag: &str| compare_with_tag_keys(ConditionOperator::StringNotEquals, tag); let session = [("env", "x"), ("owner", "x")]; let ctx = assume_role_ctx(&[("key_env", "env"), ("key_owner", "owner")], &session); for tag in ["key_env", "key_owner"] { assert_eq!( - evaluate_statement_conditions(&compare_with_tag_keys(tag), true, &ctx), + evaluate_statement_conditions(&equals(tag), true, &ctx), ConditionMatch::NoMatch, "${{aws:TagKeys}} expanded to the session tag key in {tag}" ); + assert_eq!( + evaluate_statement_conditions(¬_equals(tag), true, &ctx), + ConditionMatch::Match, + "${{aws:TagKeys}} expanded to the session tag key in {tag}" + ); } - // The variable is text, so it equals the same text. + // The variable's text is not compared. let ctx = assume_role_ctx(&[("team", "${aws:TagKeys}")], &session); assert_eq!( - evaluate_statement_conditions(&compare_with_tag_keys("team"), true, &ctx), + evaluate_statement_conditions(&equals("team"), true, &ctx), + ConditionMatch::NoMatch + ); + assert_eq!( + evaluate_statement_conditions(¬_equals("team"), true, &ctx), ConditionMatch::Match ); } diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index 998f08357..096e9c2f7 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -209,7 +209,7 @@ impl PolicyDocument { /// Unwrap a condition operator to its base, dropping any `ForAllValues:` / /// `ForAnyValue:` prefix and `IfExists` suffix. -fn base_operator(op: &ConditionOperator) -> &ConditionOperator { +pub(super) fn base_operator(op: &ConditionOperator) -> &ConditionOperator { match op { ConditionOperator::ForAllValues(inner) | ConditionOperator::ForAnyValue(inner) diff --git a/crates/auth/src/policy/evaluator.rs b/crates/auth/src/policy/evaluator.rs index 125a01876..e6af68b75 100644 --- a/crates/auth/src/policy/evaluator.rs +++ b/crates/auth/src/policy/evaluator.rs @@ -235,8 +235,9 @@ fn resource_matches( } /// Outcome of a statement's `Condition` block, three-valued. `policy` is the -/// statement's document: its `Version` decides how the condition values read -/// policy variables (`condition::evaluate_condition_with`). +/// statement's document: its `Version` decides whether the condition values +/// expand policy variables, which only a `2012-10-17` document does +/// (`condition::evaluate_condition_with`). fn statement_conditions( policy: &PolicyDocument, statement: &Statement, diff --git a/crates/auth/src/policy/evaluator/tests.rs b/crates/auth/src/policy/evaluator/tests.rs index ecb25aab5..27f6eeebd 100644 --- a/crates/auth/src/policy/evaluator/tests.rs +++ b/crates/auth/src/policy/evaluator/tests.rs @@ -60,6 +60,10 @@ fn parse(json: &str) -> PolicyDocument { mod case_three; mod combined; mod condition_variable_scope; +mod condition_variables; +mod condition_variables_arn; +mod condition_variables_arn_context; +mod condition_variables_operators; mod core; mod empty_values; mod resource_variables; diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs index 55314cb85..551bfcaa8 100644 --- a/crates/auth/src/policy/evaluator/tests/case_three.rs +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -7,8 +7,10 @@ //! same holds when the key is named by a `${key}` variable in `Resource` or //! `NotResource` of a `2012-10-17` document; a `2008-10-17` or no-`Version` //! document matches such a pattern as written. A variable naming the key in a -//! condition value fails the condition closed in the same way, with or without -//! a default. +//! condition value of a `2012-10-17` document fails the condition closed in +//! the same way, with or without a default, under the string and ARN +//! operators, which expand the value. A `2008-10-17` or no-`Version` document, +//! and the other operators, do not expand the value, so it reads no key. //! //! Every expectation here is an ExtendDB rule with no service measurement: //! the service has a value for such a key, so there is no service case to @@ -285,7 +287,8 @@ fn get_t_as_blue(policies: &[PolicyDocument], ctx: Ctx) -> AuthzDecision { fn a_condition_value_naming_an_unresolvable_key_fails_closed_with_or_without_a_default() { // As for the Deny gated on the key in a Condition above: the variable has // no value to expand to, and the default is not used, because the service - // would have a value for the key. The Deny applies whatever the operator. + // would have a value for the key. The Deny applies whatever the string or + // ARN operator. for (operator, value) in [ ("StringEquals", "${aws:PrincipalOrgID}"), ("StringEquals", "${aws:PrincipalOrgID, 'blue'}"), @@ -326,3 +329,136 @@ fn a_condition_value_naming_an_unresolvable_key_fails_closed_with_or_without_a_d assert_eq!(get_t_as_blue(&p, org("blue")), AuthzDecision::Deny); assert_eq!(get_t_as_blue(&p, org("red")), AuthzDecision::Allow); } + +#[test] +fn a_2008_or_no_version_condition_value_naming_an_unresolvable_key_is_compared_as_written() { + // Only a `2012-10-17` document expands a condition value. Here the value + // is text and reads no key, so there is nothing to fail closed on, with or + // without a default: the Deny applies only to a caller whose tag is that + // text. + for (label, version) in [ + ("2008-10-17", r#""Version":"2008-10-17","#), + ("no Version", ""), + ] { + for value in ["${aws:PrincipalOrgID}", "${aws:PrincipalOrgID, 'blue'}"] { + let deny = parse(&format!( + r#"{{{version}"Statement":[{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"StringEquals":{{"aws:PrincipalTag/team":"{value}"}}}}}}]}}"# + )); + let p = [ + only(r#"{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}"#), + deny, + ]; + let unresolvable = || Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + get_t_as_blue(&p, unresolvable()), + AuthzDecision::Allow, + "{label} {value}" + ); + let tagged_with_the_text = unresolvable().with("aws:PrincipalTag/team", vec![value]); + assert_eq!( + evaluate_policies( + &p, + None, + None, + "dynamodb:GetItem", + RES, + &tagged_with_the_text + ), + AuthzDecision::Deny, + "{label} {value}" + ); + } + } +} + +#[test] +fn a_condition_value_naming_an_unresolvable_key_under_a_numeric_operator_is_compared_as_written() { + // Only the string and ARN operators expand a condition value. Under + // `NumericLessThan` the value is its text, with or without a default, and + // reads no key. The text is not a number, so the Deny does not apply, and + // it does not fail closed. + for value in ["${aws:PrincipalOrgID}", "${aws:PrincipalOrgID, '600'}"] { + let p = allow_all_and(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"NumericLessThan":{{"aws:MultiFactorAuthAge":"{value}"}}}}}}"# + )); + let ctx = Ctx::empty() + .unresolvable("aws:PrincipalOrgID") + .with("aws:MultiFactorAuthAge", vec!["300"]); + assert_eq!(get_t_as_blue(&p, ctx), AuthzDecision::Allow, "{value}"); + } +} + +#[test] +fn an_arn_condition_value_naming_an_unresolvable_key_fails_closed_with_or_without_a_default() { + // The `Arn*` operators expand a condition value as the string ones do, so + // under `ArnLike` and `ArnNotLike` a value naming the key fails the + // condition closed too, with or without a default: the Deny applies and + // the Allow does not grant, for a request whose `aws:SourceArn` is an ARN. + // The value has the five `:` of an ARN written, so the `Arn*` field rule + // does not decide it. + let source = || { + Ctx::empty() + .unresolvable("aws:PrincipalOrgID") + .with("aws:SourceArn", vec![RES]) + }; + for value in [ + "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalOrgID}", + "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalOrgID, 'blue'}", + ] { + let deny = allow_all_and(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"ArnLike":{{"aws:SourceArn":"{value}"}}}}}}"# + )); + assert_eq!( + get_t_as_blue(&deny, source()), + AuthzDecision::Deny, + "ArnLike {value}" + ); + let allow = [only(&format!( + r#"{{"Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"ArnNotLike":{{"aws:SourceArn":"{value}"}}}}}}"# + ))]; + assert_eq!( + get_t_as_blue(&allow, source()), + AuthzDecision::Deny, + "ArnNotLike {value}" + ); + } + // In a `2008-10-17` document the value is text and reads no key, so the + // condition does not fail closed: the Deny applies only to a request whose + // `aws:SourceArn` is that text, and the Allow grants every other request. + let value = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalOrgID}"; + let in_2008 = |statement: String| { + parse(&format!( + r#"{{"Version":"2008-10-17","Statement":[{statement}]}}"# + )) + }; + let deny = [ + only(r#"{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}"#), + in_2008(format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"ArnLike":{{"aws:SourceArn":"{value}"}}}}}}"# + )), + ]; + let allow = [in_2008(format!( + r#"{{"Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"ArnNotLike":{{"aws:SourceArn":"{value}"}}}}}}"# + ))]; + let source_is_the_text = || { + Ctx::empty() + .unresolvable("aws:PrincipalOrgID") + .with("aws:SourceArn", vec![value]) + }; + assert_eq!(get_t_as_blue(&deny, source()), AuthzDecision::Allow); + assert_eq!( + get_t_as_blue(&deny, source_is_the_text()), + AuthzDecision::Deny + ); + assert_eq!(get_t_as_blue(&allow, source()), AuthzDecision::Allow); + assert_eq!( + get_t_as_blue(&allow, source_is_the_text()), + AuthzDecision::Deny + ); +} diff --git a/crates/auth/src/policy/evaluator/tests/condition_variable_scope.rs b/crates/auth/src/policy/evaluator/tests/condition_variable_scope.rs index a21a405d3..90e2a2337 100644 --- a/crates/auth/src/policy/evaluator/tests/condition_variable_scope.rs +++ b/crates/auth/src/policy/evaluator/tests/condition_variable_scope.rs @@ -3,9 +3,10 @@ //! Where a condition value reads the `${key, 'default'}` form and a trimmed //! key name: only in a `2012-10-17` document and under a string or ARN -//! operator. Elsewhere the text between the braces is the key name as -//! written, as it was before the default form was read, so the default form -//! is compared as written and `${key}` expands as before. +//! operator, the places where a condition value expands variables at all. +//! Elsewhere the value is not expanded and is compared as written, the +//! default form and `${key}` alike (`condition_variables.rs`, +//! `condition_variables_operators.rs`). //! //! The tests named after rows repeat cases measured with the IAM policy //! simulator on 2026-11-03, `dynamodb:GetItem`, two samples per row with the @@ -14,9 +15,10 @@ //! in a `2008-10-17` document, the set qualifier and `IfExists` wrapping a //! numeric operator, and the unresolvable key follow from the rows and were //! not measured. Row N4b is not repeated: IAM reads a `Bool` value written as -//! a variable as `false`, which ExtendDB does not do for either form. Row N6 -//! (`IpAddress`) is not repeated either: ExtendDB refuses a document whose -//! `IpAddress` value is not a CIDR, so such a value never reaches evaluation. +//! a variable as `false`, which `condition_variables_operators.rs` covers with +//! cells N2a to N2e. Row N6 (`IpAddress`) is not repeated either: ExtendDB +//! refuses a document whose `IpAddress` value is not a CIDR, so such a value +//! never reaches evaluation. use super::*; use serde_json::{Value, json}; @@ -292,8 +294,8 @@ fn a_defaulted_unresolvable_key_fails_closed_only_where_the_default_form_is_read // Deny fails closed. let p = deny_if(Some("2012-10-17"), "StringEquals", TEAM, value); assert_eq!(get(&p, &ctx), AuthzDecision::Deny); - // Elsewhere the value names the key `aws:PrincipalOrgID, 'blue'`, which - // the request does not carry, and is compared as written. + // Elsewhere the value is not expanded, reads no key, and is compared as + // written. let p = deny_if(Some("2008-10-17"), "StringEquals", TEAM, value); assert_eq!(get(&p, &ctx), AuthzDecision::Allow); let p = deny_if( diff --git a/crates/auth/src/policy/evaluator/tests/condition_variables.rs b/crates/auth/src/policy/evaluator/tests/condition_variables.rs new file mode 100644 index 000000000..fb8c92b1e --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/condition_variables.rs @@ -0,0 +1,384 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Policy variables in condition values (issue #534). +//! +//! One test per cell measured with the IAM policy simulator on 2026-10-24: +//! `Allow dynamodb:*` on `*` beside a `Deny dynamodb:GetItem` on `*` with the +//! condition under test, action `dynamodb:GetItem`. Cells E1 to E9b are +//! measured; the tests after them apply the nearest measured rule to shapes +//! the cells do not cover and say so. The cells measured on 2026-10-25, +//! including a wildcard that comes from a substituted value, are in +//! `condition_variables_operators.rs`. + +use super::*; + +const TEAM: &str = "aws:PrincipalTag/team"; +const OWNER: &str = "aws:ResourceTag/owner"; +const SRC: &str = "aws:SourceArn"; +const USER: &str = "arn:aws:iam::123456789012:user/"; +const BLUE: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/blue"; + +fn allow_all() -> PolicyDocument { + parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:*","Resource":"*" + }]}"#, + ) +} + +/// `Allow dynamodb:* on *` beside `Deny dynamodb:GetItem on *` if +/// `operator key: value`, in a document with the given `Version`. +fn deny_if(version: Option<&str>, operator: &str, key: &str, value: &str) -> Vec { + let version = version.map_or(String::new(), |v| format!(r#""Version":"{v}","#)); + vec![ + allow_all(), + parse(&format!( + r#"{{{version}"Statement":[{{ + "Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"{operator}":{{"{key}":"{value}"}}}} + }}]}}"# + )), + ] +} + +fn get(policies: &[PolicyDocument], ctx: &Ctx) -> AuthzDecision { + evaluate_policies(policies, None, None, "dynamodb:GetItem", BLUE, ctx) +} + +fn team(value: &str) -> Ctx { + Ctx::empty().with(TEAM, vec![value]) +} + +fn owner(value: &str) -> Ctx { + Ctx::empty().with(OWNER, vec![value]) +} + +const V2012: Option<&str> = Some("2012-10-17"); +const V2008: Option<&str> = Some("2008-10-17"); + +// --- Rule 2: escapes in a 2012-10-17 document --- + +#[test] +fn e1_string_like_escaped_star_does_not_match_text() { + let p = deny_if(V2012, "StringLike", TEAM, "a${*}"); + assert_eq!(get(&p, &team("abc")), AuthzDecision::Allow); +} + +#[test] +fn e2_string_like_escaped_star_matches_literal_star() { + let p = deny_if(V2012, "StringLike", TEAM, "a${*}"); + assert_eq!(get(&p, &team("a*")), AuthzDecision::Deny); +} + +#[test] +fn e2b_string_like_written_star_is_a_wildcard() { + let p = deny_if(V2012, "StringLike", TEAM, "a*"); + assert_eq!(get(&p, &team("abc")), AuthzDecision::Deny); +} + +#[test] +fn e3_string_equals_escaped_question_is_the_character() { + let p = deny_if(V2012, "StringEquals", TEAM, "${?}"); + assert_eq!(get(&p, &team("?")), AuthzDecision::Deny); +} + +#[test] +fn e3b_string_like_escaped_question_does_not_match_one_character() { + let p = deny_if(V2012, "StringLike", TEAM, "a${?}"); + assert_eq!(get(&p, &team("ab")), AuthzDecision::Allow); +} + +#[test] +fn e3c_string_like_escaped_question_matches_literal_question() { + let p = deny_if(V2012, "StringLike", TEAM, "a${?}"); + assert_eq!(get(&p, &team("a?")), AuthzDecision::Deny); +} + +#[test] +fn e4_string_equals_escaped_dollar_is_the_character() { + let p = deny_if(V2012, "StringEquals", TEAM, "${$}"); + assert_eq!(get(&p, &team("$")), AuthzDecision::Deny); +} + +#[test] +fn e4b_escaped_dollar_result_is_not_expanded_again() { + let p = deny_if(V2012, "StringEquals", TEAM, "a${$}{x}"); + assert_eq!(get(&p, &team("a${x}")), AuthzDecision::Deny); +} + +// --- Rule 1: 2008-10-17 and no-Version documents compare the text as written --- + +#[test] +fn e5_version_2008_escape_is_not_the_character() { + let p = deny_if(V2008, "StringEquals", TEAM, "${*}"); + assert_eq!(get(&p, &team("*")), AuthzDecision::Allow); +} + +#[test] +fn e5b_version_2008_escape_is_literal_text() { + let p = deny_if(V2008, "StringEquals", TEAM, "${*}"); + assert_eq!(get(&p, &team("${*}")), AuthzDecision::Deny); +} + +#[test] +fn e6_no_version_variable_is_not_expanded() { + let p = deny_if(None, "StringEquals", OWNER, "${aws:username}"); + let ctx = owner("alice").with("aws:username", vec!["alice"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Allow); +} + +#[test] +fn e6b_no_version_variable_is_literal_text() { + let p = deny_if(None, "StringEquals", OWNER, "${aws:username}"); + let ctx = owner("${aws:username}").with("aws:username", vec!["alice"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Deny); +} + +#[test] +fn e6c_version_2008_variable_is_literal_text() { + let p = deny_if(V2008, "StringEquals", OWNER, "${aws:username}"); + let ctx = owner("${aws:username}").with("aws:username", vec!["alice"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Deny); +} + +#[test] +fn version_2012_variable_is_expanded() { + // The issue's second row: the same statement as E6 under 2012-10-17. + let p = deny_if(V2012, "StringEquals", OWNER, "${aws:username}"); + let ctx = owner("alice").with("aws:username", vec!["alice"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Deny); +} + +// --- Rule 2 for ArnLike --- + +#[test] +fn e7_arn_like_escaped_star_does_not_match_text() { + let p = deny_if(V2012, "ArnLike", SRC, &format!("{USER}a${{*}}")); + let ctx = Ctx::empty().with(SRC, vec![&format!("{USER}abc")]); + assert_eq!(get(&p, &ctx), AuthzDecision::Allow); +} + +#[test] +fn e7b_arn_like_escaped_star_matches_literal_star() { + let p = deny_if(V2012, "ArnLike", SRC, &format!("{USER}a${{*}}")); + let ctx = Ctx::empty().with(SRC, vec![&format!("{USER}a*")]); + assert_eq!(get(&p, &ctx), AuthzDecision::Deny); +} + +// --- Rule 3: the default form --- + +#[test] +fn e8_default_form_expands_when_key_absent() { + let p = deny_if(V2012, "StringEquals", OWNER, "${aws:username, 'anon'}"); + assert_eq!(get(&p, &owner("anon")), AuthzDecision::Deny); +} + +#[test] +fn e8b_version_2008_default_form_is_literal_text() { + let p = deny_if(V2008, "StringEquals", OWNER, "${aws:username, 'anon'}"); + assert_eq!(get(&p, &owner("anon")), AuthzDecision::Allow); +} + +// --- Rule 4: an unresolved variable matches nothing --- + +#[test] +fn e9_unresolved_variable_does_not_match_its_own_text() { + let p = deny_if(V2012, "StringEquals", OWNER, "${aws:username}"); + assert_eq!(get(&p, &owner("${aws:username}")), AuthzDecision::Allow); +} + +#[test] +fn e9b_unresolved_variable_negated_operator_holds() { + let p = deny_if(V2012, "StringNotEquals", OWNER, "${aws:username}"); + assert_eq!(get(&p, &owner("alice")), AuthzDecision::Deny); +} + +// --- Derived from the cells above; not measured --- + +#[test] +fn derived_unresolved_variable_among_several_values_is_skipped() { + // Rule 4 per value: the resolved value still matches, the unresolved one + // matches nothing. + let p = vec![ + allow_all(), + parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:ResourceTag/owner":["${aws:username}","bob"]}} + }]}"#, + ), + ]; + assert_eq!(get(&p, &owner("bob")), AuthzDecision::Deny); + assert_eq!(get(&p, &owner("${aws:username}")), AuthzDecision::Allow); + assert_eq!(get(&p, &owner("alice")), AuthzDecision::Allow); +} + +#[test] +fn derived_unresolved_variable_negated_like_and_ignore_case_hold() { + // Rule 4 for the other negative string operators. + let p = deny_if(V2012, "StringNotLike", OWNER, "${aws:username}"); + assert_eq!(get(&p, &owner("alice")), AuthzDecision::Deny); + let p = deny_if(V2012, "StringNotEqualsIgnoreCase", OWNER, "${aws:username}"); + assert_eq!(get(&p, &owner("alice")), AuthzDecision::Deny); + let p = deny_if(V2012, "StringLike", OWNER, "${aws:username}"); + assert_eq!(get(&p, &owner("${aws:username}")), AuthzDecision::Allow); +} + +#[test] +fn derived_ignore_case_operators_read_escapes() { + // Rule 2 for StringEqualsIgnoreCase: the escape is the character and the + // comparison stays case-insensitive on the rest. + let p = deny_if(V2012, "StringEqualsIgnoreCase", TEAM, "A${*}"); + assert_eq!(get(&p, &team("a*")), AuthzDecision::Deny); + assert_eq!(get(&p, &team("abc")), AuthzDecision::Allow); +} + +#[test] +fn derived_set_operators_with_unresolved_variable() { + // Rule 4 under ForAnyValue and ForAllValues on a multivalued key: the + // unresolved value matches none of the context values, so a positive + // operator fails and a negative one holds. + let p = deny_if( + V2012, + "ForAnyValue:StringEquals", + "dynamodb:LeadingKeys", + "${aws:username}", + ); + let ctx = Ctx::empty().with("dynamodb:LeadingKeys", vec!["${aws:username}", "k"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Allow); + let p = deny_if( + V2012, + "ForAllValues:StringNotEquals", + "dynamodb:LeadingKeys", + "${aws:username}", + ); + assert_eq!(get(&p, &ctx), AuthzDecision::Deny); +} + +#[test] +fn derived_set_operator_escaped_star_is_literal() { + // Rule 2 under ForAnyValue:StringLike. + let p = deny_if( + V2012, + "ForAnyValue:StringLike", + "dynamodb:LeadingKeys", + "a${*}", + ); + let ctx = Ctx::empty().with("dynamodb:LeadingKeys", vec!["abc", "axy"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Allow); + let ctx = Ctx::empty().with("dynamodb:LeadingKeys", vec!["abc", "a*"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Deny); +} + +#[test] +fn derived_date_and_address_values_with_a_variable_are_not_expanded() { + use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions}; + use crate::policy::document::parse_conditions; + // IAM's operator reference lists policy variables as not supported for + // the date and IP address operators. The value is compared as written, the + // rule measured for `NumericEquals` (cell C4a), so it parses as neither a + // date nor an address and the condition does not match. + let p = deny_if( + V2012, + "DateEquals", + "aws:CurrentTime", + "${aws:PrincipalTag/when}", + ); + let ctx = Ctx::empty() + .with("aws:CurrentTime", vec!["2026-10-28T00:00:00Z"]) + .with("aws:PrincipalTag/when", vec!["2026-10-28T00:00:00Z"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Allow, "DateEquals"); + // An identity policy with this `IpAddress` value does not parse (a + // malformed CIDR). A trust policy's conditions go through + // `parse_conditions` alone, so the trust evaluator reaches this value. + let conditions = parse_conditions(&serde_json::json!({ + "IpAddress": {"aws:SourceIp": "${aws:PrincipalTag/net}"} + })) + .unwrap(); + let ctx = Ctx::empty() + .with("aws:SourceIp", vec!["10.1.2.3"]) + .with("aws:PrincipalTag/net", vec!["10.0.0.0/8"]); + assert_eq!( + evaluate_statement_conditions(&conditions, true, &ctx), + ConditionMatch::NoMatch, + "IpAddress" + ); +} + +#[test] +fn version_2008_string_like_wildcards_still_match() { + // Rule 1 leaves written wildcards as wildcards: only variable expansion is + // off in a 2008-10-17 document. + let p = deny_if(V2008, "StringLike", TEAM, "a*"); + assert_eq!(get(&p, &team("abc")), AuthzDecision::Deny); +} + +#[test] +fn permissions_boundary_and_session_policy_take_their_own_version() { + // The flag comes from the document being evaluated: a 2008-10-17 boundary + // compares its condition value as written while the identity policy is + // 2012-10-17. + let identity = allow_all(); + let boundary_2008 = parse( + r#"{"Version":"2008-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:*","Resource":"*", + "Condition":{"StringEquals":{"aws:ResourceTag/owner":"${aws:username}"}} + }]}"#, + ); + let ctx = owner("alice").with("aws:username", vec!["alice"]); + assert_eq!( + evaluate_policies( + std::slice::from_ref(&identity), + Some(&boundary_2008), + None, + "dynamodb:GetItem", + BLUE, + &ctx + ), + AuthzDecision::Deny + ); + let session_2012 = parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:*","Resource":"*", + "Condition":{"StringEquals":{"aws:ResourceTag/owner":"${aws:username}"}} + }]}"#, + ); + assert_eq!( + evaluate_policies( + std::slice::from_ref(&identity), + None, + Some(&session_2012), + "dynamodb:GetItem", + BLUE, + &ctx + ), + AuthzDecision::Allow + ); +} + +#[test] +fn explicit_deny_matches_arc_takes_the_document_version() { + let arcs = |docs: Vec| -> Vec> { + docs.into_iter().map(std::sync::Arc::new).collect() + }; + let ctx = owner("alice").with("aws:username", vec!["alice"]); + let deny_2008 = arcs(deny_if(V2008, "StringEquals", OWNER, "${aws:username}")); + assert!(!explicit_deny_matches_arc( + &deny_2008, + None, + None, + "dynamodb:GetItem", + BLUE, + &ctx + )); + let deny_2012 = arcs(deny_if(V2012, "StringEquals", OWNER, "${aws:username}")); + assert!(explicit_deny_matches_arc( + &deny_2012, + None, + None, + "dynamodb:GetItem", + BLUE, + &ctx + )); +} diff --git a/crates/auth/src/policy/evaluator/tests/condition_variables_arn.rs b/crates/auth/src/policy/evaluator/tests/condition_variables_arn.rs new file mode 100644 index 000000000..041d72937 --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/condition_variables_arn.rs @@ -0,0 +1,170 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! An `Arn*` condition value that contains `${` and has fewer than five `:` +//! written outside its `${...}` references, such as +//! `${aws:ResourceTag/blockedPrincipal}`. On 2026-10-28 the IAM policy +//! simulator refused the `ArnLike` Deny (cell A7) and the `ArnNotLike` Allow +//! (cell A8) below with `InvalidInput`, two samples each, and IAM Access +//! Analyzer reported `MISSING_ARN_FIELD` for the value. IAM gives no decision +//! for such a value, so ExtendDB fails the statement closed: a `Deny` applies +//! and an `Allow` does not grant. The `ArnEquals` and `ArnNotEquals` rows, the +//! qualified forms and the `2008-10-17` and no-`Version` documents apply the +//! same rule and were not sent to the simulator. + +use super::*; + +const BLUE: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/blue"; +const ALICE: &str = "arn:aws:iam::123456789012:user/alice"; + +fn allow_all() -> PolicyDocument { + parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:*","Resource":"*" + }]}"#, + ) +} + +fn statement(effect: &str, condition: &str) -> PolicyDocument { + statement_in(Some("2012-10-17"), effect, condition) +} + +/// [`statement`] in a document with the given `Version`, or with none. +fn statement_in(version: Option<&str>, effect: &str, condition: &str) -> PolicyDocument { + let version = version.map_or(String::new(), |v| format!(r#""Version":"{v}","#)); + parse(&format!( + r#"{{{version}"Statement":[{{ + "Effect":"{effect}","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{condition} + }}]}}"# + )) +} + +fn get(policies: &[PolicyDocument], ctx: &Ctx) -> AuthzDecision { + evaluate_policies(policies, None, None, "dynamodb:GetItem", BLUE, ctx) +} + +/// The caller `alice` on a table whose `blockedPrincipal` tag names her. +fn alice_blocked() -> Ctx { + Ctx::empty() + .with("aws:PrincipalArn", vec![ALICE]) + .with("aws:ResourceTag/blockedPrincipal", vec![ALICE]) +} + +/// The cells that are not `Deny` for each operator: A7 is `Allow dynamodb:*` +/// beside a `Deny` with the condition, A8 an `Allow` with the condition alone. +fn not_denied(operators: &[&str]) -> Vec { + not_denied_in(Some("2012-10-17"), operators) +} + +/// [`not_denied`] with the condition in a document with the given `Version`. +fn not_denied_in(version: Option<&str>, operators: &[&str]) -> Vec { + let mut wrong = Vec::new(); + for op in operators { + let condition = + format!(r#"{{"{op}":{{"aws:PrincipalArn":"${{aws:ResourceTag/blockedPrincipal}}"}}}}"#); + let a7 = vec![allow_all(), statement_in(version, "Deny", &condition)]; + if get(&a7, &alice_blocked()) != AuthzDecision::Deny { + wrong.push(format!("A7 {op}")); + } + let a8 = vec![statement_in(version, "Allow", &condition)]; + if get(&a8, &alice_blocked()) != AuthzDecision::Deny { + wrong.push(format!("A8 {op}")); + } + } + wrong +} + +#[test] +fn a_value_that_is_one_variable_fails_closed_under_every_arn_operator() { + let wrong = not_denied(&["ArnLike", "ArnNotLike", "ArnEquals", "ArnNotEquals"]); + assert!(wrong.is_empty(), "not denied: {wrong:?}"); +} + +#[test] +fn the_rule_reads_the_operator_inside_if_exists_and_the_set_qualifiers() { + let wrong = not_denied(&[ + "ArnNotLikeIfExists", + "ForAllValues:ArnNotLike", + "ForAnyValue:ArnLike", + ]); + assert!(wrong.is_empty(), "not denied: {wrong:?}"); +} + +#[test] +fn the_rule_holds_in_a_2008_document_and_in_one_with_no_version() { + // These cells were not sent to the simulator. IAM checks that an `Arn*` + // value has an ARN's six fields whatever the document's `Version`, so it + // refuses the same values in a `2008-10-17` or no-`Version` document, + // where the variable is not expanded, and ExtendDB fails them closed there. + let mut wrong = Vec::new(); + for (version, name) in [(Some("2008-10-17"), "2008-10-17"), (None, "no Version")] { + let operators = ["ArnLike", "ArnNotLike", "ArnEquals", "ArnNotEquals"]; + for cell in not_denied_in(version, &operators) { + wrong.push(format!("{name} {cell}")); + } + } + assert!(wrong.is_empty(), "not denied: {wrong:?}"); +} + +#[test] +fn a_variable_after_five_written_colons_still_matches() { + // The value has its six fields, so the variable is expanded and matched as + // before. `ArnLike` and `ArnEquals` on this shape are measured cells + // (`arn_like_and_arn_equals_read_a_substituted_star_as_a_character`); the + // `Not` forms are their negation. + let condition = |op: &str| { + format!( + r#"{{"{op}":{{"aws:PrincipalArn":"arn:aws:iam::123456789012:user/${{aws:username}}"}}}}"# + ) + }; + let caller = |name: &str| { + Ctx::empty() + .with("aws:PrincipalArn", vec![ALICE]) + .with("aws:username", vec![name]) + }; + for (op, alice, bob) in [ + ("ArnLike", AuthzDecision::Deny, AuthzDecision::Allow), + ("ArnEquals", AuthzDecision::Deny, AuthzDecision::Allow), + ("ArnNotLike", AuthzDecision::Allow, AuthzDecision::Deny), + ("ArnNotEquals", AuthzDecision::Allow, AuthzDecision::Deny), + ] { + let p = vec![allow_all(), statement("Deny", &condition(op))]; + assert_eq!(get(&p, &caller("alice")), alice, "{op} alice"); + assert_eq!(get(&p, &caller("bob")), bob, "{op} bob"); + } +} + +#[test] +fn five_written_colons_are_compared_as_written_in_a_2008_document_and_in_one_with_no_version() { + // Not sent to the simulator. The value has its six fields, so it does not + // fail closed, and the document does not expand the variable, so the text + // `user/${aws:username}` names no principal: the positive operators never + // match and the negated ones always hold, for `alice` and for `bob`. + let condition = |op: &str| { + format!( + r#"{{"{op}":{{"aws:PrincipalArn":"arn:aws:iam::123456789012:user/${{aws:username}}"}}}}"# + ) + }; + let caller = |name: &str| { + Ctx::empty() + .with("aws:PrincipalArn", vec![ALICE]) + .with("aws:username", vec![name]) + }; + for version in [Some("2008-10-17"), None] { + for (op, decision) in [ + ("ArnLike", AuthzDecision::Allow), + ("ArnEquals", AuthzDecision::Allow), + ("ArnNotLike", AuthzDecision::Deny), + ("ArnNotEquals", AuthzDecision::Deny), + ] { + let p = vec![allow_all(), statement_in(version, "Deny", &condition(op))]; + assert_eq!( + get(&p, &caller("alice")), + decision, + "{version:?} {op} alice" + ); + assert_eq!(get(&p, &caller("bob")), decision, "{version:?} {op} bob"); + } + } +} diff --git a/crates/auth/src/policy/evaluator/tests/condition_variables_arn_context.rs b/crates/auth/src/policy/evaluator/tests/condition_variables_arn_context.rs new file mode 100644 index 000000000..040110430 --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/condition_variables_arn_context.rs @@ -0,0 +1,136 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The four `Arn*` operators on a value whose variable may have no value, +//! against a context value that is an ARN and one that is not. +//! +//! One test per cell measured with the IAM policy simulator on 2026-10-31 +//! (the rows are in the description of PR #541): an `Allow dynamodb:GetItem` +//! on `*` alone in a `2012-10-17` document, with the condition +//! ` aws:SourceArn` against +//! `arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team}`, +//! action `dynamodb:GetItem`. `Allow` is the simulator's `allowed` and `Deny` +//! its `implicitDeny`. A context value that is not an ARN matches none of the +//! four operators, whatever the policy value. Against an ARN, a value whose +//! variable has no value matches nothing: `ArnLike` and `ArnEquals` are false +//! and `ArnNotLike` and `ArnNotEquals` true. + +use super::*; + +const SRC: &str = "aws:SourceArn"; +const TEAM: &str = "aws:PrincipalTag/team"; +const VALUE: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team}"; +const ARN_X: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/x"; +const NOT_AN_ARN: &str = "notanarn"; +const TABLE: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/probe"; + +/// GetItem under `Allow dynamodb:GetItem` on `*` if `operator aws:SourceArn` +/// matches [`VALUE`], for a caller whose `team` tag is `team` (absent when +/// `None`) and a request whose `aws:SourceArn` is `source_arn`. +fn get(operator: &str, team: Option<&str>, source_arn: &str) -> AuthzDecision { + let policy = parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"{operator}":{{"{SRC}":"{VALUE}"}}}} + }}]}}"# + )); + let mut ctx = Ctx::empty().with(SRC, vec![source_arn]); + if let Some(team) = team { + ctx = ctx.with(TEAM, vec![team]); + } + evaluate_policies(&[policy], None, None, "dynamodb:GetItem", TABLE, &ctx) +} + +// --- No `team` tag, `aws:SourceArn` an ARN --- + +#[test] +fn arn_like_with_no_team_against_an_arn_is_false() { + assert_eq!(get("ArnLike", None, ARN_X), AuthzDecision::Deny); +} + +#[test] +fn arn_equals_with_no_team_against_an_arn_is_false() { + assert_eq!(get("ArnEquals", None, ARN_X), AuthzDecision::Deny); +} + +#[test] +fn arn_not_like_with_no_team_against_an_arn_is_true() { + assert_eq!(get("ArnNotLike", None, ARN_X), AuthzDecision::Allow); +} + +#[test] +fn arn_not_equals_with_no_team_against_an_arn_is_true() { + assert_eq!(get("ArnNotEquals", None, ARN_X), AuthzDecision::Allow); +} + +// --- No `team` tag, `aws:SourceArn` not an ARN --- + +#[test] +fn arn_like_with_no_team_against_a_non_arn_is_false() { + assert_eq!(get("ArnLike", None, NOT_AN_ARN), AuthzDecision::Deny); +} + +#[test] +fn arn_equals_with_no_team_against_a_non_arn_is_false() { + assert_eq!(get("ArnEquals", None, NOT_AN_ARN), AuthzDecision::Deny); +} + +#[test] +fn arn_not_like_with_no_team_against_a_non_arn_is_false() { + assert_eq!(get("ArnNotLike", None, NOT_AN_ARN), AuthzDecision::Deny); +} + +#[test] +fn arn_not_equals_with_no_team_against_a_non_arn_is_false() { + assert_eq!(get("ArnNotEquals", None, NOT_AN_ARN), AuthzDecision::Deny); +} + +// --- `team=x`, `aws:SourceArn` the ARN the value expands to --- + +#[test] +fn arn_like_with_team_against_the_expanded_arn_is_true() { + assert_eq!(get("ArnLike", Some("x"), ARN_X), AuthzDecision::Allow); +} + +#[test] +fn arn_equals_with_team_against_the_expanded_arn_is_true() { + assert_eq!(get("ArnEquals", Some("x"), ARN_X), AuthzDecision::Allow); +} + +#[test] +fn arn_not_like_with_team_against_the_expanded_arn_is_false() { + assert_eq!(get("ArnNotLike", Some("x"), ARN_X), AuthzDecision::Deny); +} + +#[test] +fn arn_not_equals_with_team_against_the_expanded_arn_is_false() { + assert_eq!(get("ArnNotEquals", Some("x"), ARN_X), AuthzDecision::Deny); +} + +// --- `team=x`, `aws:SourceArn` not an ARN --- + +#[test] +fn arn_like_with_team_against_a_non_arn_is_false() { + assert_eq!(get("ArnLike", Some("x"), NOT_AN_ARN), AuthzDecision::Deny); +} + +#[test] +fn arn_equals_with_team_against_a_non_arn_is_false() { + assert_eq!(get("ArnEquals", Some("x"), NOT_AN_ARN), AuthzDecision::Deny); +} + +#[test] +fn arn_not_like_with_team_against_a_non_arn_is_false() { + assert_eq!( + get("ArnNotLike", Some("x"), NOT_AN_ARN), + AuthzDecision::Deny + ); +} + +#[test] +fn arn_not_equals_with_team_against_a_non_arn_is_false() { + assert_eq!( + get("ArnNotEquals", Some("x"), NOT_AN_ARN), + AuthzDecision::Deny + ); +} diff --git a/crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs b/crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs new file mode 100644 index 000000000..4e6795dee --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs @@ -0,0 +1,175 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Policy variables in condition values, the review cells of issue #534: +//! which operators expand a variable, a wildcard inside a substituted value, +//! `IfExists` and the set qualifiers with an unresolved variable. Every +//! expected decision was measured with the IAM policy simulator on +//! 2026-10-25 (`Allow dynamodb:*` on `*` beside a `Deny dynamodb:GetItem` on +//! `*` with the condition under test). The `Null` and `Bool` cells N1a to N1f +//! and N2a to N2e were measured the same way on 2026-10-28, two samples each; +//! N1f is an `Allow dynamodb:GetItem` with the condition and nothing else. +//! `bool_reads_a_request_value_true_in_any_case_as_true` is not measured. + +use super::*; + +const BLUE: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/blue"; +const USER: &str = "arn:aws:iam::123456789012:user/"; + +fn deny_if(condition: &str) -> Vec { + vec![ + parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:*","Resource":"*" + }]}"#, + ), + parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{{ + "Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{condition} + }}]}}"# + )), + ] +} + +fn get(policies: &[PolicyDocument], ctx: &Ctx) -> AuthzDecision { + evaluate_policies(policies, None, None, "dynamodb:GetItem", BLUE, ctx) +} + +// --- Only the string and ARN operators expand a variable (cell C4a) --- + +#[test] +fn numeric_equals_compares_a_variable_as_written() { + let p = deny_if(r#"{"NumericEquals":{"aws:MultiFactorAuthAge":"${aws:PrincipalTag/age}"}}"#); + let ctx = Ctx::empty() + .with("aws:PrincipalTag/age", vec!["300"]) + .with("aws:MultiFactorAuthAge", vec!["300"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Allow); +} + +// --- `Null` and `Bool` read a variable as the boolean `false` (N1a to N2e) --- + +fn allow_only(condition: &str) -> Vec { + vec![parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{condition} + }}]}}"# + ))] +} + +/// The cells whose decision differs from the one IAM gave. +fn differ(cells: &[(&'static str, Vec, Ctx, AuthzDecision)]) -> Vec<&'static str> { + cells + .iter() + .filter(|(_, policies, ctx, iam)| get(policies, ctx) != *iam) + .map(|(cell, ..)| *cell) + .collect() +} + +#[test] +fn null_reads_a_variable_as_false() { + use AuthzDecision::{Allow, Deny}; + // `Null` on `team` holds when `team` is present, whatever `flag` holds. + let null = |value: &str| format!(r#"{{"Null":{{"aws:PrincipalTag/team":"{value}"}}}}"#); + let flag = "${aws:PrincipalTag/flag}"; + let flag_or_false = "${aws:PrincipalTag/flag, 'false'}"; + let team = || Ctx::empty().with("aws:PrincipalTag/team", vec!["blue"]); + let flag_true = |ctx: Ctx| ctx.with("aws:PrincipalTag/flag", vec!["true"]); + let cells = [ + ("N1a", deny_if(&null(flag)), team(), Deny), + ("N1b", deny_if(&null(flag_or_false)), team(), Deny), + ("N1c", deny_if(&null(flag)), flag_true(team()), Deny), + ("N1d", deny_if(&null(flag)), flag_true(Ctx::empty()), Allow), + ("N1e", deny_if(&null(flag)), Ctx::empty(), Allow), + ("N1f", allow_only(&null(flag_or_false)), team(), Allow), + ]; + let wrong = differ(&cells); + assert!(wrong.is_empty(), "differ from IAM: {wrong:?}"); +} + +#[test] +fn bool_reads_a_variable_as_false() { + use AuthzDecision::{Allow, Deny}; + // `Bool` holds when `aws:SecureTransport` is `false`, whatever `flag` + // holds. N2c is cell C4b, measured on 2026-10-25. + let p = || deny_if(r#"{"Bool":{"aws:SecureTransport":"${aws:PrincipalTag/flag}"}}"#); + let request = |secure: &str, flag: Option<&str>| { + let ctx = Ctx::empty().with("aws:SecureTransport", vec![secure]); + match flag { + Some(flag) => ctx.with("aws:PrincipalTag/flag", vec![flag]), + None => ctx, + } + }; + let cells = [ + ("N2a", p(), request("false", Some("false")), Deny), + ("N2b", p(), request("false", Some("true")), Deny), + ("N2c", p(), request("true", Some("true")), Allow), + ("N2d", p(), request("true", Some("false")), Allow), + ("N2e", p(), request("false", None), Deny), + ]; + let wrong = differ(&cells); + assert!(wrong.is_empty(), "differ from IAM: {wrong:?}"); +} + +#[test] +fn bool_reads_a_request_value_true_in_any_case_as_true() { + // Not measured. Cell N2c with the request value spelled `TRUE`: the + // request value is read as `true` ignoring case, as a `Bool` value without + // a variable compares it, so the Deny does not apply. + let p = deny_if(r#"{"Bool":{"aws:SecureTransport":"${aws:PrincipalTag/flag}"}}"#); + let ctx = Ctx::empty() + .with("aws:SecureTransport", vec!["TRUE"]) + .with("aws:PrincipalTag/flag", vec!["true"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Allow); +} + +// --- A wildcard inside a substituted value is literal (cells C1, C2f, C2g) --- + +#[test] +fn string_like_reads_a_substituted_star_as_a_character() { + let p = deny_if(r#"{"StringLike":{"aws:ResourceTag/owner":"${aws:PrincipalTag/team}"}}"#); + let team = |owner: &str| { + Ctx::empty() + .with("aws:PrincipalTag/team", vec!["a*"]) + .with("aws:ResourceTag/owner", vec![owner]) + }; + assert_eq!(get(&p, &team("abc")), AuthzDecision::Allow); + assert_eq!(get(&p, &team("a*")), AuthzDecision::Deny); +} + +#[test] +fn arn_like_and_arn_equals_read_a_substituted_star_as_a_character() { + for op in ["ArnLike", "ArnEquals"] { + let p = deny_if(&format!( + r#"{{"{op}":{{"aws:SourceArn":"{USER}${{aws:PrincipalTag/name}}"}}}}"# + )); + let ctx = |name: &str| { + Ctx::empty() + .with("aws:PrincipalTag/name", vec![name]) + .with("aws:SourceArn", vec![format!("{USER}alice").as_str()]) + }; + assert_eq!(get(&p, &ctx("alice")), AuthzDecision::Deny, "{op}"); + assert_eq!(get(&p, &ctx("al*")), AuthzDecision::Allow, "{op}"); + } +} + +// --- IfExists and the set qualifiers with an unresolved variable (C3, C6) --- + +#[test] +fn if_exists_with_the_key_present_and_the_variable_unresolved() { + let owner = Ctx::empty().with("aws:ResourceTag/owner", vec!["alice"]); + let p = deny_if(r#"{"StringEqualsIfExists":{"aws:ResourceTag/owner":"${aws:username}"}}"#); + assert_eq!(get(&p, &owner), AuthzDecision::Allow); + let p = deny_if(r#"{"StringNotEqualsIfExists":{"aws:ResourceTag/owner":"${aws:username}"}}"#); + assert_eq!(get(&p, &owner), AuthzDecision::Deny); +} + +#[test] +fn set_qualifiers_with_an_unresolved_variable() { + let keys = Ctx::empty().with("aws:TagKeys", vec!["a", "b"]); + let p = deny_if(r#"{"ForAllValues:StringNotEquals":{"aws:TagKeys":"${aws:username}"}}"#); + assert_eq!(get(&p, &keys), AuthzDecision::Deny); + let p = deny_if(r#"{"ForAnyValue:StringEquals":{"aws:TagKeys":"${aws:username}"}}"#); + assert_eq!(get(&p, &keys), AuthzDecision::Allow); +} diff --git a/crates/auth/src/policy/evaluator/tests/variable_values.rs b/crates/auth/src/policy/evaluator/tests/variable_values.rs index 7f3ec7099..cc2025a88 100644 --- a/crates/auth/src/policy/evaluator/tests/variable_values.rs +++ b/crates/auth/src/policy/evaluator/tests/variable_values.rs @@ -4,16 +4,21 @@ //! Text that comes from a variable value or a default: it is not expanded a //! second time, and a `*` or `?` in it is not a wildcard. Also the two //! condition-value rules, the `${key, 'default'}` form and no expansion for a -//! multivalued key, under the set operators and `IfExists`, and the default -//! form under `Null`, which does not read it. +//! multivalued key, under the set operators and `IfExists`, and the same two +//! value shapes under `Null`, which expands no variable and reads the value +//! as the boolean `false`. //! -//! Except for the `Null` row, none of these expectations was measured on its -//! own. Each follows from a rule the PR #535 body table measured with the IAM -//! policy simulator on 2026-10-24: text from a value is matched literally -//! (`team=bl*` matches only `table/bl*`), and a condition value naming -//! `dynamodb:LeadingKeys` is not expanded under `StringEquals`. The default -//! form in a condition value was measured on 2026-11-03 under `StringEquals`, -//! `ArnLike` and `Null` (`condition_variable_scope.rs`). +//! The `Null` cases have the shapes of cells N1f and N1a, measured with the +//! IAM policy simulator on 2026-10-28 (`condition_variables_operators.rs`), +//! and of row N5, measured on 2026-11-03 (`condition_variable_scope.rs`). +//! No other expectation here was measured on its own. Each follows from a +//! rule the PR #535 body table measured with the IAM policy simulator on +//! 2026-10-24: text from a value is matched literally (`team=bl*` matches only +//! `table/bl*`), and a condition value naming `dynamodb:LeadingKeys` is not +//! expanded under `StringEquals`. The default form in a condition value was +//! measured under `StringEquals` (cell E8, `condition_variables.rs`) and, on +//! 2026-11-03, under `StringEquals`, `ArnLike` and `Null` +//! (`condition_variable_scope.rs`). use super::*; @@ -146,11 +151,27 @@ fn the_default_form_expands_under_set_operators_and_if_exists() { } } +#[test] +fn a_null_value_written_with_a_default_reads_as_false() { + // `Null` expands no variable. The text `${aws:PrincipalTag/flag, 'false'}` + // is not `true`, so it reads as `false` and the Allow needs `team`; the + // default is not used. With `team` this is cell N1f. + let p = allow_get_if( + "Null", + "aws:PrincipalTag/team", + "${aws:PrincipalTag/flag, 'false'}", + ); + let with_team = Ctx::empty().with("aws:PrincipalTag/team", vec!["blue"]); + assert_eq!(get(&p, "T", &with_team), AuthzDecision::Allow); + assert_eq!(get(&p, "T", &Ctx::empty()), AuthzDecision::Deny); +} + #[test] fn the_default_form_is_not_read_in_a_null_condition() { // Row N5 of `condition_variable_scope.rs`: the Deny does not apply to a - // caller with neither tag. Read with its default, the value would be - // `true` and the Deny would apply. + // caller with neither tag. The text `${aws:PrincipalTag/f, 'true'}` reads + // as `false`, so the Deny needs `team`. Read with its default, the value + // would be `true` and the Deny would apply. let p = deny_get_if( "Null", "aws:PrincipalTag/team", @@ -176,11 +197,14 @@ fn a_multivalued_key_in_a_condition_value_is_not_expanded_under_any_form() { let p = deny_get_if(operator, key, "${dynamodb:LeadingKeys}"); assert_eq!(get(&p, "T", &ctx), AuthzDecision::Allow, "{operator}"); } - // Under `Null`, the first value `false` would say that `team` is present, - // which it is, and the Deny would apply. + // `Null` expands no variable. The text `${dynamodb:LeadingKeys}` is not + // `true`, so it reads as `false`, which says that `team` is present. It + // is, and the Deny applies, as in cell N1a; the leading key is not read. + // Expanded to its first value, the text would be `true`, which says that + // `team` is absent, and the Deny would not apply. let null = Ctx::empty() - .with("dynamodb:LeadingKeys", vec!["false"]) + .with("dynamodb:LeadingKeys", vec!["true"]) .with("aws:PrincipalTag/team", vec!["blue"]); let p = deny_get_if("Null", "aws:PrincipalTag/team", "${dynamodb:LeadingKeys}"); - assert_eq!(get(&p, "T", &null), AuthzDecision::Allow, "Null"); + assert_eq!(get(&p, "T", &null), AuthzDecision::Deny, "Null"); } diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index 4fab6d4a8..753fe82f0 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -18,17 +18,23 @@ //! does not carry `key`. //! - `${*}`, `${?}`, `${$}`: a literal `*`, `?` or `$`. //! -//! In a `Resource` or `NotResource` pattern the text written in the policy -//! keeps `*` and `?` as wildcards, while text that comes from a variable -//! value, a default or an escape is matched literally, so a tag value or an -//! escaped `*` cannot widen a pattern. A variable whose key the request does -//! not carry, with no default, makes the pattern match nothing. Both rules -//! were measured with the IAM policy simulator (issue #533). A variable whose -//! key the service populates but the context cannot resolve (a key listed in +//! In a `Resource` or `NotResource` pattern, and in a `StringLike`, `ArnLike` +//! or `ArnEquals` condition value, the text written in the policy keeps `*` +//! and `?` as wildcards, while text that comes from a variable value, a +//! default or an escape is matched literally, so a tag value or an escaped +//! `*` cannot widen a pattern. A variable whose key the request does not +//! carry, with no default, makes the pattern or value match nothing. Both +//! rules were measured with the IAM policy simulator (issues #533 and #534). +//! In a `Resource` or `NotResource` pattern, and in a condition value that is +//! expanded, a variable whose key the service populates but the context +//! cannot resolve (an `aws:RequestTag/` that the request names under two +//! spellings with different values, or a key listed in //! `context::FAIL_CLOSED_GLOBAL_KEYS`) leaves the match unknown, and the -//! evaluator fails closed on it as it does on such a key in a condition. -//! Only a document whose `Version` is `2012-10-17` expands variables; the -//! caller decides that with [`PolicyDocument::expands_policy_variables`]. +//! evaluator fails closed on it as it does on such a key in a condition. Only +//! a document whose `Version` is `2012-10-17` expands variables, in patterns +//! and in condition values alike; a `2008-10-17` document, or one with no +//! `Version`, compares the text as written. The caller decides that with +//! [`PolicyDocument::expands_policy_variables`]. //! //! ARN matching here follows the same field rule as //! [`super::matcher::arn_match`]: the first five `:` separate fields, the @@ -41,16 +47,16 @@ use super::context::ConditionContext; use super::document::{PolicyDocument, PolicyParseError}; -use super::matcher::arn_match; +use super::matcher::{arn_match, wildcard_match}; use serde_json::Value; impl PolicyDocument { /// Whether `${key}` policy variables in `Resource` and `NotResource` - /// patterns are expanded. + /// patterns and in condition values are expanded. /// /// IAM expands them only in a `2012-10-17` document; a `2008-10-17` - /// document, or one with no `Version`, matches the pattern as written. - /// Measured with the IAM policy simulator (issue #533). + /// document, or one with no `Version`, matches the pattern or value as + /// written. Measured with the IAM policy simulator (issues #533 and #534). #[must_use] pub fn expands_policy_variables(&self) -> bool { self.version.as_deref() == Some("2012-10-17") @@ -87,7 +93,7 @@ const COLON: u16 = 0x3A; /// One unit of an expanded pattern. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Token { +pub enum Token { /// `*` written in the policy text: matches zero or more UTF-16 code units. Star, /// `?` written in the policy text: matches exactly one UTF-16 code unit. @@ -174,15 +180,14 @@ enum Part<'a> { Ref(&'a str, Reference<'a>), } -/// Split `value` into plain text and `${...}` references. A `${` with no -/// closing `}` is plain text, as before. +/// Split `value` into plain text and `${...}` references, read by +/// [`parse_reference`]. A `${` with no closing `}` is plain text, as before. /// -/// With `iam_syntax` the text between the braces is read by -/// [`parse_reference`]. Without it the whole text, as written, is the key -/// name, which is how a condition value was read before the default form: -/// nothing is trimmed, and `${key, 'default'}` names the key -/// `key, 'default'`, which no request carries. -fn parts(value: &str, iam_syntax: bool) -> Vec> { +/// Every caller reads a pattern or value that is expanded, in a `2012-10-17` +/// document and, for a condition value, under a string or ARN operator. A +/// value that is not expanded is compared as written and is not split +/// ([`expand_condition_value`]). +fn parts(value: &str) -> Vec> { let mut out = Vec::new(); let mut rest = value; while let Some(start) = rest.find("${") { @@ -191,16 +196,7 @@ fn parts(value: &str, iam_syntax: bool) -> Vec> { match after_start.find('}') { Some(end) => { let raw = &rest[start..start + 3 + end]; - let inner = &after_start[..end]; - let reference = if iam_syntax { - parse_reference(inner) - } else { - Reference::Key { - name: inner, - default: None, - } - }; - out.push(Part::Ref(raw, reference)); + out.push(Part::Ref(raw, parse_reference(&after_start[..end]))); rest = &after_start[end + 1..]; } None => { @@ -263,7 +259,7 @@ fn resolve<'a>( fn expand_tokens(pattern: &str, context: &impl ConditionContext) -> Result, Missing> { let mut tokens = Vec::with_capacity(pattern.len()); let mut unresolvable = false; - for part in parts(pattern, true) { + for part in parts(pattern) { match part { Part::Text(text) => tokens.extend(text.encode_utf16().map(Token::written)), Part::Ref(_, Reference::Escape(byte)) => tokens.push(Token::Unit(u16::from(byte))), @@ -322,60 +318,165 @@ pub fn resource_pattern_is_unresolvable( } /// The key names the `${key}` and `${key, 'default'}` variables in `value` -/// read, in the order [`expand_condition_value`] reads them with the same -/// `iam_syntax`. +/// read, in the order [`expand_condition_value`] reads them. /// -/// With `iam_syntax` the default is not part of the name, so a caller that -/// asks the context about each name sees the key behind -/// `${aws:RequestTag/env, 'x'}` as it sees the one behind -/// `${aws:RequestTag/env}`. Without it the name is the text between the -/// braces as written. The `${*}`, `${?}` and `${$}` escapes name no key, and -/// neither does a `${` with no closing brace, as both expand to themselves. -pub fn variable_key_names(value: &str, iam_syntax: bool) -> impl Iterator { - parts(value, iam_syntax) - .into_iter() - .filter_map(|part| match part { - Part::Ref(_, Reference::Key { name, .. }) => Some(name), - Part::Text(_) | Part::Ref(_, Reference::Escape(_)) => None, - }) +/// The default is not part of the name, and the name is trimmed as +/// [`parse_reference`] trims it, so a caller that asks the context about each +/// name sees the key behind `${aws:RequestTag/env, 'x'}` as it sees the one +/// behind `${aws:RequestTag/env}`. The `${*}`, `${?}` and `${$}` escapes name +/// no key, and neither does a `${` with no closing brace, as both expand to +/// themselves. The caller asks only for a value that is expanded; a value +/// that is not expanded reads no key. +pub fn variable_key_names(value: &str) -> impl Iterator { + parts(value).into_iter().filter_map(|part| match part { + Part::Ref(_, Reference::Key { name, .. }) => Some(name), + Part::Text(_) | Part::Ref(_, Reference::Escape(_)) => None, + }) } -/// Expand variables in a condition value. +/// A condition value after policy variable expansion for one request. /// -/// `${key}` is replaced by its value, and with `iam_syntax` so is -/// `${key, 'default'}`; a variable whose key the request does not carry, -/// with no default, is left as written, and so are the `${*}`, `${?}` and -/// `${$}` escapes, as before. A variable whose key the context reports -/// unresolvable is also left as written, with or without a default: the -/// service would have a value for the key, so the default is not used, and -/// the caller fails the condition closed instead of comparing the text -/// (`condition::evaluate_condition_match`, which finds the key through -/// [`variable_key_names`]). +/// The `StringLike` and `ArnLike` operators, and `ArnEquals`, which IAM +/// evaluates as `ArnLike` (PR #471), read `*` and `?` as wildcards only where +/// the policy wrote them: a `*` or `?` that came from a variable value, a +/// default or an escape is matched literally, the same rule as a `Resource` +/// pattern. Measured with the IAM policy simulator (issue #534): `StringLike` +/// `a${*}` matches `a*` and not `abc`, `a${?}` matches `a?` and not `ab`, and +/// `ArnLike` `.../user/a${*}` matches `.../user/a*` and not `.../user/abc`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConditionValue { + /// The value as written: it has no `${`, or the document does not expand + /// variables. Every `*` and `?` is a wildcard for the Like and Arn + /// operators. + Literal(String), + /// The value with its variables, defaults and escapes expanded. + Expanded { + /// The expanded text, compared by `StringEquals`, `StringNotEquals` + /// and their `IgnoreCase` forms. Only the string and ARN operators + /// expand a value, so no other operator receives this variant, and the + /// Like and Arn operators match `tokens`. + text: String, + /// The expanded text with the `*` and `?` the policy wrote marked as + /// wildcards, matched by `StringLike`, `StringNotLike` and the four + /// Arn operators. + tokens: Vec, + }, + /// A variable left nothing to compare: a `${key}` with no default named a + /// key the request does not carry, or a variable named a multivalued key + /// the request carries or a key the context reports unresolvable, with or + /// without a default. The value matches nothing: a positive operator is + /// false against it and a negative operator true (measured: `StringEquals` + /// `${aws:username}` with no `aws:username` does not match the literal + /// text `${aws:username}`, and `StringNotEquals` with the same value + /// matches `alice`). A condition whose value names an unresolvable key + /// fails closed before the value is compared + /// (`condition::evaluate_condition_match`). + Unresolved, +} + +impl ConditionValue { + /// The text the value compares as, `None` for an unresolved variable. + #[must_use] + pub fn text(&self) -> Option<&str> { + match self { + Self::Literal(text) | Self::Expanded { text, .. } => Some(text), + Self::Unresolved => None, + } + } + + /// Whether the value, read as a `StringLike` pattern, matches `value`. + #[must_use] + pub fn wildcard_matches(&self, value: &str) -> bool { + match self { + Self::Literal(pattern) => wildcard_match(pattern, value), + Self::Expanded { tokens, .. } => { + let units: Vec = value.encode_utf16().collect(); + match_tokens(tokens, &units) + } + Self::Unresolved => false, + } + } + + /// Whether the value, read as an `ArnLike` pattern, matches `value`. + #[must_use] + pub fn arn_matches(&self, value: &str) -> bool { + match self { + Self::Literal(pattern) => arn_match(pattern, value), + Self::Expanded { tokens, .. } => arn_match_tokens(tokens, value), + Self::Unresolved => false, + } + } +} + +/// Expand variables in a condition value. /// -/// The caller passes `iam_syntax` only where IAM reads the default form: in -/// a `2012-10-17` document and under a string or ARN operator. Elsewhere the -/// text between the braces is the key name as written ([`parts`]), so -/// `${key, 'default'}` stays as written, and `${key}` expands as it did -/// before the default form was read. +/// With `expand` false (a `2008-10-17` document, or one with no `Version`, or +/// an operator other than a string or ARN one), or when `value` has no `${`, +/// the value is compared as written: `${aws:username}` is the +/// eleven-character text and `${*}` the four-character text (measured, issue +/// #534). Otherwise `${key}` and `${key, 'default'}` are replaced by their +/// value, `${*}`, `${?}` and `${$}` by `*`, `?` and `$` (the result is not +/// expanded again: `a${$}{x}` is the text `a${x}`), and a variable whose key +/// the request does not carry, with no default, makes the value +/// [`ConditionValue::Unresolved`]. So does a variable whose key the context +/// reports unresolvable, with or without a default: the service would have a +/// value for the key, so the default is not used, and the caller fails the +/// condition closed before comparing (`condition::evaluate_condition_match`, +/// which finds the key through [`variable_key_names`]). The text between the +/// braces is read as [`parse_reference`] reads it, trimmed of the code points +/// up to U+0020. #[must_use] pub fn expand_condition_value( value: &str, - iam_syntax: bool, + expand: bool, context: &impl ConditionContext, -) -> String { - if !value.contains("${") { - return value.to_owned(); +) -> ConditionValue { + if !expand || !value.contains("${") { + return ConditionValue::Literal(value.to_owned()); } - let mut result = String::with_capacity(value.len()); - for part in parts(value, iam_syntax) { + let mut text = String::with_capacity(value.len()); + let mut tokens = Vec::with_capacity(value.len()); + for part in parts(value) { match part { - Part::Text(text) | Part::Ref(text, Reference::Escape(_)) => result.push_str(text), - Part::Ref(raw, Reference::Key { name, default }) => { - result.push_str(resolve(name, default, context).unwrap_or(raw)); + Part::Text(written) => { + text.push_str(written); + tokens.extend(written.encode_utf16().map(Token::written)); + } + Part::Ref(_, Reference::Escape(byte)) => { + text.push(char::from(byte)); + tokens.push(Token::Unit(u16::from(byte))); + } + Part::Ref(_, Reference::Key { name, default }) => { + // `Missing::Absent` and `Missing::Unresolvable` both leave + // nothing to compare, so the value matches nothing. + let Ok(resolved) = resolve(name, default, context) else { + return ConditionValue::Unresolved; + }; + text.push_str(resolved); + tokens.extend(resolved.encode_utf16().map(Token::Unit)); } } } - result + ConditionValue::Expanded { text, tokens } +} + +/// Whether the `Arn*` condition value `value` contains `${` and has fewer +/// than five `:` written outside its `${...}` references, as +/// `${aws:ResourceTag/owner}` has none. Only a written `:` separates ARN +/// fields here, so such a value cannot have the six fields of an ARN. The IAM +/// policy simulator refuses a policy with such a value (`InvalidInput`) and +/// IAM Access Analyzer reports `MISSING_ARN_FIELD` (measured 2026-10-28). +#[must_use] +pub(super) fn arn_value_lacks_fields(value: &str) -> bool { + value.contains("${") + && parts(value) + .iter() + .map(|part| match part { + Part::Text(text) => text.matches(':').count(), + Part::Ref(..) => 0, + }) + .sum::() + < 5 } /// Match an expanded ARN pattern against an ARN value with the field rule of diff --git a/crates/auth/src/policy/variables/tests.rs b/crates/auth/src/policy/variables/tests.rs index f45e0a474..7151c8a26 100644 --- a/crates/auth/src/policy/variables/tests.rs +++ b/crates/auth/src/policy/variables/tests.rs @@ -446,43 +446,123 @@ fn unclosed_brace_is_literal_text() { // --- expand_condition_value --- +fn text_of(value: &ConditionValue) -> Option<&str> { + value.text() +} + #[test] fn condition_value_expands_variable_and_default() { let ctx = team_blue(); + let v = expand_condition_value("team-${aws:PrincipalTag/team}", true, &ctx); + assert_eq!(text_of(&v), Some("team-blue")); + assert!(matches!(v, ConditionValue::Expanded { .. })); assert_eq!( - expand_condition_value("team-${aws:PrincipalTag/team}", true, &ctx), - "team-blue" + text_of(&expand_condition_value( + "${aws:username, 'anon'}", + true, + &ctx + )), + Some("anon") ); assert_eq!( - expand_condition_value("${aws:username, 'anon'}", true, &ctx), - "anon" + text_of(&expand_condition_value( + "${aws:PrincipalTag/team, 'none'}", + true, + &ctx + )), + Some("blue") ); +} + +#[test] +fn condition_value_unresolved_variable_matches_nothing() { + let ctx = Ctx::empty(); + let v = expand_condition_value("${aws:username}", true, &ctx); + assert_eq!(v, ConditionValue::Unresolved); + assert_eq!(v.text(), None); + assert!(!v.wildcard_matches("${aws:username}")); + assert!(!v.wildcard_matches("anything")); + assert!(!v.arn_matches(BLUE)); + // One unresolved variable among resolved text is still unresolved. assert_eq!( - expand_condition_value("${aws:PrincipalTag/team, 'none'}", true, &ctx), - "blue" + expand_condition_value("a-${aws:username}-b", true, &ctx), + ConditionValue::Unresolved ); } #[test] -fn condition_value_leaves_unresolved_and_escapes_literal() { +fn condition_value_escapes_expand_to_literal_characters() { let ctx = Ctx::empty(); + let v = expand_condition_value("a${*}b${?}c${$}", true, &ctx); + assert_eq!(text_of(&v), Some("a*b?c$")); + // The escaped characters are literal for the Like operators. + assert!(v.wildcard_matches("a*b?c$")); + assert!(!v.wildcard_matches("aXbYc$")); + // The result of an escape is not expanded again. + let v = expand_condition_value("a${$}{x}", true, &ctx); + assert_eq!(text_of(&v), Some("a${x}")); + assert!(matches!(v, ConditionValue::Expanded { .. })); +} + +#[test] +fn condition_value_written_wildcards_stay_wildcards_when_expanded() { + let ctx = team_blue(); + // `*` written in the policy is a wildcard; `*` from an escape is not. + let v = expand_condition_value("${aws:PrincipalTag/team}-*${*}", true, &ctx); + assert_eq!(text_of(&v), Some("blue-**")); + assert!(v.wildcard_matches("blue-anything*")); + assert!(v.wildcard_matches("blue-*")); + assert!(!v.wildcard_matches("blue-anything")); + // ArnLike: a written `*` in the resource field spans `/`, an escaped one + // is the character. + let v = expand_condition_value( + "arn:aws:dynamodb:*:*:table/${aws:PrincipalTag/team}/*${*}", + true, + &ctx, + ); + assert!(v.arn_matches("arn:aws:dynamodb:us-east-1:1:table/blue/index/x*")); + assert!(!v.arn_matches("arn:aws:dynamodb:us-east-1:1:table/blue/index/x")); + // A `:` or `*` inside a variable value stays literal in its field. + let ctx = Ctx::empty().with(TEAM, &["a*b"]); + let v = expand_condition_value("${aws:PrincipalTag/team}", true, &ctx); + assert!(v.wildcard_matches("a*b")); + assert!(!v.wildcard_matches("aXb")); +} + +#[test] +fn condition_value_is_literal_without_expansion_or_without_variables() { + let ctx = team_blue(); + // A document that does not expand compares the text as written, and its + // `*` and `?` are wildcards. + let v = expand_condition_value("${aws:PrincipalTag/team}", false, &ctx); assert_eq!( - expand_condition_value("${aws:username}", true, &ctx), - "${aws:username}" + v, + ConditionValue::Literal("${aws:PrincipalTag/team}".to_owned()) ); + let v = expand_condition_value("a${*}", false, &ctx); + assert_eq!(text_of(&v), Some("a${*}")); + assert!(v.wildcard_matches("a${*}")); + assert!(v.wildcard_matches("a${anything}")); + assert!(!v.wildcard_matches("a*")); + // No `${` at all: literal whatever the document. assert_eq!( - expand_condition_value("a${*}b${?}c${$}", true, &ctx), - "a${*}b${?}c${$}" + expand_condition_value("plain", true, &ctx), + ConditionValue::Literal("plain".to_owned()) + ); + let v = expand_condition_value("a*", true, &ctx); + assert!(v.wildcard_matches("abc")); + assert!(expand_condition_value("arn:aws:dynamodb:*:*:table/*", true, &ctx).arn_matches(BLUE)); + // A `${` with no closing brace is plain text. + assert_eq!( + text_of(&expand_condition_value("${open", true, &ctx)), + Some("${open") ); - assert_eq!(expand_condition_value("plain", true, &ctx), "plain"); - assert_eq!(expand_condition_value("${open", true, &ctx), "${open"); } #[test] fn variable_key_names_strip_the_default_and_skip_escapes() { let names: Vec<&str> = variable_key_names( "${aws:PrincipalTag/team}-${aws:RequestTag/env, 'x'}${*}${aws:username,'y'}${open", - true, ) .collect(); assert_eq!( @@ -495,39 +575,62 @@ fn variable_key_names_strip_the_default_and_skip_escapes() { ); // A comma whose right side is not single-quoted is part of the name, as // `expand_condition_value` reads it. - let names: Vec<&str> = variable_key_names("${a, b}", true).collect(); + let names: Vec<&str> = variable_key_names("${a, b}").collect(); assert_eq!(names, ["a, b"]); - assert_eq!(variable_key_names("plain", true).count(), 0); + assert_eq!(variable_key_names("plain").count(), 0); + // The name is trimmed as `parse_reference` trims it. + let names: Vec<&str> = + variable_key_names("${aws:RequestTag/env , 'x'}${ aws:username }").collect(); + assert_eq!(names, ["aws:RequestTag/env", "aws:username"]); } -/// Without `iam_syntax`, as in a `2008-10-17` document or under an operator -/// other than a string or ARN one, the text between the braces is the key -/// name as written: the default form and a trimmed key are not read, and -/// `${key}` expands as it did before the default form was read. +/// Without expansion, as in a `2008-10-17` document or under an operator +/// other than a string or ARN one, the whole value is compared as written: +/// the default form, a trimmed key and `${key}` itself are all literal text +/// (rows V1 to V5 of `evaluator/tests/condition_variable_scope.rs`, and the +/// no-`Version` `${aws:username}` rows of issue #534). #[test] -fn without_iam_syntax_the_text_between_the_braces_is_the_key_name() { +fn without_expansion_the_whole_value_is_literal_text() { let ctx = Ctx::empty().with("aws:username", &["alice"]); for value in [ "${aws:username, 'anon'}", "${aws:PrincipalTag/team, 'none'}", "${ aws:username }", + "u-${aws:username}", ] { - assert_eq!(expand_condition_value(value, false, &ctx), value); + assert_eq!( + expand_condition_value(value, false, &ctx), + ConditionValue::Literal(value.to_owned()) + ); } - assert_eq!( - expand_condition_value("u-${aws:username}", false, &ctx), - "u-alice" - ); - let names: Vec<&str> = - variable_key_names("${aws:RequestTag/env, 'x'}${ aws:username }", false).collect(); - assert_eq!(names, ["aws:RequestTag/env, 'x'", " aws:username "]); } #[test] fn condition_value_present_key_without_values_is_empty() { let ctx = Ctx::empty().with(TEAM, &[]); assert_eq!( - expand_condition_value("x${aws:PrincipalTag/team}y", true, &ctx), - "xy" + text_of(&expand_condition_value( + "x${aws:PrincipalTag/team}y", + true, + &ctx + )), + Some("xy") ); } + +#[test] +fn arn_value_lacks_fields_counts_only_colons_written_outside_references() { + assert!(arn_value_lacks_fields("${aws:ResourceTag/owner}")); + // Four written `:` and one inside the reference. + assert!(arn_value_lacks_fields( + "arn:aws:iam::${aws:PrincipalTag/acct}" + )); + assert!(!arn_value_lacks_fields( + "arn:aws:iam::123456789012:user/${aws:username}" + )); + assert!(!arn_value_lacks_fields( + "arn:aws:iam::${aws:PrincipalAccount}:user/alice" + )); + // No variable: the value is compared as written. + assert!(!arn_value_lacks_fields("user/alice")); +} diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index ba25f7177..223580eeb 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -378,11 +378,11 @@ async fn evaluate_trust_policy( ) } -/// Whether a trust policy's `Version` is `2012-10-17`. Its condition values -/// then read the `${key, 'default'}` form and trim the key name, as an -/// identity policy's do (`condition::evaluate_condition_with`); in a -/// `2008-10-17` trust policy, or one with no `Version`, the text between the -/// braces is the key name as written. +/// Whether a trust policy's `Version` is `2012-10-17`. Only then do its +/// condition values expand policy variables, as an identity policy's do +/// (`condition::evaluate_condition_with`); a `2008-10-17` trust policy, or +/// one with no `Version`, compares `${...}` as literal text (measured with +/// AssumeRole on fresh roles, issue #534). fn trust_policy_is_version_2012(trust_policy: &Value) -> bool { trust_policy.get("Version").and_then(Value::as_str) == Some("2012-10-17") } @@ -1048,6 +1048,9 @@ async fn fetch_caller_created_at( } } +#[cfg(test)] +mod trust_version_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/server/src/management/assume_role/trust_version_tests.rs b/crates/server/src/management/assume_role/trust_version_tests.rs new file mode 100644 index 000000000..799386f46 --- /dev/null +++ b/crates/server/src/management/assume_role/trust_version_tests.rs @@ -0,0 +1,91 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! A trust policy's `Version` decides whether policy variables in its +//! condition values expand (issue #534). Measured with AssumeRole on fresh +//! roles whose trust policy allows the caller under +//! `StringEquals aws:PrincipalArn: ${aws:PrincipalArn}` and under the same +//! condition with `StringNotEquals`: in a `2012-10-17` document the +//! `StringEquals` form grants and the `StringNotEquals` form refuses; in a +//! `2008-10-17` document, and in one with no `Version`, the reverse. + +use std::collections::HashMap; + +use serde_json::{Value, json}; + +use super::{trust_decision_for, trust_policy_is_version_2012}; +use extenddb_auth::policy::context::AssumeRoleContext; + +const CALLER: &str = "arn:aws:iam::123456789012:user/alice"; + +fn context() -> AssumeRoleContext { + AssumeRoleContext { + principal_tags: HashMap::new(), + external_id: None, + session_name: Some("s".to_owned()), + principal_arn: CALLER.to_owned(), + principal_account: Some("123456789012".to_owned()), + request_tags: HashMap::new(), + region: Some("us-east-1".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + username: Some("alice".to_owned()), + source_ip: None, + user_agent: None, + userid: None, + resource_account: Some("123456789012".to_owned()), + current_time: "2026-01-01T00:00:00Z".to_owned(), + epoch_time: "1767225600".to_owned(), + } +} + +fn trust_policy(version: Option<&str>, operator: &str) -> Value { + let mut doc = json!({"Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {operator: {"aws:PrincipalArn": "${aws:PrincipalArn}"}} + }]}); + if let Some(v) = version { + doc["Version"] = json!(v); + } + doc +} + +fn assumable(doc: &Value) -> bool { + let statements = doc["Statement"].as_array().cloned().unwrap_or_default(); + trust_decision_for( + &statements, + CALLER, + &context(), + false, + trust_policy_is_version_2012(doc), + ) +} + +#[test] +fn a_2012_trust_policy_expands_condition_variables() { + assert!(assumable(&trust_policy(Some("2012-10-17"), "StringEquals"))); + assert!(!assumable(&trust_policy( + Some("2012-10-17"), + "StringNotEquals" + ))); +} + +#[test] +fn a_2008_trust_policy_compares_condition_variables_as_text() { + assert!(!assumable(&trust_policy( + Some("2008-10-17"), + "StringEquals" + ))); + assert!(assumable(&trust_policy( + Some("2008-10-17"), + "StringNotEquals" + ))); +} + +#[test] +fn a_trust_policy_with_no_version_compares_condition_variables_as_text() { + assert!(!assumable(&trust_policy(None, "StringEquals"))); + assert!(assumable(&trust_policy(None, "StringNotEquals"))); +} diff --git a/docs/backlog.md b/docs/backlog.md index b2207b03b..81200f62a 100755 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -40,7 +40,6 @@ Refreshed: v0.0.118 (P115) ## Standing Items (need human decision) - ⬜ **22 unapproved license dependencies** — Unicode-3.0, CDLA-Permissive-2.0, MPL-2.0. All pre-existing. Human approved as-is (P99 session). -- ⬜ **Policy-variable gaps in condition values (#534)**: a condition value in a `2008-10-17` or no-`Version` document is still expanded, where IAM treats it as literal text, and `${*}` in a condition value is left as text, where IAM expands it to `*`. Variables in `Resource` and `NotResource` patterns expand as IAM does (PR #535), and `05-component-auth.md` and `01-requirements.md` (REQ-ABAC-006) describe the expansion. (BR-7085) ## Recently Completed diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index c6e9b1294..47f5e2f68 100755 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -325,7 +325,7 @@ extenddb implements a local IAM identity model that mirrors the structure of AWS - `dynamodb:FullTableScan` — restricts whether Scan operations are allowed (`Bool` condition). - `dynamodb:EnclosingOperation` — identifies the parent operation for batch/transact sub-operations. - REQ-ABAC-005: The policy engine must build a **request context** for each operation containing all applicable context keys. The request context is populated by the server middleware before policy evaluation. -- REQ-ABAC-006: **Policy variables** (`${aws:PrincipalTag/key}`, `${aws:username}`, etc.) in Resource and NotResource ARNs and in Condition values are expanded from the request context, as AWS IAM expands them, so that one policy can grant access to resources that match the caller's own tags, for example `"Resource": "arn:aws:dynamodb:*:*:table/${aws:PrincipalTag/Team}-*"`. Expansion in `Resource` and `NotResource` applies to `2012-10-17` documents only, as in IAM. Two gaps in condition values remain and are filed as #534. +- REQ-ABAC-006: **Policy variables** (`${aws:PrincipalTag/key}`, `${aws:username}`, etc.) in Resource and NotResource ARNs and in Condition values are expanded from the request context, as AWS IAM expands them, so that one policy can grant access to resources that match the caller's own tags, for example `"Resource": "arn:aws:dynamodb:*:*:table/${aws:PrincipalTag/Team}-*"`. Expansion in `Resource` and `NotResource` applies to `2012-10-17` documents only, as in IAM. Condition values follow the same `Version` rule, and only the string and ARN operators expand them (#534). ### 4.5 Authorization — Caching diff --git a/docs/design/05-component-auth.md b/docs/design/05-component-auth.md index dd1cb72da..01ded144d 100755 --- a/docs/design/05-component-auth.md +++ b/docs/design/05-component-auth.md @@ -501,7 +501,7 @@ pub enum ConditionOperator { } ``` -> **Policy Variables.** AWS IAM supports variable substitution in `Resource` ARNs and `Condition` values, for example `"Resource": "arn:aws:dynamodb:*:*:table/${aws:PrincipalTag/Team}-*"`, which grants access to tables matching the caller's own team tag. extenddb expands `${key}` and `${key, 'default'}` in the `Resource` and `NotResource` patterns of a `2012-10-17` document and in condition values, where the default form is read only in a `2012-10-17` document and under a string or ARN operator (`policy/variables.rs`). In a `Resource` or `NotResource` pattern, text that comes from a value or a default is matched literally, so a value cannot add a wildcard, and a variable whose key the request does not carry, with no default, makes the pattern match nothing. With a default, the default is used: `table/${aws:PrincipalTag/team, 'none'}` matches `table/none` when the request has no `team` tag (measured with the IAM policy simulator, issue #533). The full rules are in the security model manual (`docs/manuals/10-security-model.md`); two gaps that remain in condition values are filed as #534. Policy variable support is tracked as REQ-ABAC-006. +> **Policy Variables.** AWS IAM supports variable substitution in `Resource` ARNs and `Condition` values, for example `"Resource": "arn:aws:dynamodb:*:*:table/${aws:PrincipalTag/Team}-*"`, which grants access to tables matching the caller's own team tag. extenddb expands `${key}` and `${key, 'default'}` in the `Resource` and `NotResource` patterns and the condition values of a `2012-10-17` document, in condition values only for the string and ARN operators (`policy/variables.rs`). In a `Resource` or `NotResource` pattern, text that comes from a value or a default is matched literally, so a value cannot add a wildcard, and a variable whose key the request does not carry, with no default, makes the pattern match nothing. With a default, the default is used: `table/${aws:PrincipalTag/team, 'none'}` matches `table/none` when the request has no `team` tag (measured with the IAM policy simulator, issue #533). The full rules are in the security model manual (`docs/manuals/10-security-model.md`). Policy variable support is tracked as REQ-ABAC-006. **JSON deserialization:** @@ -1353,7 +1353,7 @@ The EC2 instance (or IAM role) running extenddb in Mode 3 needs the following pe 3. **Service control policies (SCPs) are not evaluated.** SCPs are an Organizations-level feature. extenddb does not query Organizations APIs. -4. **Policy variables follow the identity-policy rules, with the condition-value gaps of #534.** Fetched policies are evaluated by the same engine, so `${aws:PrincipalTag/key}` in a `Resource`, `NotResource` or condition value expands from the request (REQ-ABAC-006), and the two condition-value gaps filed as #534 apply to them as well. +4. **Policy variables follow the identity-policy rules.** Fetched policies are evaluated by the same engine, so `${aws:PrincipalTag/key}` in a `Resource`, `NotResource` or condition value expands from the request under the same rules (REQ-ABAC-006). 5. **Cross-account access is not evaluated.** extenddb assumes all callers are in the same account. Cross-account role assumption and resource policies are not supported. diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 0686d8445..74f0236ab 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -80,7 +80,7 @@ from a request sent to that service or backend. |------|----------|------| | TagResource / UntagResource | Validates resource ARN exists, returns `ResourceNotFoundException` for missing tables | Matches DynamoDB — validates resource ARN and returns `ResourceNotFoundException` for missing tables. | | `ResourceArn` naming a backup (`table//backup/`) | Backups take no tags: `TagResource`, `UntagResource`, and `ListTagsOfResource` take table and stream ARNs, and the authorization reference lists no `aws:ResourceTag` key for the backup resource. The service's reply to a backup ARN was not measured | `TagResource`, `UntagResource`, and `ListTagsOfResource` refuse it with `ValidationException` ("One or more parameter values were invalid: Provided Arn is not a DynamoDB resource arn: "), the reply already given for an ARN whose resource is not `table/...`. So are `table//backup`, `table//backup/`, and any longer path under a backup. Another account's backup ARN is `AccessDeniedException` "Access is denied" when this deployment's catalog does not hold the account and gets this `ValidationException` when it does, as on the service (measured); another region's stays the region `ValidationException`, and the table is not looked up. Earlier builds accepted a backup ARN and stored its tags; such tags stay in storage but nothing reads them: `ListTagsOfResource` and `UntagResource` refuse the ARN, and authorization ignores them (see "Tag conditions on backups" under Backup and Restore). `TagResource` and `UntagResource` are still authorized against `table/*`, so a policy statement on `table/*/backup/*` does not apply to them. Measured on PostgreSQL and SQLite; the check is in the engine, the same for every backend | -| Tag condition keys on tag operations | `aws:RequestTag/` and `aws:TagKeys` are populated for `TagResource` and `CreateTable`, and `aws:TagKeys` for `UntagResource` (service authorization reference, dynamodb v1.5). Whether `CreateTable` with `Tags` is also authorized as `dynamodb:TagResource` was not measured | The same three operations populate the same keys, and every other operation leaves both absent. The operator semantics (set operators, `Null`, a bare negated operator on an absent `aws:TagKeys`, tag name case) come from `iam simulate-custom-policy` runs, not from DynamoDB requests. A request that names one tag under two spellings with differing values fails closed: a `Deny` that reads the key blocks and an `Allow` that reads it does not grant; the service's answer was not measured. A condition value `${aws:TagKeys}` stays literal text, as on IAM and on a table resource policy (measured 2026-10-27). Tag names in `aws:RequestTag/` match without regard to case on both (`Étag` and `étag` match on the service), but ExtendDB's Unicode case folding also equates the sharp s with `ss` (`Straße` and `STRASSE`), which the service does not (measured on IAM and on a table resource policy, 2026-10-27). An `Allow` gated on `aws:RequestTag/STRASSE` grants a request carrying `Straße` on ExtendDB where the service would not (inferred from the same measurement; the Allow direction was not run) | +| Tag condition keys on tag operations | `aws:RequestTag/` and `aws:TagKeys` are populated for `TagResource` and `CreateTable`, and `aws:TagKeys` for `UntagResource` (service authorization reference, dynamodb v1.5). Whether `CreateTable` with `Tags` is also authorized as `dynamodb:TagResource` was not measured | The same three operations populate the same keys, and every other operation leaves both absent. The operator semantics (set operators, `Null`, a bare negated operator on an absent `aws:TagKeys`, tag name case) come from `iam simulate-custom-policy` runs, not from DynamoDB requests. A request that names one tag under two spellings with differing values fails closed: a `Deny` that reads the key blocks and an `Allow` that reads it does not grant; the service's answer was not measured. The variable `${aws:TagKeys}` in a condition value has no value: a positive operator is false against it and a negated one true (measured with the IAM policy simulator on 2026-10-27), and its text is not compared (measured the same way on 2026-10-31). On a table resource policy a `StringEquals` `Deny` with it did not apply for either tag order (measured 2026-10-27). Tag names in `aws:RequestTag/` match without regard to case on both (`Étag` and `étag` match on the service), but ExtendDB's Unicode case folding also equates the sharp s with `ss` (`Straße` and `STRASSE`), which the service does not (measured on IAM and on a table resource policy, 2026-10-27). An `Allow` gated on `aws:RequestTag/STRASSE` grants a request carrying `Straße` on ExtendDB where the service would not (inferred from the same measurement; the Allow direction was not run) | | A tag key repeated in one `TagResource` or `CreateTable` request | `ValidationException` "Duplicate Tag Keys provided as input: Duplicate Tag Key found " on both operations (measured 2026-10-27), and nothing is stored | `TagResource` accepts it and stores the last value, and authorization reads that value. `CreateTable` answers HTTP 500 `InternalServerError` on SQLite and PostgreSQL and creates nothing, because the tags are inserted with a plain `INSERT`. Measured on SQLite and PostgreSQL on 2026-10-27 | ## Secondary Indexes diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index b1e3295e3..813c9dbba 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -351,27 +351,30 @@ issue #533). Four decisions change for stored policies: NotResource` written that way no longer grants the team's table. 2. A condition value naming a multivalued key, such as `${dynamodb:LeadingKeys}` or `${aws:TagKeys}` in a trust policy, is no longer expanded to the key's first - value; the text is compared as written. Under a positive operator such as - `StringEquals` the condition no longer matches, and under a negated operator + value. In a `2012-10-17` document the value now matches nothing when the + request carries the key, and a written default is used only when the request + does not carry it; a `2008-10-17` document, or one with no `Version`, compares + the text as written (item 1 of the next entry). Under a positive operator such + as `StringEquals` the condition no longer matches, and under a negated operator such as `StringNotEquals` it now matches. For example, an `Allow` with the condition `StringNotEquals` on `aws:PrincipalTag/team` and the value `${dynamodb:LeadingKeys}` now grants a caller tagged `team=blue` whose request has the leading key `blue`, where it did not, and a `Deny` with that condition now applies to the same request. -3. In a `2012-10-17` document, a condition value written as - `${key, 'default'}` under a string or ARN operator now expands to the - request's value for `key`, or to `default` when the request does not carry - `key`. Before, the whole text was compared as written. For example, an - `Allow` with the condition `StringEquals` on `aws:ResourceTag/owner` and the - value `${aws:username, 'anon'}` now grants `alice` a table tagged - `owner=alice`, and a caller with no user name a table tagged `owner=anon`; - before, it granted neither. In a `2008-10-17` document, in one with no - `Version`, and under the numeric, date, `Bool` and `Null` operators, the - text is still compared as written; IAM does not read the default form - there either (measured with the IAM policy simulator). A user, group or - role policy or boundary write refuses an `IpAddress` value written as a - variable as a malformed CIDR, as before. A role trust policy follows its - own `Version`. +3. A condition value written as `${key, 'default'}` in a `2012-10-17` document + now expands, under the string and ARN operators, to the request's value for + `key`, or to `default` when the request does not carry `key`. `Null` and + `Bool` read such a value as the boolean `false` whatever the default (item 6 + of the next entry), and the numeric and date operators compare it as written, + as does a `2008-10-17` document or one with no `Version`; IAM does not read + the default form there either (measured with the IAM policy simulator). + Before, the whole text was compared as written. For example, an `Allow` with + the condition `StringEquals` on `aws:ResourceTag/owner` and the value + `${aws:username, 'anon'}` now grants `alice` a table tagged `owner=alice`, and + a caller with no user name a table tagged `owner=anon`; before, it granted + neither. A user, group or role policy or boundary write refuses an + `IpAddress` value written as a variable as a malformed CIDR, as before. A + role trust policy follows its own `Version`. 4. Spaces, and the other code points up to U+0020, around the key name of a variable are now ignored, in `Resource` and `NotResource` patterns and in the condition values item 3 covers. An `Allow` with the condition `StringEquals` @@ -411,6 +414,22 @@ migration is required. --- +### v0.1.15: IAM policy variables in condition values + +**What changed.** Policy variables in condition values now follow IAM's rules (issue #534), as measured with the IAM policy simulator for the string, ARN, numeric, `Bool` and `Null` operators in `2012-10-17` documents and for `StringEquals` in `2008-10-17` and no-`Version` documents; the date and address operators follow the numeric case. Only a `2012-10-17` document expands a variable, and in that document only the string and ARN operators do. Decisions change for stored policies in these cases: + +1. A `2008-10-17` document, or one with no `Version`, no longer expands a variable in a condition value: `${aws:username}` is the literal text. This covers user, group and role policies, permissions boundaries, session policies and role trust policies. In such a document a `Deny` with the condition `StringEquals` on `aws:ResourceTag/owner` and the value `${aws:username}` no longer applies to the caller's own table, and the same `Deny` written with `StringNotEquals` now applies to it. +2. `${*}`, `${?}` and `${$}` now stand for the characters `*`, `?` and `$`, and the result is not expanded again. Before, the escape was compared as its text, so `StringEquals` with the value `${*}` matched the text `${*}`; now it matches `*`. In `StringLike`, `ArnLike` and `ArnEquals` the escaped character matches only itself, while a `*` or `?` written in the policy is still a wildcard. +3. A `*` or `?` that comes from a variable value is now matched as that character in `StringLike`, `ArnLike` and `ArnEquals`, where it was a wildcard. A `Deny` with `StringLike` on `aws:ResourceTag/owner` and the value `${aws:PrincipalTag/team}` no longer applies to a caller tagged `team=a*` on a table tagged `owner=abc`. +4. A variable with no default whose key the request does not carry now matches nothing. Before, its text `${...}` was compared, so the decision changes only for the request values that text matched: a positive operator such as `StringEquals` no longer matches them, and a negated operator such as `StringNotEquals` now holds for them. Under `StringEquals` those are the request values equal to the text. Under `StringLike`, `ArnLike` or `ArnEquals`, when a `*` or `?` is also written in the value, such as `${aws:username}*`, they are every request value the text matches as a pattern. +5. A numeric or date value written as a variable is no longer expanded and is compared as written, which matches no request value. A `Deny` with `NumericGreaterThan` on `aws:MultiFactorAuthAge` and the value `${aws:PrincipalTag/maxAge}`, or with `DateGreaterThan` on `aws:CurrentTime` and the value `${aws:PrincipalTag/expiry}`, no longer applies. Outside role trust policies an IP address value written as a variable does not parse, as before, because it is not a CIDR. In a role trust policy it is no longer expanded and matches no request. +6. A `Bool` or `Null` value written as a variable is no longer replaced by the variable's value. IAM reads the text as the boolean `false` whatever the variable holds, and ExtendDB now does the same: `Null` holds when the key is present, and `Bool` holds when the request value is `false`. A `Deny` with `Null` on `aws:PrincipalTag/team` and the value `${aws:PrincipalTag/flag}` now applies to every caller that has a `team` tag, and a `Deny` with `Bool` on `aws:SecureTransport` and the same value now applies to every request made without TLS. Before, both decisions followed the value of the caller's `flag` tag, and with no `flag` tag neither `Deny` applied. +7. In an `Arn*` operator, a value that contains `${` needs the five `:` that separate an ARN's six fields written in the policy, as in `arn:aws:iam::123456789012:user/${aws:username}`. The IAM policy simulator refuses a `2012-10-17` policy whose value has fewer, such as `${aws:ResourceTag/blockedPrincipal}`, and ExtendDB now fails that condition closed in a document of any `Version`, since IAM checks a value's fields whatever the `Version`. A `Deny` with it applies to every request its action, resource and other conditions match, and an `Allow` with it grants nothing. Before, the substituted ARN was compared as a whole, so such a `Deny` applied only to the principal the tag named, and an `Allow` written with `ArnNotLike` granted every other principal. Only the `ArnLike` `Deny` and the `ArnNotLike` `Allow` in `2012-10-17` documents were sent to the simulator; the `ArnEquals` and `ArnNotEquals` forms, the forms with a set qualifier or `IfExists`, and `2008-10-17` and no-`Version` policies follow the same rule without a measurement. To compare a principal with an ARN stored whole in a tag, use `StringEquals`, which compares the expanded text in a `2012-10-17` document. + +**Who is affected.** Any deployment with a stored policy whose condition values contain `${`: user, group and role policies, permissions boundaries, session policies and role trust policies. Search the stored policies for `${` (`extenddb manage list-user-policies`, `list-group-policies` and `list-role-policies` print inline policies, `get-user-boundary` and `get-role-boundary` permissions boundaries, and `list-roles` trust policies) and check each match against the cases above before upgrading. The shapes that change are a variable in a `2008-10-17` or no-`Version` document, an escape, a variable under `StringLike`, `ArnLike` or `ArnEquals`, a string or ARN value with a variable that has no default and whose key a request may not carry, a numeric, date, `Bool` or `Null` value written as a variable, and an `Arn*` value that contains `${` and has fewer than five written `:`. No catalog migration is required. + +--- + ### v0.1.14 LeadingKeys now enforced on Query, batch and transaction operations **What changed.** `dynamodb:LeadingKeys` is now evaluated on `Query`, @@ -757,9 +776,9 @@ No catalog migration is required. **What changed.** `aws:TagKeys` and `aws:RequestTag/` now resolve on the three operations that carry a tag set, as the AWS service authorization reference lists them. `TagResource` and `CreateTable` populate both keys from `Tags`: `aws:RequestTag/` holds the value of the tag named ``, and `aws:TagKeys` lists the tag keys in request order. `UntagResource` populates `aws:TagKeys` from `TagKeys` and leaves `aws:RequestTag/` absent. Every other operation leaves both keys absent, `ListTagsOfResource` included, and so does a request whose tag list is empty. Before this release both keys were absent on every data-plane request, so a statement that named either key did not depend on the tags of a tag operation. -`aws:TagKeys` is a multivalued key. A bare operator on it, one with no `ForAnyValue:` or `ForAllValues:` prefix, never matches while the key is present, as for `dynamodb:Attributes` and `dynamodb:LeadingKeys`. Tag names in `aws:RequestTag/` match without regard to case, under Unicode case folding. That folding also equates the sharp s with `ss`, which the service does not (measured 2026-10-27), so a `Deny` on `aws:RequestTag/STRASSE` applies to a tag named `Straße` on ExtendDB and not on the service, and an `Allow` gated on `aws:RequestTag/STRASSE` grants a request carrying `Straße` on ExtendDB where the service would not (inferred from the same measurement; the Allow direction was not run). A request that names one tag under two spellings that fold to one name, with differing values, fails the statement closed: a `Deny` that reads the key blocks and an `Allow` that reads it does not grant. A condition value `${aws:TagKeys}` stays literal text, because a multivalued key has no single value to put there. +`aws:TagKeys` is a multivalued key. A bare operator on it, one with no `ForAnyValue:` or `ForAllValues:` prefix, never matches while the key is present, as for `dynamodb:Attributes` and `dynamodb:LeadingKeys`. Tag names in `aws:RequestTag/` match without regard to case, under Unicode case folding. That folding also equates the sharp s with `ss`, which the service does not (measured 2026-10-27), so a `Deny` on `aws:RequestTag/STRASSE` applies to a tag named `Straße` on ExtendDB and not on the service, and an `Allow` gated on `aws:RequestTag/STRASSE` grants a request carrying `Straße` on ExtendDB where the service would not (inferred from the same measurement; the Allow direction was not run). A request that names one tag under two spellings that fold to one name, with differing values, fails the statement closed: a `Deny` that reads the key blocks and an `Allow` that reads it does not grant. The variable `${aws:TagKeys}` in a condition value has no value, because a multivalued key has no single value to put there: a positive operator such as `StringEquals` is false against it and a negated one such as `StringNotEquals` true, even for a request value equal to its text, as IAM evaluates it (measured with the IAM policy simulator on 2026-10-31). -**Who is affected.** Any deployment with a policy statement that names `aws:TagKeys` or `aws:RequestTag/` in a condition: inline user, group and role policies, user and role permissions boundaries, and session policies. Role trust policies already resolved both keys from the session tags of the `AssumeRole` call. One trust-policy case changes: a condition value `${aws:TagKeys}` stays literal text there too, where it used to expand to one session tag key in hash order. A statement that was inert on tag operations now applies to `TagResource`, `UntagResource` and `CreateTable`: +**Who is affected.** Any deployment with a policy statement that names `aws:TagKeys` or `aws:RequestTag/` in a condition: inline user, group and role policies, user and role permissions boundaries, and session policies. Role trust policies already resolved both keys from the session tags of the `AssumeRole` call. One trust-policy case changes: the variable `${aws:TagKeys}` in a condition value has no value there too, where it used to expand to one session tag key in hash order. A statement that was inert on tag operations now applies to `TagResource`, `UntagResource` and `CreateTable`: - A `Deny` written to block a tag key or tag value now blocks the requests that carry it. `ForAnyValue:StringEquals` on `aws:TagKeys` and `StringEquals` on `aws:RequestTag/` did not match before. - An `Allow` gated on `ForAllValues:StringEquals` on `aws:TagKeys` used to grant for any tag keys, because the absent key satisfied it. It now grants only when every tag key in the request is in the list. diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index 89da45d36..1cd5660ee 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -173,7 +173,7 @@ Set operators and `IfExists` can be combined: `ForAllValues:StringEqualsIfExists | `dynamodb:FullTableScan` | Boolean | `true` for Scan operations | | `dynamodb:EnclosingOperation` | String | The enclosing operation for batch/transact sub-operations | -Policy variables (e.g., `${aws:PrincipalTag/Team}`) are expanded in condition values. In a `2012-10-17` document, under a string or ARN operator (with or without `ForAnyValue:`, `ForAllValues:` or `IfExists`), the `${key, 'default'}` form expands to `default` when the request does not carry `key`, and code points up to U+0020 around the key name are ignored. Elsewhere the text between the braces is read as the key name as written, so `${key, 'default'}` is compared as written; IAM does not read the default form there either. A role trust policy follows its own `Version`. A multivalued key such as `dynamodb:LeadingKeys` is not expanded. +Policy variables (e.g., `${aws:PrincipalTag/Team}`) are expanded in condition values in a `2012-10-17` document; a `2008-10-17` document, or one with no `Version`, compares the value as written, so `${aws:username}` is the literal text, as IAM does. In a `2012-10-17` document `${*}`, `${?}` and `${$}` stand for the literal characters `*`, `?` and `$`; in `StringLike`, `ArnLike` and `ArnEquals` the escaped character is matched literally while a `*` or `?` written in the policy is a wildcard (`a${*}` matches `a*` and not `abc`), and the result of an escape is not expanded again (`a${$}{x}` is the text `a${x}`). The `${key, 'default'}` form expands to `default` when the request does not carry `key`, and code points up to U+0020 around the key name, around the comma of the default form and before the closing brace are ignored, as in IAM. A variable with no default whose key the request does not carry makes that value match nothing: a positive operator such as `StringEquals` does not match it and a negated operator such as `StringNotEquals` does, except that every `Arn*` operator is false against a request value that is not an ARN. A multivalued key such as `dynamodb:LeadingKeys` is not expanded; when the request carries it the value matches nothing, and a written default is used only when the request does not carry the key. Only the string and ARN operators (with or without `ForAnyValue:`, `ForAllValues:` or `IfExists`) expand variables. A numeric, date or address value written as a variable is compared as written. A `Bool` or `Null` value written as one is read as the boolean `false`, as IAM does, so `Null` then holds when the key is present and `Bool` holds when the request value is `false`. In an `Arn*` operator, a value that contains `${` needs the five `:` that separate an ARN's six fields written in the policy, as in `arn:aws:iam::123456789012:user/${aws:username}`; the IAM policy simulator refuses a policy whose value has fewer, such as `${aws:ResourceTag/owner}`, and ExtendDB fails that condition closed in a document of any `Version`, so a `Deny` applies and an `Allow` does not grant. A role trust policy follows its own `Version`. Because a negated operator holds for an unresolved variable, an `Allow` written as `StringNotEquals` against `${aws:PrincipalTag/team}` grants to a caller without that tag; add a `Null` condition on the tag to require it. ### Access Control Patterns diff --git a/tests/test_tag_request_variable_conditions.py b/tests/test_tag_request_variable_conditions.py index f92fdf42c..3e586f9ee 100644 --- a/tests/test_tag_request_variable_conditions.py +++ b/tests/test_tag_request_variable_conditions.py @@ -1,21 +1,25 @@ # Copyright 2026 ExtendDB contributors # SPDX-License-Identifier: Apache-2.0 -"""A policy variable that names `aws:TagKeys` stays literal text. +"""A policy variable that names `aws:TagKeys` has no value. `aws:TagKeys` is the set of tag keys a request names, so it has no single value -to put where `${aws:TagKeys}` stands in a condition value. The value keeps its -text, and no tag key equals that text. +to put where `${aws:TagKeys}` stands in a condition value. A positive operator +is false against the variable and a negated one true, and the variable's text +is not compared. -The expected answers come from two measurements on 2026-10-27: +The expected answers come from three measurements: - - `iam simulate-custom-policy`, a Deny on `TagResource` whose condition - compares `aws:ResourceTag/team` (or `aws:RequestTag/env`) with + - `iam simulate-custom-policy` on 2026-10-27, a Deny on `TagResource` whose + condition compares `aws:ResourceTag/team` (or `aws:RequestTag/env`) with `${aws:TagKeys}`: not applied for any order of the request's tag keys. The - same policy with `StringNotEquals` is applied for every order, which is the - comparison against the literal text. + same policy with `StringNotEquals` is applied for every order. - a resource policy carrying the same Deny on a DynamoDB table, with - `TagResource` requests listing the tag keys in both orders: not refused. + `TagResource` requests listing the tag keys in both orders, on 2026-10-27: + not refused. + - the IAM policy simulator on 2026-10-31, `StringEquals` with the value + `${aws:TagKeys}` against `aws:PrincipalTag/env` set to that text: not + matched, so the text is not compared. Prerequisites: - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT @@ -96,7 +100,7 @@ def _tags(keys: list[str]) -> list[dict[str, str]]: class TestTagKeysPolicyVariable: - """A condition value `${aws:TagKeys}` is compared as text.""" + """The variable `${aws:TagKeys}` in a condition value has no value.""" @pytest.fixture(autouse=True) def setup(self, auth_env, mgmt, account_id, region): @@ -156,8 +160,8 @@ def test_literal_value_on_the_same_tag_applies(self): @pytest.mark.parametrize("keys", [*TAG_ORDERS, pytest.param(["colour"], id="other-key")]) def test_negated_comparison_with_the_variable_applies_for_every_tag_order(self, keys): - # `env` is not equal to the text `${aws:TagKeys}`, so the negated - # comparison is true whatever the request's tag keys are. + # The variable has no value, so the negated comparison is true + # whatever the request's tag keys are. client = self._restricted( _deny({"StringNotEquals": {"aws:ResourceTag/team": "${aws:TagKeys}"}}) )