From e7397ac621861424d9550fbbc978552c5300eb73 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:45:49 +0000 Subject: [PATCH 01/32] fix(auth): expand policy variables in Resource and NotResource patterns In a 2012-10-17 document, every ${key} in a Resource or NotResource pattern is replaced by the request's value before ARN matching, with ${key, 'default'} falling back to its default and ${*}, ${?} and ${$} producing literal characters. Text substituted from a value, a default or an escape is matched literally; a wildcard written in the policy still matches. A variable whose key the request does not carry, with no default, makes the pattern match nothing. A 2008-10-17 document, or one with no Version, matches the pattern as written, so PolicyDocument now keeps an absent Version as None. Condition values read the ${key, 'default'} form through the same module. A multivalued key such as aws:TagKeys or dynamodb:LeadingKeys has no value, default or not, as main already treats it in a condition value: a condition value naming one stays as written, and a pattern naming one matches nothing. Measured with the IAM policy simulator. Closes #533. --- crates/auth/src/policy/condition.rs | 50 +--- crates/auth/src/policy/document.rs | 35 ++- crates/auth/src/policy/evaluator.rs | 40 ++- crates/auth/src/policy/mod.rs | 1 + crates/auth/src/policy/variables.rs | 268 +++++++++++++++++++ crates/auth/src/policy/variables/tests.rs | 305 ++++++++++++++++++++++ 6 files changed, 637 insertions(+), 62 deletions(-) create mode 100644 crates/auth/src/policy/variables.rs create mode 100644 crates/auth/src/policy/variables/tests.rs diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 6164cce58..fd66ac9fe 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -252,52 +252,12 @@ pub fn evaluate_statement_conditions( /// Expand IAM policy variables in a string value. /// -/// Replaces `${variable}` patterns with their resolved values from the context. -/// Supported variables: `aws:PrincipalTag/*` and any single-valued key the -/// context can resolve. A variable that names an unresolvable key stays -/// literal (IAM behavior), and so does one that names a multivalued key -/// (`aws:TagKeys`, `dynamodb:LeadingKeys`, `dynamodb:Attributes`): 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). +/// Replaces `${variable}` and `${variable, 'default'}` patterns with their +/// resolved values from the context (see [`super::variables`]). Supported +/// variables: `aws:PrincipalTag/*` and any key the context can resolve. +/// Unresolvable variables with no default are left as-is (IAM behavior). fn expand_policy_variables(value: &str, context: &impl ConditionContext) -> String { - if !value.contains("${") { - return value.to_owned(); - } - - let mut result = String::with_capacity(value.len()); - let mut rest = value; - - while let Some(start) = rest.find("${") { - result.push_str(&rest[..start]); - let after_start = &rest[start + 2..]; - if let Some(end) = after_start.find('}') { - let var_name = &after_start[..end]; - let resolved = if context.is_multivalued_key(var_name) { - None - } else { - context.resolve_key(var_name) - }; - if let Some(vals) = resolved { - // A single-valued key resolves to one value. - if let Some(v) = vals.first() { - result.push_str(v); - } - } else { - // Unresolvable or multivalued: leave the variable literal. - result.push_str(&rest[start..start + 3 + end]); - } - rest = &after_start[end + 1..]; - } else { - // No closing brace — copy literally. - result.push_str(&rest[start..]); - rest = ""; - } - } - result.push_str(rest); - result + super::variables::expand_condition_value(value, context) } /// Unwrap an `IfExists` wrapper if present. diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index dedcd5e2a..a5893169e 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -13,12 +13,26 @@ use std::fmt; /// A parsed IAM policy document. #[derive(Debug, Clone)] pub struct PolicyDocument { - /// Policy version string (e.g., "2012-10-17"). - pub version: String, + /// Policy version string (e.g., "2012-10-17"), `None` when the document + /// has no `Version`. + pub version: Option, /// The policy statements. pub statements: Vec, } +impl PolicyDocument { + /// Whether `${key}` policy variables in `Resource` and `NotResource` + /// patterns 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). + #[must_use] + pub fn expands_policy_variables(&self) -> bool { + self.version.as_deref() == Some("2012-10-17") + } +} + /// A single policy statement. #[derive(Debug, Clone)] pub struct Statement { @@ -172,7 +186,7 @@ impl PolicyDocument { ))); } let raw: Value = serde_json::from_str(json).map_err(|e| PolicyParseError(e.to_string()))?; - let version = raw["Version"].as_str().unwrap_or("2012-10-17").to_owned(); + let version = raw["Version"].as_str().map(ToOwned::to_owned); let statements = parse_statements(&raw["Statement"])?; validate_statement_cidrs(&statements)?; Ok(Self { @@ -492,6 +506,18 @@ fn parse_string_or_array(value: &Value) -> Result, PolicyParseError> mod tests { use super::*; + #[test] + fn version_2008_or_absent_does_not_expand_variables() { + let stmt = r#""Statement":[{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}]"#; + let v2008 = + PolicyDocument::from_json(&format!(r#"{{"Version":"2008-10-17",{stmt}}}"#)).unwrap(); + assert_eq!(v2008.version.as_deref(), Some("2008-10-17")); + assert!(!v2008.expands_policy_variables()); + let none = PolicyDocument::from_json(&format!("{{{stmt}}}")).unwrap(); + assert_eq!(none.version, None); + assert!(!none.expands_policy_variables()); + } + #[test] fn parse_simple_allow_policy() { let json = r#"{ @@ -503,7 +529,8 @@ mod tests { }] }"#; let doc = PolicyDocument::from_json(json).unwrap(); - assert_eq!(doc.version, "2012-10-17"); + assert_eq!(doc.version.as_deref(), Some("2012-10-17")); + assert!(doc.expands_policy_variables()); assert_eq!(doc.statements.len(), 1); assert_eq!(doc.statements[0].effect, Effect::Allow); assert!(matches!( diff --git a/crates/auth/src/policy/evaluator.rs b/crates/auth/src/policy/evaluator.rs index cf17858e3..049fb0737 100644 --- a/crates/auth/src/policy/evaluator.rs +++ b/crates/auth/src/policy/evaluator.rs @@ -10,7 +10,8 @@ use super::condition::{ConditionMatch, evaluate_statement_conditions}; use super::context::ConditionContext; use super::document::{ActionMatch, Effect, PolicyDocument, ResourceMatch, Statement}; -use super::matcher::{arn_match, wildcard_match_ignore_case}; +use super::matcher::wildcard_match_ignore_case; +use super::variables::resource_pattern_matches; /// The result of policy evaluation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -100,7 +101,7 @@ pub fn explicit_deny_matches_arc( policy.statements.iter().any(|stmt| { stmt.effect == Effect::Deny && action_matches(stmt, action) - && resource_matches(stmt, resource_arn) + && resource_matches(policy, stmt, resource_arn, context) && deny_conditions_apply(stmt, context) }) }) @@ -129,7 +130,7 @@ where for stmt in &policy.statements { if stmt.effect == Effect::Deny && action_matches(stmt, action) - && resource_matches(stmt, resource_arn) + && resource_matches(policy, stmt, resource_arn, context) && deny_conditions_apply(stmt, context) { return AuthzDecision::Deny; @@ -142,7 +143,7 @@ where let boundary_allows = boundary.statements.iter().any(|stmt| { stmt.effect == Effect::Allow && action_matches(stmt, action) - && resource_matches(stmt, resource_arn) + && resource_matches(boundary, stmt, resource_arn, context) && allow_conditions_grant(stmt, context) }); if !boundary_allows { @@ -155,7 +156,7 @@ where let session_allows = session.statements.iter().any(|stmt| { stmt.effect == Effect::Allow && action_matches(stmt, action) - && resource_matches(stmt, resource_arn) + && resource_matches(session, stmt, resource_arn, context) && allow_conditions_grant(stmt, context) }); if !session_allows { @@ -168,7 +169,7 @@ where for stmt in &policy.statements { if stmt.effect == Effect::Allow && action_matches(stmt, action) - && resource_matches(stmt, resource_arn) + && resource_matches(policy, stmt, resource_arn, context) && allow_conditions_grant(stmt, context) { return AuthzDecision::Allow; @@ -194,14 +195,27 @@ fn action_matches(statement: &Statement, request_action: &str) -> bool { } /// Check if the request resource matches the statement's resource constraint. -fn resource_matches(statement: &Statement, request_resource: &str) -> bool { +/// +/// In a `2012-10-17` document, `${key}` policy variables in each pattern are +/// expanded from `context` before matching, and a pattern with an unresolved +/// variable matches nothing. For `Resource` the statement then does not +/// apply; for `NotResource` every resource is outside the pattern, so the +/// statement applies to all of them. +fn resource_matches( + policy: &PolicyDocument, + statement: &Statement, + request_resource: &str, + context: &impl ConditionContext, +) -> bool { + let expand = policy.expands_policy_variables(); + let any_match = |patterns: &[String]| { + patterns + .iter() + .any(|p| resource_pattern_matches(p, request_resource, expand, context)) + }; match &statement.resource_match { - ResourceMatch::Resources(patterns) => { - patterns.iter().any(|p| arn_match(p, request_resource)) - } - ResourceMatch::NotResources(patterns) => { - !patterns.iter().any(|p| arn_match(p, request_resource)) - } + ResourceMatch::Resources(patterns) => any_match(patterns), + ResourceMatch::NotResources(patterns) => !any_match(patterns), } } diff --git a/crates/auth/src/policy/mod.rs b/crates/auth/src/policy/mod.rs index 2eac6a912..ceaf66376 100755 --- a/crates/auth/src/policy/mod.rs +++ b/crates/auth/src/policy/mod.rs @@ -14,3 +14,4 @@ pub mod document; pub mod evaluator; pub mod matcher; pub mod principal_id; +pub mod variables; diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs new file mode 100644 index 000000000..f23f72a2b --- /dev/null +++ b/crates/auth/src/policy/variables.rs @@ -0,0 +1,268 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! IAM policy variable expansion. +//! +//! A policy may write `${key}` in a `Resource` or `NotResource` pattern or in +//! a condition value, and IAM replaces it with the request's value for `key` +//! before matching. The forms are: +//! +//! - `${key}`: the value of `key` in the request context. A multivalued key +//! has no value, default or not: a set of values has no one value to +//! expand to. +//! - `${key, 'default'}`: the value of `key`, or `default` when the request +//! 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). Only a document +//! whose `Version` is `2012-10-17` expands variables; the caller decides that +//! with [`super::document::PolicyDocument::expands_policy_variables`]. +//! +//! ARN matching here follows the same field rule as +//! [`super::matcher::arn_match`]: the first five `:` separate fields, the +//! resource field keeps its own `:` and `/`, and both sides need the same +//! number of fields. + +use super::context::ConditionContext; +use super::matcher::arn_match; + +/// One unit of an expanded pattern. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Token { + /// `*` written in the policy text: matches zero or more bytes. + Star, + /// `?` written in the policy text: matches exactly one byte. + Question, + /// One byte matched literally. + Byte(u8), +} + +/// A `${...}` reference as written in a policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Reference<'a> { + /// `${key}` or `${key, 'default'}`. + Key { + name: &'a str, + default: Option<&'a str>, + }, + /// `${*}`, `${?}` or `${$}`. + Escape(u8), +} + +/// Parse the text between `${` and `}`. +/// +/// `*`, `?` and `$` are escapes. `name, 'default'` (whitespace around either +/// part ignored) is the default form. Anything else, including a `,` whose +/// right side is not single-quoted, is taken as the key name as written, which +/// is what the condition path did before the default form was read. +fn parse_reference(inner: &str) -> Reference<'_> { + match inner { + "*" => return Reference::Escape(b'*'), + "?" => return Reference::Escape(b'?'), + "$" => return Reference::Escape(b'$'), + _ => {} + } + if let Some((name, default)) = inner.split_once(',') { + let default = default.trim(); + if default.len() >= 2 && default.starts_with('\'') && default.ends_with('\'') { + return Reference::Key { + name: name.trim(), + default: Some(&default[1..default.len() - 1]), + }; + } + } + Reference::Key { + name: inner, + default: None, + } +} + +/// One piece of a policy string: plain text, or a `${...}` reference with its +/// raw text including the braces. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Part<'a> { + Text(&'a str), + Ref(&'a str, Reference<'a>), +} + +/// Split `value` into plain text and `${...}` references. A `${` with no +/// closing `}` is plain text, as before. +fn parts(value: &str) -> Vec> { + let mut out = Vec::new(); + let mut rest = value; + while let Some(start) = rest.find("${") { + out.push(Part::Text(&rest[..start])); + let after_start = &rest[start + 2..]; + match after_start.find('}') { + Some(end) => { + let raw = &rest[start..start + 3 + end]; + out.push(Part::Ref(raw, parse_reference(&after_start[..end]))); + rest = &after_start[end + 1..]; + } + None => { + out.push(Part::Text(&rest[start..])); + rest = ""; + } + } + } + out.push(Part::Text(rest)); + out +} + +/// The request's value for `key`: the first value of a present key, the +/// default when the key is absent and a default was written, `None` when the +/// key is absent and no default was written. A present key with no values +/// expands to the empty string. A multivalued key is always `None`. +fn resolve<'a>( + name: &str, + default: Option<&'a str>, + context: &'a impl ConditionContext, +) -> Option<&'a str> { + if context.is_multivalued_key(name) { + return None; + } + match context.resolve_key(name) { + Some(values) => Some(values.first().copied().unwrap_or("")), + None => default, + } +} + +/// Expand `pattern` into tokens. Returns `None` when a variable names a key +/// the request does not carry and gives no default. +fn expand_tokens(pattern: &str, context: &impl ConditionContext) -> Option> { + let mut tokens = Vec::with_capacity(pattern.len()); + for part in parts(pattern) { + match part { + Part::Text(text) => tokens.extend(text.bytes().map(|b| match b { + b'*' => Token::Star, + b'?' => Token::Question, + other => Token::Byte(other), + })), + Part::Ref(_, Reference::Escape(byte)) => tokens.push(Token::Byte(byte)), + Part::Ref(_, Reference::Key { name, default }) => { + let value = resolve(name, default, context)?; + tokens.extend(value.bytes().map(Token::Byte)); + } + } + } + Some(tokens) +} + +/// Whether the `Resource` or `NotResource` pattern `pattern` matches +/// `resource_arn`. +/// +/// With `expand` false, or when `pattern` has no `${`, this is exactly +/// [`arn_match`]. Otherwise variables are expanded first and a pattern with an +/// unresolved variable matches nothing. +#[must_use] +pub fn resource_pattern_matches( + pattern: &str, + resource_arn: &str, + expand: bool, + context: &impl ConditionContext, +) -> bool { + if !expand || !pattern.contains("${") { + return arn_match(pattern, resource_arn); + } + match expand_tokens(pattern, context) { + Some(tokens) => arn_match_tokens(&tokens, resource_arn), + None => false, + } +} + +/// Expand variables in a condition value. +/// +/// `${key}` and `${key, 'default'}` are replaced by their value; a variable +/// whose key the request does not carry, with no default, is left as written, +/// and so are the `${*}`, `${?}` and `${$}` escapes, as before. +#[must_use] +pub fn expand_condition_value(value: &str, context: &impl ConditionContext) -> String { + if !value.contains("${") { + return value.to_owned(); + } + let mut result = String::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)); + } + } + } + result +} + +/// Match an expanded ARN pattern against an ARN value with the field rule of +/// [`arn_match`]. +fn arn_match_tokens(pattern: &[Token], value: &str) -> bool { + if pattern == [Token::Star] { + return true; + } + let p_fields = split_fields(pattern); + let v_fields: Vec<&str> = value.splitn(6, ':').collect(); + if p_fields.len() != v_fields.len() { + return false; + } + p_fields + .iter() + .zip(v_fields.iter()) + .all(|(p, v)| match_tokens(p, v.as_bytes())) +} + +/// Split at the first five literal `:` bytes, so the resource field keeps its +/// own `:`. +fn split_fields(pattern: &[Token]) -> Vec<&[Token]> { + let mut fields = Vec::with_capacity(6); + let mut rest = pattern; + while fields.len() < 5 { + match rest.iter().position(|t| *t == Token::Byte(b':')) { + Some(i) => { + fields.push(&rest[..i]); + rest = &rest[i + 1..]; + } + None => break, + } + } + fields.push(rest); + fields +} + +/// Greedy wildcard match over tokens, the same algorithm as +/// `matcher::wildcard_match` with `Star` and `Question` as the wildcards. +fn match_tokens(p: &[Token], v: &[u8]) -> bool { + let (plen, vlen) = (p.len(), v.len()); + let mut pi = 0; + let mut vi = 0; + let mut last_star = usize::MAX; + let mut match_from = 0; + + while vi < vlen { + if pi < plen && p[pi] == Token::Star { + last_star = pi + 1; + match_from = vi; + pi += 1; + } else if pi < plen && (p[pi] == Token::Question || p[pi] == Token::Byte(v[vi])) { + pi += 1; + vi += 1; + } else if last_star != usize::MAX { + match_from += 1; + vi = match_from; + pi = last_star; + } else { + return false; + } + } + + while pi < plen && p[pi] == Token::Star { + pi += 1; + } + pi == plen +} + +#[cfg(test)] +mod tests; diff --git a/crates/auth/src/policy/variables/tests.rs b/crates/auth/src/policy/variables/tests.rs new file mode 100644 index 000000000..e0f800c65 --- /dev/null +++ b/crates/auth/src/policy/variables/tests.rs @@ -0,0 +1,305 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Unit tests for policy variable parsing, expansion and token matching. + +use super::*; +use std::collections::HashMap; + +struct Ctx(HashMap>); + +impl Ctx { + fn empty() -> Self { + Self(HashMap::new()) + } + + fn with(mut self, key: &str, values: &[&str]) -> Self { + self.0.insert( + key.to_owned(), + values.iter().map(|v| (*v).to_owned()).collect(), + ); + self + } +} + +impl ConditionContext for Ctx { + fn resolve_key(&self, key: &str) -> Option> { + self.0 + .get(key) + .map(|v| v.iter().map(String::as_str).collect()) + } + + fn is_multivalued_key(&self, _key: &str) -> bool { + false + } +} + +const TEAM: &str = "aws:PrincipalTag/team"; +const BLUE: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/blue"; + +fn team_blue() -> Ctx { + Ctx::empty().with(TEAM, &["blue"]) +} + +// --- parse_reference --- + +#[test] +fn parse_plain_key() { + assert_eq!( + parse_reference("aws:username"), + Reference::Key { + name: "aws:username", + default: None + } + ); +} + +#[test] +fn parse_default_form_with_and_without_space() { + for inner in [ + "aws:username, 'anon'", + "aws:username,'anon'", + " aws:username , 'anon' ", + ] { + assert_eq!( + parse_reference(inner), + Reference::Key { + name: "aws:username", + default: Some("anon") + }, + "{inner}" + ); + } +} + +#[test] +fn parse_comma_without_quotes_is_key_text() { + assert_eq!( + parse_reference("a, b"), + Reference::Key { + name: "a, b", + default: None + } + ); +} + +#[test] +fn parse_escapes() { + assert_eq!(parse_reference("*"), Reference::Escape(b'*')); + assert_eq!(parse_reference("?"), Reference::Escape(b'?')); + assert_eq!(parse_reference("$"), Reference::Escape(b'$')); +} + +// --- resource_pattern_matches --- + +#[test] +fn no_variable_is_arn_match() { + let ctx = Ctx::empty(); + assert!(resource_pattern_matches( + "arn:aws:dynamodb:*:*:table/bl*", + BLUE, + true, + &ctx + )); + assert!(resource_pattern_matches("*", BLUE, true, &ctx)); + assert!(!resource_pattern_matches( + "arn:aws:dynamodb:*:*:table/red", + BLUE, + true, + &ctx + )); +} + +#[test] +fn variable_expands_when_enabled_only() { + let pattern = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team}"; + assert!(resource_pattern_matches(pattern, BLUE, true, &team_blue())); + assert!(!resource_pattern_matches( + pattern, + BLUE, + false, + &team_blue() + )); +} + +#[test] +fn unresolved_variable_matches_nothing() { + let pattern = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team}"; + assert!(!resource_pattern_matches( + pattern, + BLUE, + true, + &Ctx::empty() + )); + assert!(!resource_pattern_matches( + "${aws:PrincipalTag/team}*", + BLUE, + true, + &Ctx::empty() + )); +} + +#[test] +fn default_used_when_key_absent() { + let pattern = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team, 'blue'}"; + assert!(resource_pattern_matches(pattern, BLUE, true, &Ctx::empty())); + let red = Ctx::empty().with(TEAM, &["red"]); + assert!(!resource_pattern_matches(pattern, BLUE, true, &red)); +} + +#[test] +fn wildcard_after_variable_still_matches() { + let pattern = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team}*"; + let blue_x = "arn:aws:dynamodb:us-east-1:123456789012:table/blue-x"; + assert!(resource_pattern_matches( + pattern, + blue_x, + true, + &team_blue() + )); + assert!(!resource_pattern_matches( + pattern, + "arn:aws:dynamodb:us-east-1:123456789012:table/red", + true, + &team_blue() + )); +} + +#[test] +fn wildcard_inside_variable_value_is_literal() { + let pattern = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team}"; + let star = Ctx::empty().with(TEAM, &["*"]); + assert!(!resource_pattern_matches(pattern, BLUE, true, &star)); + assert!(resource_pattern_matches( + pattern, + "arn:aws:dynamodb:us-east-1:123456789012:table/*", + true, + &star + )); +} + +#[test] +fn escapes_are_literal_characters() { + let ctx = Ctx::empty(); + let star = "arn:aws:dynamodb:us-east-1:123456789012:table/${*}"; + assert!(!resource_pattern_matches(star, BLUE, true, &ctx)); + assert!(resource_pattern_matches( + star, + "arn:aws:dynamodb:us-east-1:123456789012:table/*", + true, + &ctx + )); + let question = "arn:aws:dynamodb:us-east-1:123456789012:table/${?}"; + assert!(!resource_pattern_matches( + question, + "arn:aws:dynamodb:us-east-1:123456789012:table/x", + true, + &ctx + )); + assert!(resource_pattern_matches( + question, + "arn:aws:dynamodb:us-east-1:123456789012:table/?", + true, + &ctx + )); + let dollar = "arn:aws:dynamodb:us-east-1:123456789012:table/${$}x"; + assert!(resource_pattern_matches( + dollar, + "arn:aws:dynamodb:us-east-1:123456789012:table/$x", + true, + &ctx + )); +} + +#[test] +fn multivalued_key_uses_first_value() { + let pattern = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team}"; + let ctx = Ctx::empty().with(TEAM, &["blue", "red"]); + assert!(resource_pattern_matches(pattern, BLUE, true, &ctx)); +} + +#[test] +fn present_key_with_no_values_expands_empty() { + let pattern = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team}"; + let ctx = Ctx::empty().with(TEAM, &[]); + assert!(resource_pattern_matches( + pattern, + "arn:aws:dynamodb:us-east-1:123456789012:table/", + true, + &ctx + )); + assert!(!resource_pattern_matches(pattern, BLUE, true, &ctx)); +} + +#[test] +fn field_count_rule_holds_after_expansion() { + let pattern = "arn:aws:dynamodb:${aws:RequestedRegion}:*:table/blue"; + let ctx = Ctx::empty().with("aws:RequestedRegion", &["us-east-1"]); + assert!(resource_pattern_matches(pattern, BLUE, true, &ctx)); + assert!(!resource_pattern_matches( + pattern, + "arn:aws:dynamodb:us-east-1:table/blue", + true, + &ctx + )); +} + +#[test] +fn unclosed_brace_is_literal_text() { + let pattern = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws"; + assert!(resource_pattern_matches( + pattern, + "arn:aws:dynamodb:us-east-1:123456789012:table/${aws", + true, + &Ctx::empty() + )); + assert!(!resource_pattern_matches( + pattern, + BLUE, + true, + &Ctx::empty() + )); +} + +// --- expand_condition_value --- + +#[test] +fn condition_value_expands_variable_and_default() { + let ctx = team_blue(); + assert_eq!( + expand_condition_value("team-${aws:PrincipalTag/team}", &ctx), + "team-blue" + ); + assert_eq!( + expand_condition_value("${aws:username, 'anon'}", &ctx), + "anon" + ); + assert_eq!( + expand_condition_value("${aws:PrincipalTag/team, 'none'}", &ctx), + "blue" + ); +} + +#[test] +fn condition_value_leaves_unresolved_and_escapes_literal() { + let ctx = Ctx::empty(); + assert_eq!( + expand_condition_value("${aws:username}", &ctx), + "${aws:username}" + ); + assert_eq!( + expand_condition_value("a${*}b${?}c${$}", &ctx), + "a${*}b${?}c${$}" + ); + assert_eq!(expand_condition_value("plain", &ctx), "plain"); + assert_eq!(expand_condition_value("${open", &ctx), "${open"); +} + +#[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", &ctx), + "xy" + ); +} From 00eadf18c6f4c4d2741e0cbbc217fab38841740c Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:45:49 +0000 Subject: [PATCH 02/32] test(auth): cover policy variables in Resource and NotResource at the evaluator One test per measured row of issue #533, plus the escapes, the default form in a condition value, the 2008-10-17 and no-Version documents for NotResource, the permissions boundary and session policy paths, and explicit_deny_matches_arc. PolicyDocument::expands_policy_variables moves beside the expansion code so document.rs does not grow. --- crates/auth/src/policy/document.rs | 26 -- crates/auth/src/policy/evaluator/tests.rs | 1 + .../evaluator/tests/resource_variables.rs | 285 ++++++++++++++++++ crates/auth/src/policy/variables.rs | 16 +- crates/auth/src/policy/variables/tests.rs | 17 ++ 5 files changed, 318 insertions(+), 27 deletions(-) create mode 100644 crates/auth/src/policy/evaluator/tests/resource_variables.rs diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index a5893169e..46d3d5415 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -20,19 +20,6 @@ pub struct PolicyDocument { pub statements: Vec, } -impl PolicyDocument { - /// Whether `${key}` policy variables in `Resource` and `NotResource` - /// patterns 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). - #[must_use] - pub fn expands_policy_variables(&self) -> bool { - self.version.as_deref() == Some("2012-10-17") - } -} - /// A single policy statement. #[derive(Debug, Clone)] pub struct Statement { @@ -506,18 +493,6 @@ fn parse_string_or_array(value: &Value) -> Result, PolicyParseError> mod tests { use super::*; - #[test] - fn version_2008_or_absent_does_not_expand_variables() { - let stmt = r#""Statement":[{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}]"#; - let v2008 = - PolicyDocument::from_json(&format!(r#"{{"Version":"2008-10-17",{stmt}}}"#)).unwrap(); - assert_eq!(v2008.version.as_deref(), Some("2008-10-17")); - assert!(!v2008.expands_policy_variables()); - let none = PolicyDocument::from_json(&format!("{{{stmt}}}")).unwrap(); - assert_eq!(none.version, None); - assert!(!none.expands_policy_variables()); - } - #[test] fn parse_simple_allow_policy() { let json = r#"{ @@ -530,7 +505,6 @@ mod tests { }"#; let doc = PolicyDocument::from_json(json).unwrap(); assert_eq!(doc.version.as_deref(), Some("2012-10-17")); - assert!(doc.expands_policy_variables()); assert_eq!(doc.statements.len(), 1); assert_eq!(doc.statements[0].effect, Effect::Allow); assert!(matches!( diff --git a/crates/auth/src/policy/evaluator/tests.rs b/crates/auth/src/policy/evaluator/tests.rs index dcc8e403e..651aba82f 100644 --- a/crates/auth/src/policy/evaluator/tests.rs +++ b/crates/auth/src/policy/evaluator/tests.rs @@ -60,3 +60,4 @@ fn parse(json: &str) -> PolicyDocument { mod case_three; mod combined; mod core; +mod resource_variables; diff --git a/crates/auth/src/policy/evaluator/tests/resource_variables.rs b/crates/auth/src/policy/evaluator/tests/resource_variables.rs new file mode 100644 index 000000000..a2bab2795 --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/resource_variables.rs @@ -0,0 +1,285 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Policy variables in `Resource` and `NotResource` (issue #533). +//! +//! One test per row of the issue's table, each measured with the IAM policy +//! simulator on 2026-10-24 with `dynamodb:GetItem`, plus the escapes, the +//! default form in a condition value, the `2008-10-17` and no-`Version` +//! documents for `NotResource`, and the boundary and session policy paths. + +use super::*; + +const TEAM: &str = "aws:PrincipalTag/team"; +const ARN: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/"; + +fn table(name: &str) -> String { + format!("{ARN}{name}") +} + +fn allow_all() -> PolicyDocument { + parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:*","Resource":"*" + }]}"#, + ) +} + +/// `Allow dynamodb:* on *` beside a `Deny dynamodb:GetItem` on `resource`. +fn allow_all_deny_get(version: Option<&str>, element: &str, resource: &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","{element}":"{ARN}{resource}" + }}]}}"# + )), + ] +} + +fn get(policies: &[PolicyDocument], target: &str, ctx: &Ctx) -> AuthzDecision { + evaluate_policies( + policies, + None, + None, + "dynamodb:GetItem", + &table(target), + ctx, + ) +} + +fn team_blue() -> Ctx { + Ctx::empty().with(TEAM, vec!["blue"]) +} + +// --- Rows 1 to 3: Deny Resource table/${aws:PrincipalTag/team} --- + +#[test] +fn row1_deny_variable_resource_denies_own_team_table() { + let p = allow_all_deny_get(Some("2012-10-17"), "Resource", "${aws:PrincipalTag/team}"); + assert_eq!(get(&p, "blue", &team_blue()), AuthzDecision::Deny); +} + +#[test] +fn row2_deny_variable_resource_leaves_other_table_allowed() { + let p = allow_all_deny_get(Some("2012-10-17"), "Resource", "${aws:PrincipalTag/team}"); + assert_eq!(get(&p, "red", &team_blue()), AuthzDecision::Allow); +} + +#[test] +fn row3_unresolved_variable_resource_denies_nothing() { + let p = allow_all_deny_get(Some("2012-10-17"), "Resource", "${aws:PrincipalTag/team}"); + assert_eq!(get(&p, "blue", &Ctx::empty()), AuthzDecision::Allow); +} + +// --- Row 4: wildcard after the variable --- + +#[test] +fn row4_wildcard_after_variable_still_matches() { + let p = allow_all_deny_get(Some("2012-10-17"), "Resource", "${aws:PrincipalTag/team}*"); + assert_eq!(get(&p, "blue-x", &team_blue()), AuthzDecision::Deny); + assert_eq!(get(&p, "red-x", &team_blue()), AuthzDecision::Allow); +} + +// --- Rows 5 and 6: Allow only table/${aws:username} --- + +fn allow_own_table() -> Vec { + vec![parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"{ARN}${{aws:username}}" + }}]}}"# + ))] +} + +#[test] +fn row5_allow_variable_resource_grants_own_table() { + let ctx = Ctx::empty().with("aws:username", vec!["alice"]); + assert_eq!(get(&allow_own_table(), "alice", &ctx), AuthzDecision::Allow); +} + +#[test] +fn row6_allow_variable_resource_does_not_grant_other_table() { + let ctx = Ctx::empty().with("aws:username", vec!["alice"]); + assert_eq!(get(&allow_own_table(), "bob", &ctx), AuthzDecision::Deny); +} + +// --- Rows 7 and 8: 2008-10-17 and no Version match the text as written --- + +#[test] +fn row7_version_2008_does_not_expand_resource() { + let p = allow_all_deny_get(Some("2008-10-17"), "Resource", "${aws:PrincipalTag/team}"); + assert_eq!(get(&p, "blue", &team_blue()), AuthzDecision::Allow); +} + +#[test] +fn row8_no_version_does_not_expand_resource() { + let p = allow_all_deny_get(None, "Resource", "${aws:PrincipalTag/team}"); + assert_eq!(get(&p, "blue", &team_blue()), AuthzDecision::Allow); +} + +// --- Rows 9 and 10: Deny NotResource table/${aws:PrincipalTag/team} --- + +#[test] +fn row9_deny_not_resource_variable_spares_own_team_table() { + let p = allow_all_deny_get( + Some("2012-10-17"), + "NotResource", + "${aws:PrincipalTag/team}", + ); + assert_eq!(get(&p, "blue", &team_blue()), AuthzDecision::Allow); +} + +#[test] +fn row10_deny_not_resource_variable_denies_other_table() { + let p = allow_all_deny_get( + Some("2012-10-17"), + "NotResource", + "${aws:PrincipalTag/team}", + ); + assert_eq!(get(&p, "red", &team_blue()), AuthzDecision::Deny); +} + +// --- Row 11: default form --- + +#[test] +fn row11_default_form_expands_when_key_absent() { + let p = allow_all_deny_get( + Some("2012-10-17"), + "Resource", + "${aws:PrincipalTag/team, 'none'}", + ); + assert_eq!(get(&p, "none", &Ctx::empty()), AuthzDecision::Deny); + assert_eq!(get(&p, "blue", &Ctx::empty()), AuthzDecision::Allow); + // With the key present the value wins over the default. + assert_eq!(get(&p, "blue", &team_blue()), AuthzDecision::Deny); + assert_eq!(get(&p, "none", &team_blue()), AuthzDecision::Allow); +} + +// --- Row 12: ${*} is a literal star, not a wildcard (measured) --- + +#[test] +fn row12_escaped_star_in_resource_is_literal() { + let p = allow_all_deny_get(Some("2012-10-17"), "Resource", "${*}"); + assert_eq!(get(&p, "x", &Ctx::empty()), AuthzDecision::Allow); + assert_eq!(get(&p, "*", &Ctx::empty()), AuthzDecision::Deny); +} + +#[test] +fn escaped_question_and_dollar_in_resource_are_literal() { + let q = allow_all_deny_get(Some("2012-10-17"), "Resource", "${?}"); + assert_eq!(get(&q, "x", &Ctx::empty()), AuthzDecision::Allow); + assert_eq!(get(&q, "?", &Ctx::empty()), AuthzDecision::Deny); + let d = allow_all_deny_get(Some("2012-10-17"), "Resource", "${$}x"); + assert_eq!(get(&d, "$x", &Ctx::empty()), AuthzDecision::Deny); + assert_eq!(get(&d, "x", &Ctx::empty()), AuthzDecision::Allow); +} + +// --- NotResource with 2008-10-17, no Version, and an unresolved variable --- + +#[test] +fn not_resource_version_2008_matches_text_as_written() { + // Unexpanded, the pattern matches no real table, so every table is + // "not in" it and the Deny applies to all of them. + let p = allow_all_deny_get( + Some("2008-10-17"), + "NotResource", + "${aws:PrincipalTag/team}", + ); + assert_eq!(get(&p, "blue", &team_blue()), AuthzDecision::Deny); + assert_eq!(get(&p, "red", &team_blue()), AuthzDecision::Deny); +} + +#[test] +fn not_resource_no_version_matches_text_as_written() { + let p = allow_all_deny_get(None, "NotResource", "${aws:PrincipalTag/team}"); + assert_eq!(get(&p, "blue", &team_blue()), AuthzDecision::Deny); +} + +#[test] +fn not_resource_unresolved_variable_applies_to_every_resource() { + // Derived from the measured rule, not measured: a pattern with an + // unresolved variable matches nothing, so every resource is outside it. + let p = allow_all_deny_get( + Some("2012-10-17"), + "NotResource", + "${aws:PrincipalTag/team}", + ); + assert_eq!(get(&p, "blue", &Ctx::empty()), AuthzDecision::Deny); +} + +// --- Default form in a condition value --- + +#[test] +fn condition_value_default_form_expands_when_key_absent() { + let p = parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"{ARN}*", + "Condition":{{"StringEquals":{{"aws:ResourceTag/owner":"${{aws:username, 'anon'}}"}}}} + }}]}}"# + )); + let owned_by_anon = Ctx::empty().with("aws:ResourceTag/owner", vec!["anon"]); + assert_eq!(get(&[p.clone()], "T", &owned_by_anon), AuthzDecision::Allow); + let owned_by_alice = Ctx::empty().with("aws:ResourceTag/owner", vec!["alice"]); + assert_eq!(get(&[p.clone()], "T", &owned_by_alice), AuthzDecision::Deny); + let alice_on_alice = owned_by_alice.with("aws:username", vec!["alice"]); + assert_eq!(get(&[p], "T", &alice_on_alice), AuthzDecision::Allow); +} + +// --- Boundary and session policy paths --- + +fn allow_own_table_doc() -> PolicyDocument { + allow_own_table().remove(0) +} + +#[test] +fn permissions_boundary_expands_resource_variable() { + let ctx = Ctx::empty().with("aws:username", vec!["alice"]); + let boundary = allow_own_table_doc(); + let decide = |target: &str| { + evaluate_policies( + &[allow_all()], + Some(&boundary), + None, + "dynamodb:GetItem", + &table(target), + &ctx, + ) + }; + assert_eq!(decide("alice"), AuthzDecision::Allow); + assert_eq!(decide("bob"), AuthzDecision::Deny); +} + +#[test] +fn session_policy_expands_resource_variable() { + let ctx = Ctx::empty().with("aws:username", vec!["alice"]); + let session = allow_own_table_doc(); + let decide = |target: &str| { + evaluate_policies( + &[allow_all()], + None, + Some(&session), + "dynamodb:GetItem", + &table(target), + &ctx, + ) + }; + assert_eq!(decide("alice"), AuthzDecision::Allow); + assert_eq!(decide("bob"), AuthzDecision::Deny); +} + +#[test] +fn explicit_deny_matches_arc_expands_resource_variable() { + use std::sync::Arc; + let p: Vec> = + allow_all_deny_get(Some("2012-10-17"), "Resource", "${aws:PrincipalTag/team}") + .into_iter() + .map(Arc::new) + .collect(); + let deny_on = |target: &str, ctx: &Ctx| { + explicit_deny_matches_arc(&p, None, None, "dynamodb:GetItem", &table(target), ctx) + }; + assert!(deny_on("blue", &team_blue())); + assert!(!deny_on("red", &team_blue())); + assert!(!deny_on("blue", &Ctx::empty())); +} diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index f23f72a2b..b4de3ba47 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -21,7 +21,7 @@ //! not carry, with no default, makes the pattern match nothing. Both rules //! were measured with the IAM policy simulator (issue #533). Only a document //! whose `Version` is `2012-10-17` expands variables; the caller decides that -//! with [`super::document::PolicyDocument::expands_policy_variables`]. +//! with [`PolicyDocument::expands_policy_variables`]. //! //! ARN matching here follows the same field rule as //! [`super::matcher::arn_match`]: the first five `:` separate fields, the @@ -29,8 +29,22 @@ //! number of fields. use super::context::ConditionContext; +use super::document::PolicyDocument; use super::matcher::arn_match; +impl PolicyDocument { + /// Whether `${key}` policy variables in `Resource` and `NotResource` + /// patterns 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). + #[must_use] + pub fn expands_policy_variables(&self) -> bool { + self.version.as_deref() == Some("2012-10-17") + } +} + /// One unit of an expanded pattern. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Token { diff --git a/crates/auth/src/policy/variables/tests.rs b/crates/auth/src/policy/variables/tests.rs index e0f800c65..704696068 100644 --- a/crates/auth/src/policy/variables/tests.rs +++ b/crates/auth/src/policy/variables/tests.rs @@ -41,6 +41,23 @@ fn team_blue() -> Ctx { Ctx::empty().with(TEAM, &["blue"]) } +// --- PolicyDocument::expands_policy_variables --- + +#[test] +fn only_version_2012_expands_variables() { + let stmt = r#""Statement":[{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}]"#; + let v2012 = + PolicyDocument::from_json(&format!(r#"{{"Version":"2012-10-17",{stmt}}}"#)).unwrap(); + assert!(v2012.expands_policy_variables()); + let v2008 = + PolicyDocument::from_json(&format!(r#"{{"Version":"2008-10-17",{stmt}}}"#)).unwrap(); + assert_eq!(v2008.version.as_deref(), Some("2008-10-17")); + assert!(!v2008.expands_policy_variables()); + let none = PolicyDocument::from_json(&format!("{{{stmt}}}")).unwrap(); + assert_eq!(none.version, None); + assert!(!none.expands_policy_variables()); +} + // --- parse_reference --- #[test] From 810cd7b002ea822fead54d391c9c828b697d2e5e Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:45:50 +0000 Subject: [PATCH 03/32] docs(security): state that Resource and NotResource expand policy variables --- docs/manuals/10-security-model.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index bb7a1c75d..5f1b2c609 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -117,7 +117,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. +Policy variables (e.g., `${aws:PrincipalTag/Team}`) are expanded in condition values. The `${key, 'default'}` form expands to `default` when the request does not carry `key`. ### Access Control Patterns @@ -141,7 +141,7 @@ Resources are identified by ARN: `arn:aws:dynamodb:::table/< - `Effect`: Allow, Deny - `Action` / `NotAction`: DynamoDB actions (e.g., `dynamodb:PutItem`, `dynamodb:*`) -- `Resource` / `NotResource`: ARN patterns with wildcards +- `Resource` / `NotResource`: ARN patterns with wildcards. In a `2012-10-17` document, `${key}` policy variables in the pattern are expanded from the request before matching (for example `arn:aws:dynamodb:*:*:table/${aws:username}`); a wildcard written after a variable still matches; `${key, 'default'}` falls back to `default`; `${*}`, `${?}` and `${$}` stand for literal characters; a variable whose key the request does not carry makes the pattern match nothing. A `2008-10-17` document, or one with no `Version`, matches the pattern as written, as IAM does. - `Condition`: Full condition block support (see operators and keys above) ## Transport Security From d0644c9175681cc008bf853fb2eb588fcee2b448 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:45:50 +0000 Subject: [PATCH 04/32] test(auth): borrow the condition default-form policy as a slice --- .../auth/src/policy/evaluator/tests/resource_variables.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/auth/src/policy/evaluator/tests/resource_variables.rs b/crates/auth/src/policy/evaluator/tests/resource_variables.rs index a2bab2795..f31ae99e4 100644 --- a/crates/auth/src/policy/evaluator/tests/resource_variables.rs +++ b/crates/auth/src/policy/evaluator/tests/resource_variables.rs @@ -218,12 +218,13 @@ fn condition_value_default_form_expands_when_key_absent() { "Condition":{{"StringEquals":{{"aws:ResourceTag/owner":"${{aws:username, 'anon'}}"}}}} }}]}}"# )); + let p = std::slice::from_ref(&p); let owned_by_anon = Ctx::empty().with("aws:ResourceTag/owner", vec!["anon"]); - assert_eq!(get(&[p.clone()], "T", &owned_by_anon), AuthzDecision::Allow); + assert_eq!(get(p, "T", &owned_by_anon), AuthzDecision::Allow); let owned_by_alice = Ctx::empty().with("aws:ResourceTag/owner", vec!["alice"]); - assert_eq!(get(&[p.clone()], "T", &owned_by_alice), AuthzDecision::Deny); + assert_eq!(get(p, "T", &owned_by_alice), AuthzDecision::Deny); let alice_on_alice = owned_by_alice.with("aws:username", vec!["alice"]); - assert_eq!(get(&[p], "T", &alice_on_alice), AuthzDecision::Allow); + assert_eq!(get(p, "T", &alice_on_alice), AuthzDecision::Allow); } // --- Boundary and session policy paths --- From b6dad4bed573af08b6ef9507d9cbcda4e994078d Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:45:50 +0000 Subject: [PATCH 05/32] fix(auth): treat multivalued keys as unset and keep a value's colons in its field Two rules of IAM's policy variable expansion, measured with the policy simulator: - A multivalued condition key is never a policy variable. A Resource pattern naming one matches no resource and a condition value naming one is not expanded, with one value in the request or several. The resolver gave such a key no value, as main does in a condition value, but it also dropped a default written beside the key. It now reads the key as one the request does not carry, so the default form applies. - Only a colon written in the policy separates ARN fields. A colon inside a substituted value is a byte of its field, so a region-field value of us-east-1:123456789012 matches no table ARN, where it used to shift the fields and match a stream ARN. Tests pin both, variables in the region and account fields, an unresolved variable before a wildcard, and a policy-text ? beside a variable. --- crates/auth/src/policy/evaluator/tests.rs | 1 + .../tests/resource_variables_fields.rs | 157 ++++++++++++++++++ crates/auth/src/policy/variables.rs | 34 ++-- crates/auth/src/policy/variables/tests.rs | 4 +- 4 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs diff --git a/crates/auth/src/policy/evaluator/tests.rs b/crates/auth/src/policy/evaluator/tests.rs index 651aba82f..bdce768a5 100644 --- a/crates/auth/src/policy/evaluator/tests.rs +++ b/crates/auth/src/policy/evaluator/tests.rs @@ -61,3 +61,4 @@ mod case_three; mod combined; mod core; mod resource_variables; +mod resource_variables_fields; diff --git a/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs new file mode 100644 index 000000000..a32d2d277 --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs @@ -0,0 +1,157 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Policy variables in `Resource`: multivalued keys, variables outside the +//! resource field, a `:` inside a value, and the wildcards beside a variable +//! (issue #533). Every expected decision was measured with the IAM policy +//! simulator on 2026-10-24 with `dynamodb:GetItem`. + +use super::*; + +const P: &str = "arn:aws:dynamodb:us-east-1:123456789012"; +const LEADING_KEYS: &str = "dynamodb:LeadingKeys"; + +fn allow_all_and(statement: &str) -> Vec { + vec![ + parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:*","Resource":"*" + }]}"#, + ), + parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{statement}]}}"# + )), + ] +} + +fn deny_get_on(resource: &str) -> Vec { + allow_all_and(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"{resource}"}}"# + )) +} + +fn get(policies: &[PolicyDocument], target: &str, ctx: &Ctx) -> AuthzDecision { + evaluate_policies(policies, None, None, "dynamodb:GetItem", target, ctx) +} + +fn leading_keys(values: Vec<&str>) -> Ctx { + Ctx::empty().with(LEADING_KEYS, values) +} + +// --- A multivalued key is never a variable (cells C1, D1) --- + +#[test] +fn a_deny_on_a_multivalued_key_variable_denies_no_table() { + let p = deny_get_on(&format!("{P}:table/${{dynamodb:LeadingKeys}}")); + for ctx in [ + leading_keys(vec!["blue", "red"]), + leading_keys(vec!["blue"]), + ] { + assert_eq!( + get(&p, &format!("{P}:table/blue"), &ctx), + AuthzDecision::Allow + ); + assert_eq!( + get(&p, &format!("{P}:table/red"), &ctx), + AuthzDecision::Allow + ); + } +} + +#[test] +fn an_allow_on_a_multivalued_key_variable_grants_no_table() { + let p = vec![parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"{P}:table/${{dynamodb:LeadingKeys}}" + }}]}}"# + ))]; + let ctx = leading_keys(vec!["blue", "red"]); + assert_eq!( + get(&p, &format!("{P}:table/blue"), &ctx), + AuthzDecision::Deny + ); +} + +#[test] +fn a_condition_value_naming_a_multivalued_key_is_not_expanded() { + let p = allow_all_and( + r#"{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:PrincipalTag/team":"${dynamodb:LeadingKeys}"}}}"#, + ); + for values in [vec!["blue", "red"], vec!["blue"]] { + let ctx = leading_keys(values).with("aws:PrincipalTag/team", vec!["blue"]); + assert_eq!(get(&p, &format!("{P}:table/x"), &ctx), AuthzDecision::Allow); + } +} + +// --- Variables before the fifth colon expand (cells C2, D3) --- + +#[test] +fn a_variable_in_the_region_field_expands() { + let p = deny_get_on("arn:aws:dynamodb:${aws:RequestedRegion}:123456789012:table/blue"); + let ctx = Ctx::empty().with("aws:RequestedRegion", vec!["us-east-1"]); + assert_eq!( + get(&p, &format!("{P}:table/blue"), &ctx), + AuthzDecision::Deny + ); +} + +#[test] +fn a_variable_in_the_account_field_expands() { + let p = deny_get_on("arn:aws:dynamodb:us-east-1:${aws:PrincipalAccount}:table/blue"); + let ctx = Ctx::empty().with("aws:PrincipalAccount", vec!["123456789012"]); + assert_eq!( + get(&p, &format!("{P}:table/blue"), &ctx), + AuthzDecision::Deny + ); +} + +// --- A ':' inside a value stays in its field (cells C3, D2) --- + +#[test] +fn a_colon_in_a_region_field_value_does_not_separate_fields() { + let p = deny_get_on("arn:aws:dynamodb:${aws:PrincipalTag/region}:*:table/T"); + let ctx = Ctx::empty().with("aws:PrincipalTag/region", vec!["us-east-1:123456789012"]); + assert_eq!(get(&p, &format!("{P}:table/T"), &ctx), AuthzDecision::Allow); + assert_eq!( + get(&p, &format!("{P}:table/A/stream/x:table/T"), &ctx), + AuthzDecision::Allow + ); +} + +#[test] +fn a_colon_in_a_resource_field_value_matches_the_same_name() { + let p = deny_get_on(&format!("{P}:table/${{aws:PrincipalTag/team}}")); + let ctx = Ctx::empty().with("aws:PrincipalTag/team", vec!["blue:x"]); + assert_eq!( + get(&p, &format!("{P}:table/blue:x"), &ctx), + AuthzDecision::Deny + ); + assert_eq!( + get(&p, &format!("{P}:table/blue"), &ctx), + AuthzDecision::Allow + ); +} + +// --- Wildcards beside a variable (cells C9, D4) --- + +#[test] +fn an_unresolved_variable_before_a_wildcard_matches_nothing() { + // An unresolved variable makes the pattern match nothing; expanding it to + // the empty string would leave `table/*` and deny every table. + let p = deny_get_on(&format!("{P}:table/${{aws:PrincipalTag/team}}*")); + assert_eq!( + get(&p, &format!("{P}:table/blue"), &Ctx::empty()), + AuthzDecision::Allow + ); +} + +#[test] +fn a_question_mark_written_beside_a_variable_is_a_wildcard() { + let p = deny_get_on(&format!("{P}:table/b?${{aws:PrincipalTag/team}}")); + let ctx = Ctx::empty().with("aws:PrincipalTag/team", vec!["ue"]); + assert_eq!( + get(&p, &format!("{P}:table/blue"), &ctx), + AuthzDecision::Deny + ); +} diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index b4de3ba47..9dbcf12eb 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -8,8 +8,8 @@ //! before matching. The forms are: //! //! - `${key}`: the value of `key` in the request context. A multivalued key -//! has no value, default or not: a set of values has no one value to -//! expand to. +//! is never a variable: it resolves as if the request did not carry it, +//! with one value or several (measured, issue #533 review cells C1 and D1). //! - `${key, 'default'}`: the value of `key`, or `default` when the request //! does not carry `key`. //! - `${*}`, `${?}`, `${$}`: a literal `*`, `?` or `$`. @@ -26,7 +26,9 @@ //! ARN matching here follows the same field rule as //! [`super::matcher::arn_match`]: the first five `:` separate fields, the //! resource field keeps its own `:` and `/`, and both sides need the same -//! number of fields. +//! number of fields. Only a `:` written in the policy text separates fields; +//! a `:` inside a variable value is matched as a byte of its field (measured: +//! a region-field value `us-east-1:123456789012` matches no table ARN). use super::context::ConditionContext; use super::document::PolicyDocument; @@ -52,6 +54,9 @@ enum Token { Star, /// `?` written in the policy text: matches exactly one byte. Question, + /// `:` written in the policy text: a field separator among the first five, + /// a literal `:` in the resource field. + Colon, /// One byte matched literally. Byte(u8), } @@ -128,17 +133,17 @@ fn parts(value: &str) -> Vec> { out } -/// The request's value for `key`: the first value of a present key, the -/// default when the key is absent and a default was written, `None` when the -/// key is absent and no default was written. A present key with no values -/// expands to the empty string. A multivalued key is always `None`. +/// The request's value for `key`: the value of a present single-valued key, +/// the default when the key is absent and a default was written, `None` when +/// the key is absent and no default was written. A present key with no values +/// expands to the empty string. A multivalued key is treated as absent. fn resolve<'a>( name: &str, default: Option<&'a str>, context: &'a impl ConditionContext, ) -> Option<&'a str> { if context.is_multivalued_key(name) { - return None; + return default; } match context.resolve_key(name) { Some(values) => Some(values.first().copied().unwrap_or("")), @@ -155,6 +160,7 @@ fn expand_tokens(pattern: &str, context: &impl ConditionContext) -> Option tokens.extend(text.bytes().map(|b| match b { b'*' => Token::Star, b'?' => Token::Question, + b':' => Token::Colon, other => Token::Byte(other), })), Part::Ref(_, Reference::Escape(byte)) => tokens.push(Token::Byte(byte)), @@ -228,13 +234,13 @@ fn arn_match_tokens(pattern: &[Token], value: &str) -> bool { .all(|(p, v)| match_tokens(p, v.as_bytes())) } -/// Split at the first five literal `:` bytes, so the resource field keeps its -/// own `:`. +/// Split at the first five `:` written in the policy text, so the resource +/// field keeps its own `:` and a `:` from a variable value stays in its field. fn split_fields(pattern: &[Token]) -> Vec<&[Token]> { let mut fields = Vec::with_capacity(6); let mut rest = pattern; while fields.len() < 5 { - match rest.iter().position(|t| *t == Token::Byte(b':')) { + match rest.iter().position(|t| *t == Token::Colon) { Some(i) => { fields.push(&rest[..i]); rest = &rest[i + 1..]; @@ -260,7 +266,11 @@ fn match_tokens(p: &[Token], v: &[u8]) -> bool { last_star = pi + 1; match_from = vi; pi += 1; - } else if pi < plen && (p[pi] == Token::Question || p[pi] == Token::Byte(v[vi])) { + } else if pi < plen + && (p[pi] == Token::Question + || p[pi] == Token::Byte(v[vi]) + || (p[pi] == Token::Colon && v[vi] == b':')) + { pi += 1; vi += 1; } else if last_star != usize::MAX { diff --git a/crates/auth/src/policy/variables/tests.rs b/crates/auth/src/policy/variables/tests.rs index 704696068..260d0e2dc 100644 --- a/crates/auth/src/policy/variables/tests.rs +++ b/crates/auth/src/policy/variables/tests.rs @@ -229,7 +229,9 @@ fn escapes_are_literal_characters() { } #[test] -fn multivalued_key_uses_first_value() { +fn a_present_single_valued_key_with_several_values_uses_the_first() { + // The request context lists one value for a single-valued key; a second + // value can only come from a test context. The first is used, as before. let pattern = "arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team}"; let ctx = Ctx::empty().with(TEAM, &["blue", "red"]); assert!(resource_pattern_matches(pattern, BLUE, true, &ctx)); From 883e3ea99c61814e0f8cc73bf6f1ffa0caa0787a Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:45:50 +0000 Subject: [PATCH 06/32] docs(security): multivalued keys and colons in policy variable values --- docs/manuals/10-security-model.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index 5f1b2c609..665525dd2 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -117,7 +117,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. The `${key, 'default'}` form expands to `default` when the request does not carry `key`. +Policy variables (e.g., `${aws:PrincipalTag/Team}`) are expanded in condition values. The `${key, 'default'}` form expands to `default` when the request does not carry `key`. A multivalued key such as `dynamodb:LeadingKeys` is not expanded. ### Access Control Patterns @@ -141,7 +141,7 @@ Resources are identified by ARN: `arn:aws:dynamodb:::table/< - `Effect`: Allow, Deny - `Action` / `NotAction`: DynamoDB actions (e.g., `dynamodb:PutItem`, `dynamodb:*`) -- `Resource` / `NotResource`: ARN patterns with wildcards. In a `2012-10-17` document, `${key}` policy variables in the pattern are expanded from the request before matching (for example `arn:aws:dynamodb:*:*:table/${aws:username}`); a wildcard written after a variable still matches; `${key, 'default'}` falls back to `default`; `${*}`, `${?}` and `${$}` stand for literal characters; a variable whose key the request does not carry makes the pattern match nothing. A `2008-10-17` document, or one with no `Version`, matches the pattern as written, as IAM does. +- `Resource` / `NotResource`: ARN patterns with wildcards. In a `2012-10-17` document, `${key}` policy variables in the pattern are expanded from the request before matching (for example `arn:aws:dynamodb:*:*:table/${aws:username}`); a wildcard written after a variable still matches; `${key, 'default'}` falls back to `default`; `${*}`, `${?}` and `${$}` stand for literal characters; a variable whose key the request does not carry, or that names a multivalued key such as `dynamodb:LeadingKeys`, makes the pattern match nothing; a `:` inside a substituted value does not separate ARN fields. A `2008-10-17` document, or one with no `Version`, matches the pattern as written, as IAM does. - `Condition`: Full condition block support (see operators and keys above) ## Transport Security From 3cc7663fe976a9c75a7cb06b45f851139a0e8fcd Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 07/32] test(auth): pin policy variable rules that had no evaluator test Text substituted into a Resource pattern or a condition value from a variable value or a default is matched as text: it is not expanded a second time, and a `*` or `?` in it is not a wildcard. An Allow whose NotResource pattern holds an unresolved variable grants every table. The `${key, 'default'}` form, and the rule that a multivalued key is never a variable, hold under every condition operator form, not only bare StringEquals. New evaluator tests pin each rule. A resource tag value of `${aws:username}` names only the table `${aws:username}`, and the same text reached through `${aws:UserAgent}` is compared as written in a condition. The defaults `'*'` and `'b?'` match only `table/*` and `table/b?`. `Allow NotResource table/${aws:PrincipalTag/team}` with no tag grants `table/blue` and `table/red`. `${aws:PrincipalTag/team,'none'}` without a space denies `table/none`. A `team` value of `bl*` denies only `table/bl*`. The default form expands under ForAnyValue, ForAllValues, IfExists and Null, and `${dynamodb:LeadingKeys}` stays text under ForAnyValue, ForAllValues and IfExists. The NotResource Allow, no-space default and `bl*` cases repeat rows the IAM policy simulator measured; the other cases follow from those rules and are labelled as not measured. --- crates/auth/src/policy/evaluator/tests.rs | 1 + .../evaluator/tests/resource_variables.rs | 24 +++ .../tests/resource_variables_fields.rs | 16 ++ .../policy/evaluator/tests/variable_values.rs | 177 ++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 crates/auth/src/policy/evaluator/tests/variable_values.rs diff --git a/crates/auth/src/policy/evaluator/tests.rs b/crates/auth/src/policy/evaluator/tests.rs index bdce768a5..0dbd08a9f 100644 --- a/crates/auth/src/policy/evaluator/tests.rs +++ b/crates/auth/src/policy/evaluator/tests.rs @@ -62,3 +62,4 @@ mod combined; mod core; mod resource_variables; mod resource_variables_fields; +mod variable_values; diff --git a/crates/auth/src/policy/evaluator/tests/resource_variables.rs b/crates/auth/src/policy/evaluator/tests/resource_variables.rs index f31ae99e4..c4941214d 100644 --- a/crates/auth/src/policy/evaluator/tests/resource_variables.rs +++ b/crates/auth/src/policy/evaluator/tests/resource_variables.rs @@ -208,6 +208,30 @@ fn not_resource_unresolved_variable_applies_to_every_resource() { assert_eq!(get(&p, "blue", &Ctx::empty()), AuthzDecision::Deny); } +#[test] +fn allow_not_resource_with_unresolved_variable_grants_every_table() { + // Measured: an `Allow` alone, with no `team` tag. The pattern matches + // nothing, so every table is outside it and the Allow grants both. + let p = vec![parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{{ + "Effect":"Allow","Action":"dynamodb:GetItem","NotResource":"{ARN}${{aws:PrincipalTag/team}}" + }}]}}"# + ))]; + assert_eq!(get(&p, "blue", &Ctx::empty()), AuthzDecision::Allow); + assert_eq!(get(&p, "red", &Ctx::empty()), AuthzDecision::Allow); +} + +#[test] +fn default_form_without_a_space_expands_when_key_absent() { + // Measured: no space after the comma. + let p = allow_all_deny_get( + Some("2012-10-17"), + "Resource", + "${aws:PrincipalTag/team,'none'}", + ); + assert_eq!(get(&p, "none", &Ctx::empty()), AuthzDecision::Deny); +} + // --- Default form in a condition value --- #[test] diff --git a/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs index a32d2d277..c1256414d 100644 --- a/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs +++ b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs @@ -155,3 +155,19 @@ fn a_question_mark_written_beside_a_variable_is_a_wildcard() { AuthzDecision::Deny ); } + +// --- A wildcard inside a value is literal --- + +#[test] +fn a_wildcard_in_a_value_matches_only_the_same_text() { + let p = deny_get_on(&format!("{P}:table/${{aws:PrincipalTag/team}}")); + let ctx = Ctx::empty().with("aws:PrincipalTag/team", vec!["bl*"]); + assert_eq!( + get(&p, &format!("{P}:table/blue"), &ctx), + AuthzDecision::Allow + ); + assert_eq!( + get(&p, &format!("{P}:table/bl*"), &ctx), + AuthzDecision::Deny + ); +} diff --git a/crates/auth/src/policy/evaluator/tests/variable_values.rs b/crates/auth/src/policy/evaluator/tests/variable_values.rs new file mode 100644 index 000000000..abdfa6878 --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/variable_values.rs @@ -0,0 +1,177 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! 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, `IfExists` and `Null`. +//! +//! 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 +//! not measured. + +use super::*; + +const ARN: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/"; + +fn table(name: &str) -> String { + format!("{ARN}{name}") +} + +fn doc(statement: &str) -> PolicyDocument { + parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{statement}]}}"# + )) +} + +fn allow_all() -> PolicyDocument { + doc(r#"{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}"#) +} + +/// `Allow dynamodb:*` on `*` beside a `Deny dynamodb:GetItem` on +/// `table/`. +fn deny_get_on(resource: &str) -> Vec { + vec![ + allow_all(), + doc(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"{ARN}{resource}"}}"# + )), + ] +} + +/// `Allow dynamodb:*` on `*` beside a `Deny dynamodb:GetItem` on `*` whose +/// condition compares `key` with `value` under `operator`. +fn deny_get_if(operator: &str, key: &str, value: &str) -> Vec { + vec![ + allow_all(), + doc(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"{operator}":{{"{key}":"{value}"}}}}}}"# + )), + ] +} + +/// Only an `Allow dynamodb:GetItem` on every table whose condition compares +/// `key` with `value` under `operator`. +fn allow_get_if(operator: &str, key: &str, value: &str) -> Vec { + vec![doc(&format!( + r#"{{"Effect":"Allow","Action":"dynamodb:GetItem","Resource":"{ARN}*", + "Condition":{{"{operator}":{{"{key}":"{value}"}}}}}}"# + ))] +} + +fn get(policies: &[PolicyDocument], target: &str, ctx: &Ctx) -> AuthzDecision { + evaluate_policies( + policies, + None, + None, + "dynamodb:GetItem", + &table(target), + ctx, + ) +} + +// --- A substituted value is not expanded again --- + +#[test] +fn a_resource_value_holding_a_variable_is_not_expanded_again() { + // A resource tag can hold any text. The value `${aws:username}` names the + // table `${aws:username}`, not the caller's own table. + let p = deny_get_on("${aws:ResourceTag/alias}"); + let ctx = Ctx::empty() + .with("aws:ResourceTag/alias", vec!["${aws:username}"]) + .with("aws:username", vec!["alice"]); + assert_eq!(get(&p, "alice", &ctx), AuthzDecision::Allow); + assert_eq!(get(&p, "${aws:username}", &ctx), AuthzDecision::Deny); +} + +#[test] +fn a_condition_value_holding_a_variable_is_not_expanded_again() { + // `${aws:UserAgent}` expands to the text `${aws:username}`, which is + // compared as written, so the user name `alice` does not equal it. + let p = deny_get_if("StringEquals", "aws:username", "${aws:UserAgent}"); + let ctx = Ctx::empty() + .with("aws:UserAgent", vec!["${aws:username}"]) + .with("aws:username", vec!["alice"]); + assert_eq!(get(&p, "T", &ctx), AuthzDecision::Allow); + let equal = Ctx::empty() + .with("aws:UserAgent", vec!["alice"]) + .with("aws:username", vec!["alice"]); + assert_eq!(get(&p, "T", &equal), AuthzDecision::Deny); +} + +// --- A `*` or `?` in a default is literal --- + +#[test] +fn a_wildcard_in_a_resource_default_is_literal() { + let star = deny_get_on("${aws:PrincipalTag/team, '*'}"); + assert_eq!(get(&star, "x", &Ctx::empty()), AuthzDecision::Allow); + assert_eq!(get(&star, "*", &Ctx::empty()), AuthzDecision::Deny); + let question = deny_get_on("${aws:PrincipalTag/team, 'b?'}"); + assert_eq!(get(&question, "bx", &Ctx::empty()), AuthzDecision::Allow); + assert_eq!(get(&question, "b?", &Ctx::empty()), AuthzDecision::Deny); +} + +// --- The default form in a condition value, under every operator form --- + +/// A request on a table tagged `owner=` by a caller with no +/// `aws:username`. +fn owned_by(owner: &str) -> Ctx { + Ctx::empty().with("aws:ResourceTag/owner", vec![owner]) +} + +#[test] +fn the_default_form_expands_under_set_operators_and_if_exists() { + for operator in [ + "ForAnyValue:StringEquals", + "ForAllValues:StringEquals", + "StringEqualsIfExists", + ] { + let p = allow_get_if(operator, "aws:ResourceTag/owner", "${aws:username, 'anon'}"); + assert_eq!( + get(&p, "T", &owned_by("anon")), + AuthzDecision::Allow, + "{operator}" + ); + assert_eq!( + get(&p, "T", &owned_by("alice")), + AuthzDecision::Deny, + "{operator}" + ); + } +} + +#[test] +fn the_default_form_expands_in_a_null_condition() { + // With no `flag` tag the value is `false`, so the Allow needs `team`. + 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); +} + +// --- A multivalued key in a condition value, under every operator form --- + +#[test] +fn a_multivalued_key_in_a_condition_value_is_not_expanded_under_any_form() { + // Expanded to its first value, `${dynamodb:LeadingKeys}` would equal the + // request's own key `blue` and each Deny would apply. + let ctx = Ctx::empty() + .with("dynamodb:LeadingKeys", vec!["blue"]) + .with("aws:PrincipalTag/team", vec!["blue"]); + for (operator, key) in [ + ("ForAnyValue:StringEquals", "dynamodb:LeadingKeys"), + ("ForAllValues:StringEquals", "dynamodb:LeadingKeys"), + ("StringEqualsIfExists", "aws:PrincipalTag/team"), + ] { + let p = deny_get_if(operator, key, "${dynamodb:LeadingKeys}"); + assert_eq!(get(&p, "T", &ctx), AuthzDecision::Allow, "{operator}"); + } +} From 07ba980018d84a8291b1dc736a438e9bdb12b7e2 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 08/32] fix(auth): fail closed on an unresolvable key in a Resource variable A condition on a key the service populates but the context cannot resolve fails closed: a Deny applies and an Allow does not grant. A `${key}` variable naming such a key in Resource or NotResource was read as an absent key instead, so `Deny table/${key}` did not apply and `Allow NotResource table/${key}` granted every table. `resolve` now reports such a key on its own, before the multivalued rule and without using a default, and `resource_matches` fails closed when no pattern matched and one names it. A pattern that matches, or one whose variable names an absent key with no default, still decides alone. In this build the context reports one such key, `aws:RequestTag/` when the request names the tag under two spellings with different values, so a pattern naming that tag fails closed for such a request. No key is listed in `FAIL_CLOSED_GLOBAL_KEYS`; the comment on that list now names both places that read it. Condition values do not change here: #522's check fails a condition closed when a plain `${key}` in one of its values names such a key, and a value with a default is compared as written. The tests mark `aws:PrincipalOrgID` unresolvable through the test context. Deny on `table/${aws:PrincipalOrgID}`, with and without a default, and Deny NotResource block GetItem on `table/T`. Allow on `table/${aws:PrincipalOrgID, 'T'}` and Allow NotResource do not grant it. `table/T` in the same list, or an absent key in the same pattern before or after the unresolvable one, still decides. The multivalued `dynamodb:LeadingKeys`, marked unresolvable, fails closed the same way in Resource and NotResource, with and without a default. --- crates/auth/src/policy/context.rs | 8 ++ crates/auth/src/policy/evaluator.rs | 21 +++- .../src/policy/evaluator/tests/case_three.rs | 118 +++++++++++++++++- crates/auth/src/policy/variables.rs | 97 ++++++++++---- 4 files changed, 216 insertions(+), 28 deletions(-) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index be764624c..855f9c593 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -128,6 +128,14 @@ fn strip_prefix_ci<'a>(key: &'a str, prefix: &str) -> Option<&'a str> { /// IAM absent-key semantics. The slice stays so a future global key the service /// begins populating before ExtendDB resolves it can be made to fail closed by /// listing it. +/// +/// Two places read the set, through `is_unresolvable_key`: a condition whose +/// key is listed fails closed (`condition::evaluate_condition_match`), and so +/// does a `Resource` or `NotResource` pattern whose `${key}` variable names a +/// listed key (`variables::resource_pattern_is_unresolvable`). A variable that +/// names a listed key inside a condition value is left as written, as an +/// unresolved variable is there, so listing a key does not make such a +/// condition fail closed. const FAIL_CLOSED_GLOBAL_KEYS: &[&str] = &[]; /// Whether an unresolved condition key must fail closed for the context that diff --git a/crates/auth/src/policy/evaluator.rs b/crates/auth/src/policy/evaluator.rs index 049fb0737..f787fffc2 100644 --- a/crates/auth/src/policy/evaluator.rs +++ b/crates/auth/src/policy/evaluator.rs @@ -11,7 +11,7 @@ use super::condition::{ConditionMatch, evaluate_statement_conditions}; use super::context::ConditionContext; use super::document::{ActionMatch, Effect, PolicyDocument, ResourceMatch, Statement}; use super::matcher::wildcard_match_ignore_case; -use super::variables::resource_pattern_matches; +use super::variables::{resource_pattern_is_unresolvable, resource_pattern_matches}; /// The result of policy evaluation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -201,6 +201,11 @@ fn action_matches(statement: &Statement, request_action: &str) -> bool { /// variable matches nothing. For `Resource` the statement then does not /// apply; for `NotResource` every resource is outside the pattern, so the /// statement applies to all of them. +/// +/// When no pattern matches and one names a key the service populates but the +/// context cannot resolve, the statement fails closed, as a condition on such +/// a key does: a `Deny` applies and an `Allow` does not grant, for `Resource` +/// and `NotResource` alike. fn resource_matches( policy: &PolicyDocument, statement: &Statement, @@ -213,9 +218,19 @@ fn resource_matches( .iter() .any(|p| resource_pattern_matches(p, request_resource, expand, context)) }; + let unresolvable = |patterns: &[String]| { + patterns + .iter() + .any(|p| resource_pattern_is_unresolvable(p, expand, context)) + }; + let deny = statement.effect == Effect::Deny; match &statement.resource_match { - ResourceMatch::Resources(patterns) => any_match(patterns), - ResourceMatch::NotResources(patterns) => !any_match(patterns), + ResourceMatch::Resources(patterns) => { + any_match(patterns) || (deny && unresolvable(patterns)) + } + ResourceMatch::NotResources(patterns) => { + !any_match(patterns) && (deny || !unresolvable(patterns)) + } } } diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs index f6ab83e30..814bb4e8f 100644 --- a/crates/auth/src/policy/evaluator/tests/case_three.rs +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -3,7 +3,9 @@ //! Identity-policy evaluation of a key the service populates but the context //! cannot resolve (case 3): a Deny fails closed and blocks, an Allow gated on -//! it does not grant, and the negated-operator-on-absent rule cannot fire. +//! it does not grant, and the negated-operator-on-absent rule cannot fire. The +//! same holds when the key is named by a `${key}` variable in `Resource` or +//! `NotResource`. use super::*; @@ -120,3 +122,117 @@ fn resolved_principal_arn_allows_normally() { AuthzDecision::Allow ); } + +// --- A `${key}` variable in Resource or NotResource naming such a key --- + +const TABLE: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/"; + +fn only(statement: &str) -> PolicyDocument { + parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{statement}]}}"# + )) +} + +/// `Allow dynamodb:*` on `*` beside `statement`. +fn allow_all_and(statement: &str) -> Vec { + vec![ + only(r#"{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}"#), + only(statement), + ] +} + +/// GetItem on table `T` with `aws:PrincipalOrgID` unresolvable. +fn get_t(policies: &[PolicyDocument]) -> AuthzDecision { + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + evaluate_policies(policies, None, None, "dynamodb:GetItem", RES, &ctx) +} + +#[test] +fn deny_naming_an_unresolvable_key_in_resource_blocks_fail_closed() { + // As for the Deny gated on the key in a Condition above. The default is + // not used, because the service would have a value for the key. + for resource in ["${aws:PrincipalOrgID}", "${aws:PrincipalOrgID, 'none'}"] { + let p = allow_all_and(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"{TABLE}{resource}"}}"# + )); + assert_eq!(get_t(&p), AuthzDecision::Deny, "{resource}"); + } + let p = allow_all_and(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","NotResource":"{TABLE}${{aws:PrincipalOrgID}}"}}"# + )); + assert_eq!(get_t(&p), AuthzDecision::Deny); +} + +#[test] +fn allow_naming_an_unresolvable_key_in_resource_does_not_grant() { + // Read as an absent key, the default would grant `table/T` and the + // NotResource would grant every table. + for (element, resource) in [ + ("Resource", "${aws:PrincipalOrgID, 'T'}"), + ("NotResource", "${aws:PrincipalOrgID}"), + ] { + let p = [only(&format!( + r#"{{"Effect":"Allow","Action":"dynamodb:GetItem","{element}":"{TABLE}{resource}"}}"# + ))]; + assert_eq!(get_t(&p), AuthzDecision::Deny, "{element} {resource}"); + } +} + +#[test] +fn a_pattern_that_decides_alone_wins_over_an_unresolvable_one() { + // `table/T` in the list decides whatever the unresolvable key holds, and + // so does an absent key with no default, which makes its pattern match + // nothing, before or after the unresolvable variable. + let allow = [only(&format!( + r#"{{"Effect":"Allow","Action":"dynamodb:GetItem","Resource":["{TABLE}${{aws:PrincipalOrgID}}","{TABLE}T"]}}"# + ))]; + assert_eq!(get_t(&allow), AuthzDecision::Allow); + let spared = allow_all_and(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","NotResource":["{TABLE}T","{TABLE}${{aws:PrincipalOrgID}}"]}}"# + )); + assert_eq!(get_t(&spared), AuthzDecision::Allow); + for resource in [ + "${aws:PrincipalTag/team}${aws:PrincipalOrgID}", + "${aws:PrincipalOrgID}${aws:PrincipalTag/team}", + ] { + let absent = allow_all_and(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"{TABLE}{resource}"}}"# + )); + assert_eq!(get_t(&absent), AuthzDecision::Allow, "{resource}"); + } +} + +#[test] +fn a_multivalued_key_the_context_reports_unresolvable_fails_closed_in_resource() { + // The unresolvable rule comes before the multivalued one, so the default + // is not used: a Deny applies and an Allow does not grant. Read as an + // absent multivalued key, the default `T` would grant `table/T` and the + // NotResource would grant every table. + let ctx = Ctx::empty().unresolvable("dynamodb:LeadingKeys"); + for (element, resource) in [ + ("Resource", "${dynamodb:LeadingKeys, 'T'}"), + ("NotResource", "${dynamodb:LeadingKeys}"), + ] { + let allow = [only(&format!( + r#"{{"Effect":"Allow","Action":"dynamodb:GetItem","{element}":"{TABLE}{resource}"}}"# + ))]; + assert_eq!( + evaluate_policies(&allow, None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Deny, + "Allow {element} {resource}" + ); + } + for (element, resource) in [ + ("Resource", "${dynamodb:LeadingKeys}"), + ("NotResource", "${dynamodb:LeadingKeys, 'T'}"), + ] { + let deny = allow_all_and(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","{element}":"{TABLE}{resource}"}}"# + )); + assert_eq!( + evaluate_policies(&deny, None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Deny, + "Deny {element} {resource}" + ); + } +} diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index 9dbcf12eb..436ecfbaa 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -19,9 +19,12 @@ //! 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). Only a document -//! whose `Version` is `2012-10-17` expands variables; the caller decides that -//! with [`PolicyDocument::expands_policy_variables`]. +//! 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 +//! `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`]. //! //! ARN matching here follows the same field rule as //! [`super::matcher::arn_match`]: the first five `:` separate fields, the @@ -133,28 +136,49 @@ fn parts(value: &str) -> Vec> { out } +/// Why a variable has no text to substitute. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Missing { + /// The request does not carry the key and no default was written. + Absent, + /// The service populates the key but the context cannot resolve it + /// (`ConditionContext::is_unresolvable_key`). A default is not used, + /// because the service would have a value. + Unresolvable, +} + /// The request's value for `key`: the value of a present single-valued key, -/// the default when the key is absent and a default was written, `None` when -/// the key is absent and no default was written. A present key with no values -/// expands to the empty string. A multivalued key is treated as absent. +/// or the default when the key is absent and a default was written. A present +/// key with no values expands to the empty string. A multivalued key is +/// treated as absent. A key the request does not carry and the context +/// reports unresolvable is [`Missing::Unresolvable`] before either rule, +/// multivalued or not, so its default is not used. fn resolve<'a>( name: &str, default: Option<&'a str>, context: &'a impl ConditionContext, -) -> Option<&'a str> { +) -> Result<&'a str, Missing> { + let values = context.resolve_key(name); + if values.is_none() && context.is_unresolvable_key(name) { + return Err(Missing::Unresolvable); + } if context.is_multivalued_key(name) { - return default; + return default.ok_or(Missing::Absent); } - match context.resolve_key(name) { - Some(values) => Some(values.first().copied().unwrap_or("")), - None => default, + match values { + Some(values) => Ok(values.first().copied().unwrap_or("")), + None => default.ok_or(Missing::Absent), } } -/// Expand `pattern` into tokens. Returns `None` when a variable names a key -/// the request does not carry and gives no default. -fn expand_tokens(pattern: &str, context: &impl ConditionContext) -> Option> { +/// Expand `pattern` into tokens. Fails with [`Missing::Absent`] when a +/// variable names a key the request does not carry and gives no default, +/// since that alone makes the pattern match nothing, and otherwise with +/// [`Missing::Unresolvable`] when a variable names a key the context cannot +/// resolve. +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) { match part { Part::Text(text) => tokens.extend(text.bytes().map(|b| match b { @@ -165,20 +189,28 @@ fn expand_tokens(pattern: &str, context: &impl ConditionContext) -> Option tokens.push(Token::Byte(byte)), Part::Ref(_, Reference::Key { name, default }) => { - let value = resolve(name, default, context)?; - tokens.extend(value.bytes().map(Token::Byte)); + match resolve(name, default, context) { + Ok(value) => tokens.extend(value.bytes().map(Token::Byte)), + Err(Missing::Unresolvable) => unresolvable = true, + Err(Missing::Absent) => return Err(Missing::Absent), + } } } } - Some(tokens) + if unresolvable { + Err(Missing::Unresolvable) + } else { + Ok(tokens) + } } /// Whether the `Resource` or `NotResource` pattern `pattern` matches /// `resource_arn`. /// /// With `expand` false, or when `pattern` has no `${`, this is exactly -/// [`arn_match`]. Otherwise variables are expanded first and a pattern with an -/// unresolved variable matches nothing. +/// [`arn_match`]. Otherwise variables are expanded first, and a pattern with +/// an unresolved variable matches nothing, as does one that +/// [`resource_pattern_is_unresolvable`] reports. #[must_use] pub fn resource_pattern_matches( pattern: &str, @@ -189,17 +221,34 @@ pub fn resource_pattern_matches( if !expand || !pattern.contains("${") { return arn_match(pattern, resource_arn); } - match expand_tokens(pattern, context) { - Some(tokens) => arn_match_tokens(&tokens, resource_arn), - None => false, - } + expand_tokens(pattern, context).is_ok_and(|tokens| arn_match_tokens(&tokens, resource_arn)) +} + +/// Whether expanding `pattern` stops on a variable whose key the service +/// populates but the context cannot resolve, so whether the pattern matches +/// is unknown. A pattern that also names an absent key with no default is not +/// reported: it matches nothing whatever the other value is. +/// +/// The caller fails closed on such a pattern, as on a condition key the +/// context cannot resolve: a `Deny` applies and an `Allow` does not grant. +#[must_use] +pub fn resource_pattern_is_unresolvable( + pattern: &str, + expand: bool, + context: &impl ConditionContext, +) -> bool { + expand + && pattern.contains("${") + && expand_tokens(pattern, context) == Err(Missing::Unresolvable) } /// Expand variables in a condition value. /// /// `${key}` and `${key, 'default'}` are replaced by their value; a variable /// whose key the request does not carry, with no default, is left as written, -/// and so are the `${*}`, `${?}` and `${$}` escapes, as before. +/// and so are the `${*}`, `${?}` and `${$}` escapes, as before. A variable +/// whose key the context cannot resolve is also left as written, with or +/// without a default; a condition value does not fail closed on it. #[must_use] pub fn expand_condition_value(value: &str, context: &impl ConditionContext) -> String { if !value.contains("${") { From e71157af05a9e637852aba536a98629fecaec8a6 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 09/32] docs(auth): name the measured cases instead of matrix cell labels Comments in the policy variable code and tests cited cells of a table in an issue comment (C1, D1 and so on), which a reader of the tree cannot look up. Each label is replaced by the case it stood for, with the PR #535 body table as the source: `dynamodb:LeadingKeys` with one value and with two, `aws:RequestedRegion` and `aws:PrincipalAccount` in the region and account fields, a region tag value `us-east-1:123456789012` and `team=blue:x`, and `table/${aws:PrincipalTag/team}*` with no tag beside `table/b?${aws:PrincipalTag/team}`. Comments only. --- .../evaluator/tests/resource_variables_fields.rs | 13 +++++++++---- crates/auth/src/policy/variables.rs | 4 +++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs index c1256414d..19e66cad4 100644 --- a/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs +++ b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs @@ -38,7 +38,8 @@ fn leading_keys(values: Vec<&str>) -> Ctx { Ctx::empty().with(LEADING_KEYS, values) } -// --- A multivalued key is never a variable (cells C1, D1) --- +// --- A multivalued key is never a variable (`dynamodb:LeadingKeys` with one +// value and with two, PR #535 body table) --- #[test] fn a_deny_on_a_multivalued_key_variable_denies_no_table() { @@ -84,7 +85,9 @@ fn a_condition_value_naming_a_multivalued_key_is_not_expanded() { } } -// --- Variables before the fifth colon expand (cells C2, D3) --- +// --- Variables before the fifth colon expand (`aws:RequestedRegion` in the +// region field, `aws:PrincipalAccount` in the account field, PR #535 body +// table) --- #[test] fn a_variable_in_the_region_field_expands() { @@ -106,7 +109,8 @@ fn a_variable_in_the_account_field_expands() { ); } -// --- A ':' inside a value stays in its field (cells C3, D2) --- +// --- A ':' inside a value stays in its field (a region tag value +// `us-east-1:123456789012`, and `team=blue:x`, PR #535 body table) --- #[test] fn a_colon_in_a_region_field_value_does_not_separate_fields() { @@ -133,7 +137,8 @@ fn a_colon_in_a_resource_field_value_matches_the_same_name() { ); } -// --- Wildcards beside a variable (cells C9, D4) --- +// --- Wildcards beside a variable (`table/${aws:PrincipalTag/team}*` with no +// `team` tag, and `table/b?${aws:PrincipalTag/team}`, PR #535 body table) --- #[test] fn an_unresolved_variable_before_a_wildcard_matches_nothing() { diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index 436ecfbaa..0972ad6ec 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -9,7 +9,9 @@ //! //! - `${key}`: the value of `key` in the request context. A multivalued key //! is never a variable: it resolves as if the request did not carry it, -//! with one value or several (measured, issue #533 review cells C1 and D1). +//! with one value or several (measured with `dynamodb:LeadingKeys` holding +//! one value and two, in `Resource` and in a condition value; PR #535 body +//! table). //! - `${key, 'default'}`: the value of `key`, or `default` when the request //! does not carry `key`. //! - `${*}`, `${?}`, `${$}`: a literal `*`, `?` or `$`. From c63894752a4da1c99d5ab3f3ffbd8779956b9bcb Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 10/32] test(auth): say which policy variable expectations were measured The header of resource_variables.rs said every test was a measured row, while the `${?}` and `${$}` escapes, NotResource in a 2008-10-17 or no-Version document, the condition default form and the boundary, session policy and explicit_deny_matches_arc tests were never measured. The Deny NotResource test with no tag said "not measured" although the simulator measured that case, and the `${*}` heading said "measured" for both its checks although only `table/x` was measured. The header now names the tests that repeat a simulator case, the checks inside them that go beyond the measured target or context, and the tests that follow from the measured rules. The fields file header names its two derived checks. Comments only. --- .../evaluator/tests/resource_variables.rs | 23 +++++++++++++------ .../tests/resource_variables_fields.rs | 6 +++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/auth/src/policy/evaluator/tests/resource_variables.rs b/crates/auth/src/policy/evaluator/tests/resource_variables.rs index c4941214d..f40700feb 100644 --- a/crates/auth/src/policy/evaluator/tests/resource_variables.rs +++ b/crates/auth/src/policy/evaluator/tests/resource_variables.rs @@ -3,10 +3,19 @@ //! Policy variables in `Resource` and `NotResource` (issue #533). //! -//! One test per row of the issue's table, each measured with the IAM policy -//! simulator on 2026-10-24 with `dynamodb:GetItem`, plus the escapes, the -//! default form in a condition value, the `2008-10-17` and no-`Version` -//! documents for `NotResource`, and the boundary and session policy paths. +//! The tests `row1` to `row11` repeat the rows of the issue's table, and +//! `row12` the `${*}` case measured with them, with the IAM policy simulator +//! on 2026-10-24 and `dynamodb:GetItem`. Three more repeat cases measured +//! the same day for the PR #535 body table: +//! `not_resource_unresolved_variable_applies_to_every_resource`, +//! `allow_not_resource_with_unresolved_variable_grants_every_table` and +//! `default_form_without_a_space_expands_when_key_absent`. A check on a +//! second target or context beside the measured one is derived: `red-x` in +//! `row4`, `table/blue` and the present-key checks in `row11`, and `table/*` +//! in `row12`. The other tests follow from those rules and were not +//! measured: the `${?}` and `${$}` escapes, `NotResource` in a `2008-10-17` +//! or no-`Version` document, the default form in a condition value, and the +//! boundary, session policy and `explicit_deny_matches_arc` paths. use super::*; @@ -156,7 +165,7 @@ fn row11_default_form_expands_when_key_absent() { assert_eq!(get(&p, "none", &team_blue()), AuthzDecision::Allow); } -// --- Row 12: ${*} is a literal star, not a wildcard (measured) --- +// --- Row 12: ${*} is a literal star (`table/x` measured, `table/*` derived) --- #[test] fn row12_escaped_star_in_resource_is_literal() { @@ -198,8 +207,8 @@ fn not_resource_no_version_matches_text_as_written() { #[test] fn not_resource_unresolved_variable_applies_to_every_resource() { - // Derived from the measured rule, not measured: a pattern with an - // unresolved variable matches nothing, so every resource is outside it. + // Measured (PR #535 body table): with no `team` tag the pattern matches + // nothing, so every resource is outside it and the Deny applies. let p = allow_all_deny_get( Some("2012-10-17"), "NotResource", diff --git a/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs index 19e66cad4..11f73a978 100644 --- a/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs +++ b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs @@ -3,8 +3,10 @@ //! Policy variables in `Resource`: multivalued keys, variables outside the //! resource field, a `:` inside a value, and the wildcards beside a variable -//! (issue #533). Every expected decision was measured with the IAM policy -//! simulator on 2026-10-24 with `dynamodb:GetItem`. +//! (issue #533). Every test repeats a case measured with the IAM policy +//! simulator on 2026-10-24 with `dynamodb:GetItem` (PR #535 body table). Two +//! checks beside a measured one are derived: `table/red` with one +//! `dynamodb:LeadingKeys` value, and `table/blue` in the `blue:x` test. use super::*; From c2ac656c2a44ffaa9d4117bb35c925fa9ebcca63 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 11/32] fix(auth): refuse a policy Version other than the two IAM values The policy write paths checked that a `Version` member exists but not its value, and the value now decides whether Resource and NotResource variables expand. A document whose `Version` was `"2012-10-18"`, `"2012-10-17 "`, `20121017`, `null`, `["2012-10-17"]` or `"2012-10-17T"` was stored, and every variable in its Resource patterns then matched as literal text, with no error at write. `from_json_for_write` now refuses a `Version` that is not the string `2012-10-17` or `2008-10-17`, the two values in IAM's policy grammar, with `invalid Version (must be the string 2012-10-17 or 2008-10-17):` followed by the value. The check sits beside `expands_policy_variables` so document.rs does not grow. It runs at write only, so a stored document with another value keeps parsing. A missing `Version` is still refused by each write path before parsing. The user, group and role policy, permissions boundary and console writes reach the check; an AssumeRole session policy is still checked only for the member. The message IAM returns for such a document was not measured. The test writes each of the six values and expects the refusal, parses each as a stored document, and writes both accepted values. The security model manual and the backlog entry on `Version` are updated. --- crates/auth/src/policy/document.rs | 21 +++++++++------ crates/auth/src/policy/variables.rs | 24 ++++++++++++++++- crates/auth/src/policy/variables/tests.rs | 33 +++++++++++++++++++++++ docs/backlog.md | 2 +- docs/manuals/10-security-model.md | 2 +- 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index 46d3d5415..998f08357 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -13,8 +13,9 @@ use std::fmt; /// A parsed IAM policy document. #[derive(Debug, Clone)] pub struct PolicyDocument { - /// Policy version string (e.g., "2012-10-17"), `None` when the document - /// has no `Version`. + /// The `Version` string (e.g., "2012-10-17"); `None` when the document has + /// no `Version` or its value is not a string. Writes accept only + /// `2012-10-17` and `2008-10-17`; a stored document may hold any value. pub version: Option, /// The policy statements. pub statements: Vec, @@ -186,17 +187,21 @@ impl PolicyDocument { /// a managed or boundary policy) and apply the checks the service applies /// only when a policy is written. /// - /// Everything `from_json` checks, plus the `Sid` charset. The `Sid` check - /// runs at write only: a `Sid` is a statement label that does not affect - /// evaluation, so a policy stored before this check existed must keep - /// parsing on the request path rather than fail the principal closed. + /// Everything `from_json` checks, plus the `Version` value and the `Sid` + /// charset. Both run at write only, so a policy stored before either check + /// existed keeps parsing on the request path rather than failing the + /// principal closed: a `Sid` is a label that does not affect evaluation, + /// and a stored document with another `Version` matches its `Resource` + /// patterns as written, as it did before. /// /// # Errors /// - /// Returns `PolicyParseError` if `from_json` rejects the document or a - /// statement `Sid` is outside the service's charset. + /// Returns `PolicyParseError` if `from_json` rejects the document, its + /// `Version` is not `2012-10-17` or `2008-10-17`, or a statement `Sid` is + /// outside the service's charset. pub fn from_json_for_write(json: &str) -> Result { let doc = Self::from_json(json)?; + super::variables::validate_version(json)?; validate_statement_sids(&doc.statements)?; Ok(doc) } diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index 0972ad6ec..885e4511a 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -36,8 +36,9 @@ //! a region-field value `us-east-1:123456789012` matches no table ARN). use super::context::ConditionContext; -use super::document::PolicyDocument; +use super::document::{PolicyDocument, PolicyParseError}; use super::matcher::arn_match; +use serde_json::Value; impl PolicyDocument { /// Whether `${key}` policy variables in `Resource` and `NotResource` @@ -52,6 +53,27 @@ impl PolicyDocument { } } +/// Refuse a `Version` that is not the string `2012-10-17` or `2008-10-17`, +/// the two values of IAM's policy grammar. +/// +/// The value decides whether `Resource` and `NotResource` variables expand, +/// so a mistyped `Version` would otherwise turn that off for the whole +/// document with no error. +/// `PolicyDocument::from_json_for_write` calls this, so a stored document with +/// another value keeps parsing. A document with no `Version` passes here, +/// because each write path refuses it first. The message IAM returns for such +/// a document was not measured. +pub(super) fn validate_version(json: &str) -> Result<(), PolicyParseError> { + let raw: Value = serde_json::from_str(json).map_err(|e| PolicyParseError(e.to_string()))?; + match raw.get("Version") { + None => Ok(()), + Some(Value::String(v)) if v == "2012-10-17" || v == "2008-10-17" => Ok(()), + Some(other) => Err(PolicyParseError(format!( + "invalid Version (must be the string 2012-10-17 or 2008-10-17): {other}" + ))), + } +} + /// One unit of an expanded pattern. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Token { diff --git a/crates/auth/src/policy/variables/tests.rs b/crates/auth/src/policy/variables/tests.rs index 260d0e2dc..de57257c3 100644 --- a/crates/auth/src/policy/variables/tests.rs +++ b/crates/auth/src/policy/variables/tests.rs @@ -58,6 +58,39 @@ fn only_version_2012_expands_variables() { assert!(!none.expands_policy_variables()); } +#[test] +fn a_write_refuses_a_version_that_is_not_one_of_the_two_values() { + let stmt = r#""Statement":[{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}]"#; + for version in [ + r#""2012-10-18""#, + r#""2012-10-17 ""#, + "20121017", + "null", + r#"["2012-10-17"]"#, + r#""2012-10-17T""#, + ] { + let json = format!(r#"{{"Version":{version},{stmt}}}"#); + let refused = PolicyDocument::from_json_for_write(&json) + .err() + .map(|e| e.0); + let expected = + format!("invalid Version (must be the string 2012-10-17 or 2008-10-17): {version}"); + assert_eq!(refused, Some(expected), "{version}"); + // A stored document holding the value still parses, and does not expand. + let stored = PolicyDocument::from_json(&json).unwrap(); + assert!(!stored.expands_policy_variables(), "{version}"); + } + for version in ["2012-10-17", "2008-10-17"] { + let json = format!(r#"{{"Version":"{version}",{stmt}}}"#); + assert!( + PolicyDocument::from_json_for_write(&json).is_ok(), + "{version}" + ); + } + // A missing Version is refused by each write path before parsing. + assert!(PolicyDocument::from_json_for_write(&format!("{{{stmt}}}")).is_ok()); +} + // --- parse_reference --- #[test] diff --git a/docs/backlog.md b/docs/backlog.md index d335df5a3..f13fe5581 100755 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -8,7 +8,7 @@ Refreshed: v0.0.118 (P115) - ⬜ **Tagging rate limiting** — extenddb should implement `LimitExceededException` for rapid tag operations to match real DynamoDB behavior. 5 tagging tests fail against real DynamoDB due to this. (P114 follow-up) - ⬜ **Key-vs-item size gap** — batch/transact delete/update WCU uses key size, not old item size. Minor fidelity gap. - ⬜ **`f64` equality in `NumericEquals`/`NumericNotEquals`** — `policy/condition.rs` compares parsed numeric condition values with `f64` equality, violating the numeric-safety rule (and clippy `float_cmp`). Verify against AWS numeric-condition semantics (AWS treats these as arbitrary-precision decimals) and switch to `BigDecimal`/integer comparison. (BR-7085 follow-up) -- ⬜ **Non-`2012-10-17` policy `Version` accepted with warning** — extenddb accepts policy documents with an unrecognized `Version` string and logs a warning; real AWS rejects them with a `MalformedPolicyDocument` error. Confirm intended behavior and align. (BR-7085 follow-up) +- ⬜ **Policy `Version` refused at write, message not measured**: the identity-policy, permissions-boundary and console writes refuse a `Version` other than the string `2012-10-17` or `2008-10-17`. Not yet compared with the service: the message IAM returns for such a document, and what STS does with one in an `AssumeRole` session policy, where ExtendDB checks only that the member exists. (BR-7085 follow-up) ## Test Gaps diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index 665525dd2..a24448627 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -83,7 +83,7 @@ Policy sources collected for evaluation: - **Auth before JSON parse**: SigV4 signature verification runs before the request body is parsed. Invalid signatures are rejected with constant-time comparison before any business logic executes. - **Concurrent policy fetching**: Identity policies, group policies, and permissions boundaries are fetched concurrently from PostgreSQL. All must succeed for evaluation to proceed. - **Constant-time rejection for inactive keys**: Inactive or expired access keys are rejected without timing differences that could reveal key existence. -- **Policy document validation on write**: Policy documents are validated for JSON structure and size-capped (6,144 bytes) when attached via the management API. Invalid documents are rejected before storage. +- **Policy document validation on write**: Policy documents are validated for JSON structure and size-capped (6,144 bytes) when attached via the management API. Invalid documents are rejected before storage. A `Version` other than the string `2012-10-17` or `2008-10-17` is refused; only a `2012-10-17` document expands policy variables. - **Expression depth and token limits**: Expression parsing enforces configurable depth (default 150) and token limits (default 4,096) to prevent resource exhaustion. ### Supported Condition Operators From 011c6cbd4259a52c1d521ab828323d334b18c48d Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 12/32] docs(design): describe policy variables in Resource as implemented The requirements, the two auth component designs and the backlog said that policy variables are deferred and that Resource and Condition values are literal strings, which the expansion in policy/variables.rs now contradicts. REQ-ABAC-006 states the requirement as met for Resource, NotResource and condition values, and the non-goals table drops its policy variables row. The policy variables note in 05-component-auth.md describes the expansion and points to the security model manual, the Mode 3 limitation names only the condition-value gaps, and the v3 note no longer calls the support deferred. The backlog entry keeps the two condition-value gaps filed as #534. --- docs/backlog.md | 2 +- docs/design/01-requirements.md | 3 +-- docs/design/05-component-auth-v3.md | 2 +- docs/design/05-component-auth.md | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/backlog.md b/docs/backlog.md index f13fe5581..5304d0ea7 100755 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -40,7 +40,7 @@ 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 expansion spec/code/AWS divergence** — `05-component-auth.md` §6.5 and `01-requirements.md` (REQ-ABAC-006) state variable substitution is deferred/literal, but `condition.rs::expand_policy_variables` **is** implemented and applied to Condition values only, not Resource ARNs (`evaluator.rs::resource_matches`/`arn_match` do not expand). AWS expands in both. Three-way inconsistent (spec vs code vs AWS). Fail-closed (a `${var}` in a Resource ARN silently fails to match), so not a leak. Resolve by either (a) documenting the implemented condition-value expansion and marking resource-ARN expansion deferred, or (b) completing REQ-ABAC-006. Needs human direction. (BR-7085) +- ⬜ **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 394412bd2..c6e9b1294 100755 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -26,7 +26,6 @@ extenddb (ExtendDB) is a standalone, stateless Rust application that provides a | Contributor Insights (DescribeContributorInsights, ListContributorInsights, UpdateContributorInsights) | Excluded — analytics feature, not core API | | Kinesis Streaming Destination (DescribeKinesisStreamingDestination, EnableKinesisStreamingDestination, DisableKinesisStreamingDestination, UpdateKinesisStreamingDestination) | Excluded — AWS-specific integration | | Resource Policies (GetResourcePolicy, PutResourcePolicy, DeleteResourcePolicy) | Deferred — resource-based policies are a future enhancement | -| IAM Policy Variables (`${aws:PrincipalTag/key}`, `${aws:username}`, etc.) | Deferred — variable substitution in Resource ARNs and Condition values is a future enhancement (see REQ-ABAC-006) | | Federated Role Assumption (AssumeRoleWithSAML, AssumeRoleWithWebIdentity) | Deferred — v1 supports basic AssumeRole only (see REQ-IDENT-008) | | Role Chaining (role assumes role) | Deferred — v1 requires CallerArn to be a user; SourceIdentity and TransitiveTagKeys are deferred alongside (see REQ-IDENT-009) | | Role MaxSessionDuration | Deferred — v1 accepts any DurationSeconds with no per-role cap (see REQ-IDENT-010) | @@ -326,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 (Deferred): **Policy variables** (`${aws:PrincipalTag/key}`, `${aws:username}`, etc.) in Resource ARNs and Condition values are not supported in v1. In AWS IAM, policy variables enable a single policy to grant access to resources that match the caller's own tags — e.g., `"Resource": "arn:aws:dynamodb:*:*:table/${aws:PrincipalTag/Team}-*"`. Without policy variables, each team or department requires a separate policy with hardcoded resource ARNs, which reduces the ABAC testing value. This is a high-priority enhancement for a future version. +- 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. ### 4.5 Authorization — Caching diff --git a/docs/design/05-component-auth-v3.md b/docs/design/05-component-auth-v3.md index 7a920770b..e8ae9f7ba 100755 --- a/docs/design/05-component-auth-v3.md +++ b/docs/design/05-component-auth-v3.md @@ -266,7 +266,7 @@ When `extenddb init` creates the first admin user, it seeds a default policy. Wh } ``` -Note: the `${account_id}` and `${user_name}` in the default policy are literal substitutions performed at user creation time, not IAM policy variables. Policy variable support (REQ-ABAC-006) remains deferred. +Note: the `${account_id}` and `${user_name}` in the default policy are literal substitutions performed at user creation time, not IAM policy variables. IAM policy variables are expanded when a policy is evaluated (REQ-ABAC-006, `policy/variables.rs`). ## 6. Catalog Schema Changes for Multi-Account diff --git a/docs/design/05-component-auth.md b/docs/design/05-component-auth.md index 277826217..d1a75e692 100755 --- a/docs/design/05-component-auth.md +++ b/docs/design/05-component-auth.md @@ -501,7 +501,7 @@ pub enum ConditionOperator { } ``` -> **Deferred: Policy Variables.** AWS IAM supports variable substitution in `Resource` ARNs and `Condition` values — e.g., `"Resource": "arn:aws:dynamodb:*:*:table/${aws:PrincipalTag/Team}-*"` grants access to tables matching the caller's own team tag. extenddb v1 does not support policy variables; all Resource and Condition values are treated as literal strings (with wildcard matching where applicable). This means ABAC policies that use variables to generalize across principals must be written as separate per-principal policies. 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 of a `2012-10-17` document and in condition values (`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 makes the pattern match nothing. 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. **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 variable substitution is not supported.** Consistent with REQ-ABAC-006 deferral. Fetched policies containing `${aws:PrincipalTag/key}` will treat the variable syntax as a literal string. +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. 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. From 026c861c8cf3d761ef68f9cc237d092110f4244e Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 13/32] docs(upgrade): note the policy variable decision changes in v0.1.15 Stored policies change decision in three ways when policy variables expand in Resource and NotResource, and operators had no note of it: a pattern with a variable now matches the table it names, a condition value naming a multivalued key is no longer expanded to its first value, and the `${key, 'default'}` form now expands in condition values. The entry names each change, including an Allow that now grants and a Deny NotResource that now spares the named table, the write-time refusal of a Version other than 2012-10-17 or 2008-10-17, who is affected, and how to find affected policies. No catalog migration is required. --- docs/manuals/07-upgrade-manual.md | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index f4451a0f5..2c8eb1517 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -319,6 +319,45 @@ Catalog upgrades change the schema; behavior changes alter how the running serve interprets existing data or configuration. Review these before upgrading a live deployment, even when no catalog migration is required. +### v0.1.15: IAM policy variables expand in `Resource` and `NotResource` + +**What changed.** In a `2012-10-17` policy document, a `${key}` variable in a +`Resource` or `NotResource` pattern is now expanded from the request before the +pattern is matched, as IAM does. Before this release the pattern was compared as +literal text and matched no table. Text from a variable value or a default is +matched literally, a variable whose key the request does not carry makes the +pattern match nothing, and a `2008-10-17` document, or one with no `Version`, +still matches the pattern as written. Three decisions change for stored policies: + +1. A pattern with a variable now matches the table it names. `Allow + table/${aws:username}` now grants the caller's own table, where it granted + nothing. `Deny table/${aws:PrincipalTag/team}` now denies the team's table, + where it denied nothing. `Deny NotResource table/${aws:PrincipalTag/team}` now + spares the team's table, where it denied every table, and an `Allow + 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, so such a condition no longer matches. +3. A condition value written as `${key, 'default'}` now expands to `default` when + the request does not carry `key`. Before, the whole text was compared. + +**Policy writes check `Version`.** Writing a user, group or role policy or a +permissions boundary, through the management API or the console, now refuses a +document whose `Version` is not the string `2012-10-17` or `2008-10-17`, for +example `"2012-10-18"` or the number `20121017`, with `Invalid policy document: +policy parse error: invalid Version ...`. A document already stored with such a +value keeps evaluating, with its `Resource` patterns matched as written. A +session policy passed to `AssumeRole` is not checked. + +**Who is affected.** Any deployment with a stored policy whose `Resource`, +`NotResource` or condition values contain `${`: inline user, group and role +policies, permissions boundaries and session policies, and, for the two condition +value changes, role trust policies. Search the stored policies for `${` +(`extenddb manage list-user-policies`, `list-group-policies` and +`list-role-policies`) and check each match against the cases above before +upgrading, in particular an `Allow` that now grants and a `Deny NotResource` that +now spares a table. 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`, From cb153c12fdf246115fb737cd6bc903bc7e066784 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 14/32] test(auth): pin a condition value naming an unresolvable key A condition value `${key, 'default'}` whose key the context reports unresolvable keeps its text as written: resolve returns Missing::Unresolvable whatever the default, and expand_condition_value falls back to the raw text. No existing test put such a key in a condition value. The new case_three test puts `${aws:PrincipalOrgID, 'blue'}` in a StringEquals Deny on aws:PrincipalTag/team, for a caller tagged team=blue. With the key unresolvable the Deny does not apply. With the key absent the default is used and the Deny applies. With the key present its value is used, so `blue` denies and `red` does not. --- .../src/policy/evaluator/tests/case_three.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs index 814bb4e8f..6793f1501 100644 --- a/crates/auth/src/policy/evaluator/tests/case_three.rs +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -5,7 +5,8 @@ //! cannot resolve (case 3): a Deny fails closed and blocks, an Allow gated on //! it does not grant, and the negated-operator-on-absent rule cannot fire. The //! same holds when the key is named by a `${key}` variable in `Resource` or -//! `NotResource`. +//! `NotResource`. A variable naming the key in a condition value is compared +//! as written, with or without a default. use super::*; @@ -236,3 +237,30 @@ fn a_multivalued_key_the_context_reports_unresolvable_fails_closed_in_resource() ); } } + +// --- A `${key}` variable in a condition value naming such a key --- + +/// GetItem on table `T` by a caller tagged `team=blue`, with `ctx` for the +/// other keys. +fn get_t_as_blue(policies: &[PolicyDocument], ctx: Ctx) -> AuthzDecision { + let ctx = ctx.with("aws:PrincipalTag/team", vec!["blue"]); + evaluate_policies(policies, None, None, "dynamodb:GetItem", RES, &ctx) +} + +#[test] +fn a_condition_value_naming_an_unresolvable_key_is_compared_as_written() { + let p = allow_all_and( + r#"{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:PrincipalTag/team":"${aws:PrincipalOrgID, 'blue'}"}}}"#, + ); + // Unresolvable: the value is compared as written, which is not `blue`, so + // the Deny does not apply. The default is not used. + let unresolvable = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!(get_t_as_blue(&p, unresolvable), AuthzDecision::Allow); + // Absent: the default `blue` is used and the Deny applies. + assert_eq!(get_t_as_blue(&p, Ctx::empty()), AuthzDecision::Deny); + // Present: the key's value is used, not the default. + let org = |value| Ctx::empty().with("aws:PrincipalOrgID", vec![value]); + assert_eq!(get_t_as_blue(&p, org("blue")), AuthzDecision::Deny); + assert_eq!(get_t_as_blue(&p, org("red")), AuthzDecision::Allow); +} From fd8d5138a47641148050da95d0ac5d4e8953320f Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 15/32] test(auth): pin the Version gate on an unresolvable Resource key resource_pattern_is_unresolvable starts with `expand &&`, so a `2008-10-17` document, or one with no Version, matches a pattern naming a key the context cannot resolve as written and does not fail closed. Every test of the fail-closed rule used a `2012-10-17` document, so no test covered the gate. The new case_three test puts `Deny table/${aws:PrincipalOrgID}` in each of the two documents beside `Allow dynamodb:*`, with the key unresolvable: GetItem on table `T` is allowed, and GetItem on the table named `${aws:PrincipalOrgID}`, which the text matches, is denied. --- .../src/policy/evaluator/tests/case_three.rs | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs index 6793f1501..957fce4f4 100644 --- a/crates/auth/src/policy/evaluator/tests/case_three.rs +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -5,8 +5,9 @@ //! cannot resolve (case 3): a Deny fails closed and blocks, an Allow gated on //! it does not grant, and the negated-operator-on-absent rule cannot fire. The //! same holds when the key is named by a `${key}` variable in `Resource` or -//! `NotResource`. A variable naming the key in a condition value is compared -//! as written, with or without a default. +//! `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 is compared as written, with or without a default. use super::*; @@ -238,6 +239,33 @@ fn a_multivalued_key_the_context_reports_unresolvable_fails_closed_in_resource() } } +#[test] +fn an_unresolvable_key_in_a_2008_or_no_version_document_is_matched_as_written() { + // Only a `2012-10-17` document expands variables, so here the pattern is + // text: it names the table `${aws:PrincipalOrgID}` and no other, and the + // Deny does not fail closed. + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + let literal = format!("{TABLE}${{aws:PrincipalOrgID}}"); + for (label, version) in [ + ("2008-10-17", r#""Version":"2008-10-17","#), + ("no Version", ""), + ] { + let deny = parse(&format!( + r#"{{{version}"Statement":[{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"{literal}"}}]}}"# + )); + let p = [ + only(r#"{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}"#), + deny, + ]; + assert_eq!(get_t(&p), AuthzDecision::Allow, "{label}"); + assert_eq!( + evaluate_policies(&p, None, None, "dynamodb:GetItem", &literal, &ctx), + AuthzDecision::Deny, + "{label}" + ); + } +} + // --- A `${key}` variable in a condition value naming such a key --- /// GetItem on table `T` by a caller tagged `team=blue`, with `ctx` for the From 918f09a738804bca62a4f1fc2fcb6027aaf7f4a1 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 16/32] docs: say a Resource variable with a default uses the default The v0.1.15 upgrade entry and the policy variables paragraph of 05-component-auth.md said that a variable whose key the request does not carry makes the pattern match nothing. That holds only with no default. With a default, the default is used: `table/${aws:PrincipalTag/team, 'none'}` matches `table/none` when the request has no `team` tag, as measured with the IAM policy simulator for the PR #535 measurements table. Both places now say so. --- docs/design/05-component-auth.md | 2 +- docs/manuals/07-upgrade-manual.md | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/design/05-component-auth.md b/docs/design/05-component-auth.md index d1a75e692..df7ce422a 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 (`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 makes the pattern match nothing. 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 of a `2012-10-17` document and in condition values (`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. **JSON deserialization:** diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 2c8eb1517..ba6b6a453 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -325,9 +325,12 @@ deployment, even when no catalog migration is required. `Resource` or `NotResource` pattern is now expanded from the request before the pattern is matched, as IAM does. Before this release the pattern was compared as literal text and matched no table. Text from a variable value or a default is -matched literally, a variable whose key the request does not carry makes the -pattern match nothing, and a `2008-10-17` document, or one with no `Version`, -still matches the pattern as written. Three decisions change for stored policies: +matched literally, a variable whose key the request does not carry, with no +default, makes the pattern match nothing, and a `2008-10-17` document, or one +with no `Version`, still matches the pattern as written. 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). Three decisions change for stored policies: 1. A pattern with a variable now matches the table it names. `Allow table/${aws:username}` now grants the caller's own table, where it granted From 06522e5b333146060876b520300e02b84f9ed675 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 17/32] docs(upgrade): cover negated operators in the multivalued key item Item 2 of the v0.1.15 entry said a condition value naming a multivalued key is compared as written, so such a condition no longer matches. That holds for a positive operator. Under a negated operator the comparison with the text now succeeds where the comparison with the first value failed: `Allow` if `StringNotEquals` on aws:PrincipalTag/team with the value `${dynamodb:LeadingKeys}`, for a caller tagged team=blue whose request has the leading key `blue`, was denied before and is allowed now, and the same condition in a Deny now applies. The item now names both operator kinds with that example, and the check-before-upgrading sentence names an Allow that now grants and a Deny that now applies, for a Resource pattern with a variable and for such a negated condition. --- docs/manuals/07-upgrade-manual.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index ba6b6a453..332344a90 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -340,7 +340,13 @@ issue #533). Three 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, so such a condition no longer matches. + value; the text is compared as written. 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. A condition value written as `${key, 'default'}` now expands to `default` when the request does not carry `key`. Before, the whole text was compared. @@ -358,8 +364,10 @@ policies, permissions boundaries and session policies, and, for the two conditio value changes, role trust policies. Search the stored policies for `${` (`extenddb manage list-user-policies`, `list-group-policies` and `list-role-policies`) and check each match against the cases above before -upgrading, in particular an `Allow` that now grants and a `Deny NotResource` that -now spares a table. No catalog migration is required. +upgrading, in particular an `Allow` that now grants (a `Resource` pattern with a +variable, or a negated condition whose value names a multivalued key), a `Deny` +that now applies (the same two forms), and a `Deny NotResource` that now spares +a table. No catalog migration is required. ### v0.1.14 LeadingKeys now enforced on Query, batch and transaction operations From 056e0b959e529fb4a03c2bda2fd5740b583dbab2 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:12 +0000 Subject: [PATCH 18/32] docs(auth): correct policy variable labels and upgrade wording - variable_values: the multivalued condition-value test also covers Null. With dynamodb:LeadingKeys holding `false`, expanding `${dynamodb:LeadingKeys}` to its first value would make `Null aws:PrincipalTag/team` match for a caller with a team tag, and the Deny would apply; the test expects Allow. The module header already listed Null for both rules. - case_three: the header says its expectations are ExtendDB rules with no service measurement. The Version write test says its message is ExtendDB's own and that whether IAM refuses those values at write was not measured; validate_version and the backlog row say the same. - condition.rs: the expand_policy_variables comment says "unresolved" for a key the request does not carry, and names the key the context cannot resolve, which is left as written with or without a default. - Upgrade manual, v0.1.15: a permissions boundary write refuses another Version with `Invalid permissions boundary document: ...`; a session policy is checked only for its Version and Statement members; item 3 names the present-key case of `${key, 'default'}`; the search advice names get-user-boundary, get-role-boundary and list-roles for boundaries and trust policies. --- crates/auth/src/policy/condition.rs | 4 ++- .../src/policy/evaluator/tests/case_three.rs | 5 ++++ .../policy/evaluator/tests/variable_values.rs | 7 +++++ crates/auth/src/policy/variables.rs | 4 +-- crates/auth/src/policy/variables/tests.rs | 2 ++ docs/backlog.md | 2 +- docs/manuals/07-upgrade-manual.md | 29 ++++++++++++------- 7 files changed, 39 insertions(+), 14 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index fd66ac9fe..e11418299 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -255,7 +255,9 @@ pub fn evaluate_statement_conditions( /// Replaces `${variable}` and `${variable, 'default'}` patterns with their /// resolved values from the context (see [`super::variables`]). Supported /// variables: `aws:PrincipalTag/*` and any key the context can resolve. -/// Unresolvable variables with no default are left as-is (IAM behavior). +/// Unresolved variables with no default are left as-is (IAM behavior). A +/// variable naming a key the context cannot resolve is also left as-is, with +/// or without a default; see [`super::variables::expand_condition_value`]. fn expand_policy_variables(value: &str, context: &impl ConditionContext) -> String { super::variables::expand_condition_value(value, context) } diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs index 957fce4f4..caea88b0c 100644 --- a/crates/auth/src/policy/evaluator/tests/case_three.rs +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -8,6 +8,11 @@ //! `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 is compared as written, with or without a default. +//! +//! 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 +//! compare with, and no key is listed in `FAIL_CLOSED_GLOBAL_KEYS` in this +//! build. use super::*; diff --git a/crates/auth/src/policy/evaluator/tests/variable_values.rs b/crates/auth/src/policy/evaluator/tests/variable_values.rs index abdfa6878..fb06a4596 100644 --- a/crates/auth/src/policy/evaluator/tests/variable_values.rs +++ b/crates/auth/src/policy/evaluator/tests/variable_values.rs @@ -174,4 +174,11 @@ 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. + let null = Ctx::empty() + .with("dynamodb:LeadingKeys", vec!["false"]) + .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"); } diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index 885e4511a..c8dc297bc 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -61,8 +61,8 @@ impl PolicyDocument { /// document with no error. /// `PolicyDocument::from_json_for_write` calls this, so a stored document with /// another value keeps parsing. A document with no `Version` passes here, -/// because each write path refuses it first. The message IAM returns for such -/// a document was not measured. +/// because each write path refuses it first. Whether IAM refuses such a +/// document at write, and with what message, was not measured. pub(super) fn validate_version(json: &str) -> Result<(), PolicyParseError> { let raw: Value = serde_json::from_str(json).map_err(|e| PolicyParseError(e.to_string()))?; match raw.get("Version") { diff --git a/crates/auth/src/policy/variables/tests.rs b/crates/auth/src/policy/variables/tests.rs index de57257c3..9f990ca18 100644 --- a/crates/auth/src/policy/variables/tests.rs +++ b/crates/auth/src/policy/variables/tests.rs @@ -60,6 +60,8 @@ fn only_version_2012_expands_variables() { #[test] fn a_write_refuses_a_version_that_is_not_one_of_the_two_values() { + // The rule and its message are ExtendDB's own. Whether IAM refuses these + // values at write, and with what message, was not measured. let stmt = r#""Statement":[{"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}]"#; for version in [ r#""2012-10-18""#, diff --git a/docs/backlog.md b/docs/backlog.md index 5304d0ea7..b2207b03b 100755 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -8,7 +8,7 @@ Refreshed: v0.0.118 (P115) - ⬜ **Tagging rate limiting** — extenddb should implement `LimitExceededException` for rapid tag operations to match real DynamoDB behavior. 5 tagging tests fail against real DynamoDB due to this. (P114 follow-up) - ⬜ **Key-vs-item size gap** — batch/transact delete/update WCU uses key size, not old item size. Minor fidelity gap. - ⬜ **`f64` equality in `NumericEquals`/`NumericNotEquals`** — `policy/condition.rs` compares parsed numeric condition values with `f64` equality, violating the numeric-safety rule (and clippy `float_cmp`). Verify against AWS numeric-condition semantics (AWS treats these as arbitrary-precision decimals) and switch to `BigDecimal`/integer comparison. (BR-7085 follow-up) -- ⬜ **Policy `Version` refused at write, message not measured**: the identity-policy, permissions-boundary and console writes refuse a `Version` other than the string `2012-10-17` or `2008-10-17`. Not yet compared with the service: the message IAM returns for such a document, and what STS does with one in an `AssumeRole` session policy, where ExtendDB checks only that the member exists. (BR-7085 follow-up) +- ⬜ **Policy `Version` refused at write, not compared with IAM**: the identity-policy, permissions-boundary and console writes refuse a `Version` other than the string `2012-10-17` or `2008-10-17`. Not yet compared with the service: whether IAM refuses such a document at write and with what message, and what STS does with one in an `AssumeRole` session policy, where ExtendDB checks only that the member exists. (BR-7085 follow-up) ## Test Gaps diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 332344a90..a0ad41108 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -347,27 +347,36 @@ issue #533). Three decisions change for stored policies: `${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. A condition value written as `${key, 'default'}` now expands to `default` when - the request does not carry `key`. Before, the whole text was compared. +3. A condition value written as `${key, 'default'}` 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. **Policy writes check `Version`.** Writing a user, group or role policy or a permissions boundary, through the management API or the console, now refuses a document whose `Version` is not the string `2012-10-17` or `2008-10-17`, for example `"2012-10-18"` or the number `20121017`, with `Invalid policy document: -policy parse error: invalid Version ...`. A document already stored with such a -value keeps evaluating, with its `Resource` patterns matched as written. A -session policy passed to `AssumeRole` is not checked. +policy parse error: invalid Version ...`, or for a permissions boundary `Invalid +permissions boundary document: policy parse error: invalid Version ...`. A +document already stored with such a value keeps evaluating, with its `Resource` +patterns matched as written. A session policy passed to `AssumeRole` is checked +only for its `Version` and `Statement` members. **Who is affected.** Any deployment with a stored policy whose `Resource`, `NotResource` or condition values contain `${`: inline user, group and role policies, permissions boundaries and session policies, and, for the two condition value changes, role trust policies. Search the stored policies for `${` (`extenddb manage list-user-policies`, `list-group-policies` and -`list-role-policies`) and check each match against the cases above before -upgrading, in particular an `Allow` that now grants (a `Resource` pattern with a -variable, or a negated condition whose value names a multivalued key), a `Deny` -that now applies (the same two forms), and a `Deny NotResource` that now spares -a table. No catalog migration is required. +`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, in particular an +`Allow` that now grants (a `Resource` pattern with a variable, or a negated +condition whose value names a multivalued key), a `Deny` that now applies (the +same two forms), and a `Deny NotResource` that now spares a table. No catalog +migration is required. ### v0.1.14 LeadingKeys now enforced on Query, batch and transaction operations From a6aba3c2f8a50903ee524f98256e1cdbe585efdf Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:13 +0000 Subject: [PATCH 19/32] fix(auth): a present multivalued key ignores a variable default Measured with the IAM policy simulator on 2026-10-28: with aws:TagKeys as [x, y] or [x], table/${aws:TagKeys, 'x'} in Resource and ${aws:TagKeys, 'x'} as a StringEquals condition value both answer implicitDeny; with aws:TagKeys absent both answer allowed. The code used the default whenever the key was multivalued, present or not. Now a present multivalued key makes the pattern match nothing and keeps a condition value as written, and the default is used only when the request does not carry the key. Two evaluator tests pin the six cells on dynamodb:LeadingKeys; the security model and the module comment say the rule. --- .../tests/resource_variables_fields.rs | 43 +++++++++++++++++++ crates/auth/src/policy/variables.rs | 27 ++++++++---- docs/manuals/10-security-model.md | 2 +- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs index 11f73a978..20ffdf87f 100644 --- a/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs +++ b/crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs @@ -87,6 +87,49 @@ fn a_condition_value_naming_a_multivalued_key_is_not_expanded() { } } +// --- A multivalued key with a default: the default is used only when the +// request does not carry the key. Measured with the IAM policy simulator on +// 2026-10-28 with `aws:TagKeys` as `[x, y]`, `[x]` and absent, against +// `table/${aws:TagKeys, 'x'}` in `Resource` (implicitDeny, implicitDeny, +// allowed) and against `${aws:TagKeys, 'x'}` as a `StringEquals` condition +// value with `aws:PrincipalTag/team` = `x` (the same three answers). Run here +// on `dynamodb:LeadingKeys`, the multivalued key the test context declares. --- + +#[test] +fn a_multivalued_key_variable_with_a_default_matches_only_when_the_key_is_absent() { + let p = vec![parse(&format!( + r#"{{"Version":"2012-10-17","Statement":[{{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"{P}:table/${{dynamodb:LeadingKeys, 'x'}}" + }}]}}"# + ))]; + for values in [vec!["x", "y"], vec!["x"]] { + assert_eq!( + get(&p, &format!("{P}:table/x"), &leading_keys(values)), + AuthzDecision::Deny + ); + } + assert_eq!( + get(&p, &format!("{P}:table/x"), &Ctx::empty()), + AuthzDecision::Allow + ); +} + +#[test] +fn a_condition_value_naming_a_multivalued_key_with_a_default_uses_it_only_when_absent() { + let p = vec![parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:PrincipalTag/team":"${dynamodb:LeadingKeys, 'x'}"}} + }]}"#, + )]; + for values in [vec!["x", "y"], vec!["x"]] { + let ctx = leading_keys(values).with("aws:PrincipalTag/team", vec!["x"]); + assert_eq!(get(&p, &format!("{P}:table/x"), &ctx), AuthzDecision::Deny); + } + let ctx = Ctx::empty().with("aws:PrincipalTag/team", vec!["x"]); + assert_eq!(get(&p, &format!("{P}:table/x"), &ctx), AuthzDecision::Allow); +} + // --- Variables before the fifth colon expand (`aws:RequestedRegion` in the // region field, `aws:PrincipalAccount` in the account field, PR #535 body // table) --- diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index c8dc297bc..2d255069a 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -8,10 +8,12 @@ //! before matching. The forms are: //! //! - `${key}`: the value of `key` in the request context. A multivalued key -//! is never a variable: it resolves as if the request did not carry it, -//! with one value or several (measured with `dynamodb:LeadingKeys` holding -//! one value and two, in `Resource` and in a condition value; PR #535 body -//! table). +//! is never a variable: when the request carries it, with one value or +//! several, the pattern matches nothing, default or not; a written default +//! is used only when the request does not carry the key (measured with +//! `dynamodb:LeadingKeys` holding one value and two, in `Resource` and in a +//! condition value, PR #535 body table; and with `aws:TagKeys` present and +//! absent against `${aws:TagKeys, 'x'}` on 2026-10-28). //! - `${key, 'default'}`: the value of `key`, or `default` when the request //! does not carry `key`. //! - `${*}`, `${?}`, `${$}`: a literal `*`, `?` or `$`. @@ -173,10 +175,14 @@ enum Missing { /// The request's value for `key`: the value of a present single-valued key, /// or the default when the key is absent and a default was written. A present -/// key with no values expands to the empty string. A multivalued key is -/// treated as absent. A key the request does not carry and the context -/// reports unresolvable is [`Missing::Unresolvable`] before either rule, -/// multivalued or not, so its default is not used. +/// key with no values expands to the empty string. A multivalued key is never +/// a value: when the request carries it the pattern matches nothing, default +/// or not, and when it is absent a written default is used (IAM policy +/// simulator, 2026-10-28, `aws:TagKeys` as `[x, y]`, `[x]` and absent against +/// `table/${aws:TagKeys, 'x'}`: implicitDeny, implicitDeny, allowed). A key +/// the request does not carry and the context reports unresolvable is +/// [`Missing::Unresolvable`] before either rule, multivalued or not, so its +/// default is not used. fn resolve<'a>( name: &str, default: Option<&'a str>, @@ -187,7 +193,10 @@ fn resolve<'a>( return Err(Missing::Unresolvable); } if context.is_multivalued_key(name) { - return default.ok_or(Missing::Absent); + return match values { + Some(_) => Err(Missing::Absent), + None => default.ok_or(Missing::Absent), + }; } match values { Some(values) => Ok(values.first().copied().unwrap_or("")), diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index a24448627..c1c7c52cb 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -141,7 +141,7 @@ Resources are identified by ARN: `arn:aws:dynamodb:::table/< - `Effect`: Allow, Deny - `Action` / `NotAction`: DynamoDB actions (e.g., `dynamodb:PutItem`, `dynamodb:*`) -- `Resource` / `NotResource`: ARN patterns with wildcards. In a `2012-10-17` document, `${key}` policy variables in the pattern are expanded from the request before matching (for example `arn:aws:dynamodb:*:*:table/${aws:username}`); a wildcard written after a variable still matches; `${key, 'default'}` falls back to `default`; `${*}`, `${?}` and `${$}` stand for literal characters; a variable whose key the request does not carry, or that names a multivalued key such as `dynamodb:LeadingKeys`, makes the pattern match nothing; a `:` inside a substituted value does not separate ARN fields. A `2008-10-17` document, or one with no `Version`, matches the pattern as written, as IAM does. +- `Resource` / `NotResource`: ARN patterns with wildcards. In a `2012-10-17` document, `${key}` policy variables in the pattern are expanded from the request before matching (for example `arn:aws:dynamodb:*:*:table/${aws:username}`); a wildcard written after a variable still matches; `${key, 'default'}` falls back to `default`; `${*}`, `${?}` and `${$}` stand for literal characters; a variable whose key the request does not carry, or that names a multivalued key such as `dynamodb:LeadingKeys` that the request carries, makes the pattern match nothing (a written default is used only when the key is absent); a `:` inside a substituted value does not separate ARN fields. A `2008-10-17` document, or one with no `Version`, matches the pattern as written, as IAM does. - `Condition`: Full condition block support (see operators and keys above) ## Transport Security From 3afcf10656fe7b49ed51c4b25df0a62a3a9d8294 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:05:46 +0000 Subject: [PATCH 20/32] fix(auth): fail closed on a defaulted variable naming an unresolvable key A condition fails closed when one of its values reads, through a ${key} variable, a key the context reports unresolvable. In this build that key is aws:RequestTag/ when the request names the tag under two spellings with different values. The check took the whole text between ${ and } as the key name, so ${aws:RequestTag/env, 'x'} named `aws:RequestTag/env, 'x'`, which the context does not report. The value was then compared as written: a StringEquals Deny did not apply and a StringNotEquals Allow granted, where the plain form fails closed. reads_unresolvable_variable now takes its names from variables::variable_key_names, which parses each reference the way expand_condition_value does and leaves the default out of the name. Both forms fail closed: a Deny applies and an Allow does not grant, whatever the operator. The default is not used for such a key, because the service has a value for it. A key the request does not carry still takes the default, and a present key still uses its own value. The expand_policy_variables comment drops "(IAM behavior)": a variable left as written gives IAM's answer except when the compared value holds the variable's text. The case_three test that pinned the old reading is renamed. It now expects the Deny to apply under StringEquals and StringNotEquals, and an Allow gated the same way not to grant under either operator. New tests cover variable_key_names, the default form under three operators in three_case, including as the second of two values, and an ambiguous request tag with a default in request_tag_tests. --- crates/auth/src/policy/condition.rs | 51 +++++++++--------- .../src/policy/condition/tests/three_case.rs | 54 +++++++++++++++++++ .../src/policy/context/request_tag_tests.rs | 45 ++++++++++++++++ .../src/policy/evaluator/tests/case_three.rs | 41 +++++++++++--- crates/auth/src/policy/variables.rs | 22 +++++++- crates/auth/src/policy/variables/tests.rs | 21 ++++++++ 6 files changed, 200 insertions(+), 34 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index e11418299..c69e1a124 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -179,9 +179,10 @@ pub enum ConditionMatch { /// /// 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, since the variable has -/// no value to expand to. Otherwise returns `Match` or `NoMatch` from the -/// ordinary evaluation, so cases 1 and 2 are unchanged. +/// 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. Otherwise returns `Match` or +/// `NoMatch` from the ordinary evaluation, so cases 1 and 2 are unchanged. pub fn evaluate_condition_match( condition: &Condition, context: &impl ConditionContext, @@ -202,26 +203,15 @@ fn is_unresolvable(key: &str, context: &impl ConditionContext) -> bool { context.resolve_key(key).is_none() && context.is_unresolvable_key(key) } -/// Whether a value of `condition` names, as a `${name}` policy variable, a key -/// [`is_unresolvable`] reports. +/// Whether a value of `condition` names, as a `${name}` or `${name, 'default'}` +/// policy variable, a key [`is_unresolvable`] reports. +/// +/// The names come from [`super::variables::variable_key_names`], which reads +/// the default form the way [`expand_policy_variables`] expands it, so a +/// default written beside such a key does not hide the key from this check. fn reads_unresolvable_variable(condition: &Condition, context: &impl ConditionContext) -> bool { - condition - .values - .iter() - .any(|value| policy_variable_names(value).any(|name| is_unresolvable(name, context))) -} - -/// The names of the `${name}` policy variables in `value`, in the order -/// [`expand_policy_variables`] reads them. A `${` with no closing brace names -/// nothing, as it expands to itself. -fn policy_variable_names(value: &str) -> impl Iterator { - let mut rest = value; - std::iter::from_fn(move || { - let start = rest.find("${")?; - let after_start = &rest[start + 2..]; - let end = after_start.find('}')?; - rest = &after_start[end + 1..]; - Some(&after_start[..end]) + condition.values.iter().any(|value| { + super::variables::variable_key_names(value).any(|name| is_unresolvable(name, context)) }) } @@ -254,10 +244,19 @@ pub fn evaluate_statement_conditions( /// /// Replaces `${variable}` and `${variable, 'default'}` patterns with their /// resolved values from the context (see [`super::variables`]). Supported -/// variables: `aws:PrincipalTag/*` and any key the context can resolve. -/// Unresolved variables with no default are left as-is (IAM behavior). A -/// variable naming a key the context cannot resolve is also left as-is, with -/// or without a default; see [`super::variables::expand_condition_value`]. +/// 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`]). fn expand_policy_variables(value: &str, context: &impl ConditionContext) -> String { super::variables::expand_condition_value(value, context) } diff --git a/crates/auth/src/policy/condition/tests/three_case.rs b/crates/auth/src/policy/condition/tests/three_case.rs index f0aabd82f..74d936d8d 100644 --- a/crates/auth/src/policy/condition/tests/three_case.rs +++ b/crates/auth/src/policy/condition/tests/three_case.rs @@ -68,6 +68,60 @@ fn unresolvable_key_is_unresolvable_for_every_operator() { } } +#[test] +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. + let ctx = TestContext::new() + .with("aws:PrincipalTag/team", vec!["blue"]) + .unresolvable("aws:PrincipalOrgID"); + for values in [ + vec!["${aws:PrincipalOrgID}"], + vec!["${aws:PrincipalOrgID, 'blue'}"], + vec!["${aws:PrincipalOrgID,'blue'}"], + vec!["x-${aws:PrincipalOrgID, 'blue'}-y"], + vec!["red", "${aws:PrincipalOrgID, 'blue'}"], + ] { + for op in [ + ConditionOperator::StringEquals, + ConditionOperator::StringNotEquals, + ConditionOperator::StringLike, + ] { + let c = cond(op, "aws:PrincipalTag/team", values.clone()); + assert_eq!( + evaluate_condition_match(&c, &ctx), + ConditionMatch::Unresolvable, + "{values:?}" + ); + } + } + // The key present: its value is used, not the default. + let present = TestContext::new() + .with("aws:PrincipalTag/team", vec!["blue"]) + .with("aws:PrincipalOrgID", vec!["blue"]) + .unresolvable("aws:PrincipalOrgID"); + let c = cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["${aws:PrincipalOrgID, 'red'}"], + ); + assert_eq!( + evaluate_condition_match(&c, &present), + ConditionMatch::Match + ); + // The key absent, not unresolvable: the default is used. + let absent = TestContext::new().with("aws:PrincipalTag/team", vec!["blue"]); + let c = cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["${aws:PrincipalOrgID, 'blue'}"], + ); + assert_eq!(evaluate_condition_match(&c, &absent), ConditionMatch::Match); +} + #[test] fn resolved_unresolvable_key_uses_its_value() { // If the context both lists a key as unresolvable and resolves it, the diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index 38b91e40d..e44e87968 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -205,6 +205,51 @@ fn ambiguous_request_tag_as_a_policy_variable_fails_the_statement_closed() { ); } +#[test] +fn ambiguous_request_tag_as_a_defaulted_policy_variable_fails_the_statement_closed() { + use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions}; + use crate::policy::document::{Condition, ConditionOperator}; + + let mut resource = HashMap::new(); + resource.insert("env".to_owned(), "prod".to_owned()); + let with_resource_tag = |tags: &[(&str, &str)]| { + let mut ctx = ctx_with(tags, &[]); + ctx.resource_tags = resource.clone(); + ctx + }; + let deny = [Condition { + operator: ConditionOperator::StringEquals, + key: "aws:ResourceTag/env".to_owned(), + values: vec!["${aws:RequestTag/env, 'prod'}".to_owned()], + }]; + // Two spellings with differing values have no value to expand to, and the + // default is not used: the statement fails closed as it does for the plain + // form, so the default cannot route around the rule. + for tags in [ + [("Env", "prod"), ("env", "dev")], + [("env", "dev"), ("Env", "prod")], + ] { + assert_eq!( + evaluate_statement_conditions(&deny, &with_resource_tag(&tags)), + ConditionMatch::Unresolvable + ); + } + // One spelling expands to its value, and the default is not used. + assert_eq!( + evaluate_statement_conditions(&deny, &with_resource_tag(&[("Env", "prod")])), + ConditionMatch::Match + ); + assert_eq!( + evaluate_statement_conditions(&deny, &with_resource_tag(&[("env", "dev")])), + ConditionMatch::NoMatch + ); + // An absent request tag is not ambiguous: the default is used. + assert_eq!( + evaluate_statement_conditions(&deny, &with_resource_tag(&[("team", "x")])), + ConditionMatch::Match + ); +} + #[test] fn policy_variable_naming_aws_tagkeys_is_left_literal_for_every_tag_order() { use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions}; diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs index caea88b0c..55314cb85 100644 --- a/crates/auth/src/policy/evaluator/tests/case_three.rs +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -7,7 +7,8 @@ //! 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 is compared as written, with or without a default. +//! condition value fails the condition closed in the same way, with or without +//! a default. //! //! 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 @@ -281,15 +282,43 @@ fn get_t_as_blue(policies: &[PolicyDocument], ctx: Ctx) -> AuthzDecision { } #[test] -fn a_condition_value_naming_an_unresolvable_key_is_compared_as_written() { +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. + for (operator, value) in [ + ("StringEquals", "${aws:PrincipalOrgID}"), + ("StringEquals", "${aws:PrincipalOrgID, 'blue'}"), + ("StringNotEquals", "${aws:PrincipalOrgID, 'blue'}"), + ] { + let p = allow_all_and(&format!( + r#"{{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"{operator}":{{"aws:PrincipalTag/team":"{value}"}}}}}}"# + )); + let unresolvable = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + get_t_as_blue(&p, unresolvable), + AuthzDecision::Deny, + "{operator} {value}" + ); + } + // An Allow gated the same way does not grant, under either operator. + for operator in ["StringEquals", "StringNotEquals"] { + let allow = [only(&format!( + r#"{{"Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"{operator}":{{"aws:PrincipalTag/team":"${{aws:PrincipalOrgID, 'blue'}}"}}}}}}"# + ))]; + let unresolvable = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + get_t_as_blue(&allow, unresolvable), + AuthzDecision::Deny, + "{operator}" + ); + } let p = allow_all_and( r#"{"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", "Condition":{"StringEquals":{"aws:PrincipalTag/team":"${aws:PrincipalOrgID, 'blue'}"}}}"#, ); - // Unresolvable: the value is compared as written, which is not `blue`, so - // the Deny does not apply. The default is not used. - let unresolvable = Ctx::empty().unresolvable("aws:PrincipalOrgID"); - assert_eq!(get_t_as_blue(&p, unresolvable), AuthzDecision::Allow); // Absent: the default `blue` is used and the Deny applies. assert_eq!(get_t_as_blue(&p, Ctx::empty()), AuthzDecision::Deny); // Present: the key's value is used, not the default. diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index 2d255069a..f6e248d96 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -275,13 +275,31 @@ pub fn resource_pattern_is_unresolvable( && expand_tokens(pattern, context) == Err(Missing::Unresolvable) } +/// The key names the `${key}` and `${key, 'default'}` variables in `value` +/// read, in the order [`expand_condition_value`] reads them. +/// +/// 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}`. 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) -> 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. /// /// `${key}` and `${key, 'default'}` are replaced by their value; 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 cannot resolve is also left as written, with or -/// without a default; a condition value does not fail closed on it. +/// 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`]). #[must_use] pub fn expand_condition_value(value: &str, context: &impl ConditionContext) -> String { if !value.contains("${") { diff --git a/crates/auth/src/policy/variables/tests.rs b/crates/auth/src/policy/variables/tests.rs index 9f990ca18..909a62233 100644 --- a/crates/auth/src/policy/variables/tests.rs +++ b/crates/auth/src/policy/variables/tests.rs @@ -349,6 +349,27 @@ fn condition_value_leaves_unresolved_and_escapes_literal() { assert_eq!(expand_condition_value("${open", &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", + ) + .collect(); + assert_eq!( + names, + [ + "aws:PrincipalTag/team", + "aws:RequestTag/env", + "aws:username" + ] + ); + // 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}").collect(); + assert_eq!(names, ["a, b"]); + assert_eq!(variable_key_names("plain").count(), 0); +} + #[test] fn condition_value_present_key_without_values_is_empty() { let ctx = Ctx::empty().with(TEAM, &[]); From 57861f59280efde32e0557ec746cff34d0311ddf Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:09:19 +0000 Subject: [PATCH 21/32] docs(auth): say a condition value naming an unresolvable key fails closed The FAIL_CLOSED_GLOBAL_KEYS comment said that a variable naming a listed key inside a condition value is left as written, so listing a key does not make such a condition fail closed. Main fails a condition closed when a ${key} variable in one of its values names such a key, and the previous commit does the same for ${key, 'default'}. The comment now says both forms fail closed, as a Resource or NotResource pattern does, and that a default written beside a listed key is not used. Three comments said an unresolved global key such as aws:PrincipalOrgID or aws:SourceIp fails closed: in RequestContext::is_unresolvable_key, on AssumeRoleContext and in its is_unresolvable_key. The list is empty, so such a key keeps absent-key semantics. Each comment now says which keys the context reports unresolvable: in RequestContext an ambiguous aws:RequestTag/, and in both contexts a key listed in FAIL_CLOSED_GLOBAL_KEYS. --- crates/auth/src/policy/context.rs | 41 ++++++++++++++++--------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 855f9c593..85ca0288f 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -129,13 +129,14 @@ fn strip_prefix_ci<'a>(key: &'a str, prefix: &str) -> Option<&'a str> { /// begins populating before ExtendDB resolves it can be made to fail closed by /// listing it. /// -/// Two places read the set, through `is_unresolvable_key`: a condition whose -/// key is listed fails closed (`condition::evaluate_condition_match`), and so -/// does a `Resource` or `NotResource` pattern whose `${key}` variable names a -/// listed key (`variables::resource_pattern_is_unresolvable`). A variable that -/// names a listed key inside a condition value is left as written, as an -/// unresolved variable is there, so listing a key does not make such a -/// condition fail closed. +/// 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 +/// (`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. const FAIL_CLOSED_GLOBAL_KEYS: &[&str] = &[]; /// Whether an unresolved condition key must fail closed for the context that @@ -450,10 +451,10 @@ impl ConditionContext for RequestContext { // A request tag key the request names under two or more spellings that // fold to one name and do not all carry one value cannot be read as one // value: the statement fails closed (a Deny blocks, an Allow does not - // grant). Every other unresolved aws: global key that is neither a tag - // sub-key nor on the absent-ok allowlist is one the service populates - // but this context has no value for (for example aws:PrincipalOrgID, - // aws:SourceIp): it fails closed. Keys resolved above never reach here. + // grant). Any other key is unresolvable only when it is listed in + // FAIL_CLOSED_GLOBAL_KEYS, which is empty in this build, so every other + // unresolved key, aws:PrincipalOrgID and aws:SourceIp among them, keeps + // absent-key semantics. Keys resolved above never reach here. if let Some(sub) = strip_prefix_ci(key, "aws:RequestTag/") { return self.request_tag_values(sub) == RequestTagLookup::Ambiguous; } @@ -484,11 +485,11 @@ impl ConditionContext for RequestContext { /// `aws:RequestTag/*` / `aws:TagKeys`, and the request-wide keys /// `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, /// `aws:EpochTime`, `aws:ViaAWSService` and `aws:PrincipalIsAWSService`, all of -/// which this context resolves. A global key STS populates that this context -/// cannot resolve (for example `aws:PrincipalOrgID` or `aws:SourceIp`, neither -/// on the absent-ok allowlist) is reported as unresolvable (fail closed). Keys -/// that are legitimately absent for this caller model keep absent-key -/// semantics. `DynamoDB`-specific keys are not applicable. +/// which this context resolves. Another key is reported as unresolvable (fail +/// closed) only when it is listed in `FAIL_CLOSED_GLOBAL_KEYS`, which is +/// empty in this build; every other key this context does not resolve, such +/// as `aws:PrincipalOrgID` or `aws:SourceIp`, keeps absent-key semantics. +/// `DynamoDB`-specific keys are not applicable. #[derive(Debug)] pub struct AssumeRoleContext { /// Tags on the calling principal. @@ -592,10 +593,10 @@ impl ConditionContext for AssumeRoleContext { fn is_unresolvable_key(&self, key: &str) -> bool { // Keys this context resolves (tags, external id, principal ARN and // account, region, secure transport, time, principal type, user name) - // never reach here. Any other unresolved aws: global key that is not on - // the absent-ok allowlist is one STS populates but this context has no - // value for, so a Deny gated on it fails closed and an Allow does not - // grant. + // never reach here. Another key is unresolvable only when it is listed + // in FAIL_CLOSED_GLOBAL_KEYS, which is empty in this build: a Deny gated + // on a listed key fails closed and an Allow does not grant, and every + // other unresolved key keeps absent-key semantics. aws_key_is_unresolvable(key) } } From 68ea9bb1762631aae9ec3391dd30a375e6f54e7f Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:21:59 +0000 Subject: [PATCH 22/32] fix(auth): expand policy variables in condition values only for 2012-10-17 documents A condition value in a 2008-10-17 document, or in one with no Version, is compared as written: ${aws:username} is the literal text. Only a 2012-10-17 document expands variables, the same rule as Resource and NotResource. In a 2012-10-17 document the escapes ${*}, ${?} and ${$} stand for the literal characters. StringLike and ArnLike match the escaped character literally while a * or ? written in the policy stays a wildcard, so a${*} matches a* and not abc. The result of an escape is not expanded again. A variable with no default whose key the request does not carry makes the value match nothing: a positive operator is false against it and a negated operator holds, except that every Arn operator is false against a context value that is not an ARN, whatever the value (measured on 2026-10-31, sixteen cells, one evaluator test each). A variable naming a multivalued key the request carries, such as ${aws:TagKeys}, has no value either, as IAM evaluates it (measured on 2026-10-31). #522 treated it as literal text; its tests, wire test docstrings and differences-from-dynamodb.md row now say it has no value. The document's choice reaches condition evaluation through a flag on evaluate_statement_conditions and evaluate_condition_match; the identity, boundary and session policy paths pass their own document's Version. The trust policy path keeps expanding whatever the Version. Measured with the IAM policy simulator on 2026-10-24, nineteen cells, one evaluator test each. Closes #534. --- crates/auth/src/policy/condition.rs | 141 ++++--- .../src/policy/condition/tests/multivalued.rs | 63 ++- .../src/policy/condition/tests/three_case.rs | 41 +- .../src/policy/context/request_tag_tests.rs | 69 ++-- crates/auth/src/policy/evaluator.rs | 42 +- crates/auth/src/policy/evaluator/tests.rs | 2 + .../src/policy/evaluator/tests/case_three.rs | 42 ++ .../evaluator/tests/condition_variables.rs | 358 ++++++++++++++++++ .../tests/condition_variables_arn_context.rs | 136 +++++++ crates/auth/src/policy/variables.rs | 176 +++++++-- crates/auth/src/policy/variables/tests.rs | 115 +++++- crates/server/src/management/assume_role.rs | 6 +- docs/differences-from-dynamodb.md | 2 +- docs/manuals/10-security-model.md | 2 +- tests/test_tag_request_variable_conditions.py | 28 +- 15 files changed, 1046 insertions(+), 177 deletions(-) create mode 100644 crates/auth/src/policy/evaluator/tests/condition_variables.rs create mode 100644 crates/auth/src/policy/evaluator/tests/condition_variables_arn_context.rs diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index c69e1a124..a1c3ad82e 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -9,7 +9,7 @@ use super::context::ConditionContext; use super::document::{Condition, ConditionOperator}; -use super::matcher::wildcard_match; +use super::variables::ConditionValue; /// Evaluate a single condition against a context. /// @@ -32,15 +32,31 @@ use super::matcher::wildcard_match; /// Absent key is vacuously true. /// - `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 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) +} + +/// [`evaluate_condition`] with `expand_variables` saying whether the document +/// expands policy variables in condition values +/// ([`super::document::PolicyDocument::expands_policy_variables`]). A +/// `2008-10-17` document, or one with no `Version`, compares each value as +/// written. +pub fn evaluate_condition_with( + condition: &Condition, + expand_variables: bool, + context: &impl ConditionContext, +) -> bool { let context_values = context.resolve_key(&condition.key); let is_multivalued = context.is_multivalued_key(&condition.key); // 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, context)) + .map(|v| expand_policy_variables(v, expand_variables, context)) .collect(); match &condition.operator { @@ -48,6 +64,7 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext let key_absent = context_values.is_none(); expanded_values .first() + .and_then(ConditionValue::text) .is_some_and(|v| (v == "true" && key_absent) || (v == "false" && !key_absent)) } ConditionOperator::ForAllValues(inner) => { @@ -178,19 +195,24 @@ pub enum ConditionMatch { /// 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}` 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. Otherwise returns `Match` or -/// `NoMatch` from the ordinary evaluation, so cases 1 and 2 are unchanged. +/// context reports it as service-populated (case 3), and, with +/// `expand_variables`, 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. Without `expand_variables` the value is compared as written and +/// reads no key. Otherwise returns `Match` or `NoMatch` from the ordinary +/// evaluation, so cases 1 and 2 are unchanged. pub fn evaluate_condition_match( condition: &Condition, + expand_variables: bool, context: &impl ConditionContext, ) -> ConditionMatch { - if is_unresolvable(&condition.key, context) || reads_unresolvable_variable(condition, context) { + if is_unresolvable(&condition.key, context) + || (expand_variables && reads_unresolvable_variable(condition, context)) + { return ConditionMatch::Unresolvable; } - if evaluate_condition(condition, context) { + if evaluate_condition_with(condition, expand_variables, context) { ConditionMatch::Match } else { ConditionMatch::NoMatch @@ -221,13 +243,17 @@ fn reads_unresolvable_variable(condition: &Condition, context: &impl ConditionCo /// statement does not apply, even if another condition is unresolvable. Failing /// that, any unresolvable condition makes the block `Unresolvable` (fail /// closed). Otherwise every condition matched. +/// +/// `expand_variables` is the document's +/// [`super::document::PolicyDocument::expands_policy_variables`]. pub fn evaluate_statement_conditions( conditions: &[Condition], + expand_variables: bool, context: &impl ConditionContext, ) -> ConditionMatch { let mut any_unresolvable = false; for condition in conditions { - match evaluate_condition_match(condition, context) { + match evaluate_condition_match(condition, expand_variables, context) { ConditionMatch::Match => {} ConditionMatch::NoMatch => return ConditionMatch::NoMatch, ConditionMatch::Unresolvable => any_unresolvable = true, @@ -242,23 +268,31 @@ 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`]). -fn expand_policy_variables(value: &str, context: &impl ConditionContext) -> String { - super::variables::expand_condition_value(value, context) +/// 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, + expand_variables: bool, + context: &impl ConditionContext, +) -> ConditionValue { + super::variables::expand_condition_value(value, expand_variables, context) } /// Unwrap an `IfExists` wrapper if present. @@ -290,7 +324,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. @@ -363,13 +397,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, @@ -379,8 +435,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) @@ -417,22 +473,17 @@ fn compare_single(op: &ConditionOperator, context_value: &str, policy_value: &st // 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 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 74d936d8d..896a5086e 100644 --- a/crates/auth/src/policy/condition/tests/three_case.rs +++ b/crates/auth/src/policy/condition/tests/three_case.rs @@ -18,13 +18,19 @@ fn present_key_matches_or_not() { "aws:PrincipalArn", vec!["arn:a"], ); - assert_eq!(evaluate_condition_match(&eq, &ctx), ConditionMatch::Match); + assert_eq!( + evaluate_condition_match(&eq, true, &ctx), + ConditionMatch::Match + ); let ne = cond( ConditionOperator::StringEquals, "aws:PrincipalArn", vec!["arn:b"], ); - assert_eq!(evaluate_condition_match(&ne, &ctx), ConditionMatch::NoMatch); + assert_eq!( + evaluate_condition_match(&ne, true, &ctx), + ConditionMatch::NoMatch + ); } #[test] @@ -38,7 +44,7 @@ fn absent_key_keeps_iam_semantics() { vec!["x"], ); assert_eq!( - evaluate_condition_match(&pos, &ctx), + evaluate_condition_match(&pos, true, &ctx), ConditionMatch::NoMatch ); let neg = cond( @@ -46,7 +52,10 @@ fn absent_key_keeps_iam_semantics() { "aws:PrincipalTag/team", vec!["x"], ); - assert_eq!(evaluate_condition_match(&neg, &ctx), ConditionMatch::Match); + assert_eq!( + evaluate_condition_match(&neg, true, &ctx), + ConditionMatch::Match + ); } #[test] @@ -62,7 +71,7 @@ fn unresolvable_key_is_unresolvable_for_every_operator() { ] { let c = cond(op, "aws:PrincipalOrgID", vec!["o-1"]); assert_eq!( - evaluate_condition_match(&c, &ctx), + evaluate_condition_match(&c, true, &ctx), ConditionMatch::Unresolvable ); } @@ -92,7 +101,7 @@ fn variable_naming_unresolvable_key_is_unresolvable_with_or_without_default() { ] { let c = cond(op, "aws:PrincipalTag/team", values.clone()); assert_eq!( - evaluate_condition_match(&c, &ctx), + evaluate_condition_match(&c, true, &ctx), ConditionMatch::Unresolvable, "{values:?}" ); @@ -109,7 +118,7 @@ fn variable_naming_unresolvable_key_is_unresolvable_with_or_without_default() { vec!["${aws:PrincipalOrgID, 'red'}"], ); assert_eq!( - evaluate_condition_match(&c, &present), + evaluate_condition_match(&c, true, &present), ConditionMatch::Match ); // The key absent, not unresolvable: the default is used. @@ -119,7 +128,10 @@ fn variable_naming_unresolvable_key_is_unresolvable_with_or_without_default() { "aws:PrincipalTag/team", vec!["${aws:PrincipalOrgID, 'blue'}"], ); - assert_eq!(evaluate_condition_match(&c, &absent), ConditionMatch::Match); + assert_eq!( + evaluate_condition_match(&c, true, &absent), + ConditionMatch::Match + ); } #[test] @@ -134,7 +146,10 @@ fn resolved_unresolvable_key_uses_its_value() { "aws:PrincipalArn", vec!["arn:a"], ); - assert_eq!(evaluate_condition_match(&c, &ctx), ConditionMatch::Match); + assert_eq!( + evaluate_condition_match(&c, true, &ctx), + ConditionMatch::Match + ); } #[test] @@ -157,7 +172,7 @@ fn statement_nomatch_dominates_unresolvable() { ), ]; assert_eq!( - evaluate_statement_conditions(&conds, &ctx), + evaluate_statement_conditions(&conds, true, &ctx), ConditionMatch::NoMatch ); } @@ -180,7 +195,7 @@ fn statement_unresolvable_when_only_unresolvable_present() { ), ]; assert_eq!( - evaluate_statement_conditions(&conds, &ctx), + evaluate_statement_conditions(&conds, true, &ctx), ConditionMatch::Unresolvable ); } @@ -194,12 +209,12 @@ fn statement_all_match() { vec!["contractors"], )]; assert_eq!( - evaluate_statement_conditions(&conds, &ctx), + evaluate_statement_conditions(&conds, true, &ctx), ConditionMatch::Match ); // An empty condition block always matches. assert_eq!( - evaluate_statement_conditions(&[], &ctx), + evaluate_statement_conditions(&[], true, &ctx), ConditionMatch::Match ); } diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index e44e87968..b4b9c4aa9 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -127,13 +127,13 @@ fn ambiguous_request_tag_fails_the_statement_closed() { values: vec!["prod".to_owned()], }]; assert_eq!( - evaluate_statement_conditions(&deny_on_prod, &ctx), + evaluate_statement_conditions(&deny_on_prod, true, &ctx), ConditionMatch::Unresolvable ); // The same statement against a request naming the tag once reads its value. let one = ctx_with(&[("env", "prod")], &["env"]); assert_eq!( - evaluate_statement_conditions(&deny_on_prod, &one), + evaluate_statement_conditions(&deny_on_prod, true, &one), ConditionMatch::Match ); } @@ -158,7 +158,7 @@ fn ambiguous_request_tag_as_a_policy_variable_fails_the_statement_closed() { // One spelling expands to its value. for tags in [[("env", "prod")], [("Env", "prod")]] { assert_eq!( - evaluate_statement_conditions(&deny, &with_resource_tag(&tags)), + evaluate_statement_conditions(&deny, true, &with_resource_tag(&tags)), ConditionMatch::Match ); } @@ -168,7 +168,7 @@ fn ambiguous_request_tag_as_a_policy_variable_fails_the_statement_closed() { [("env", "dev"), ("Env", "prod")], ] { assert_eq!( - evaluate_statement_conditions(&deny, &with_resource_tag(&tags)), + evaluate_statement_conditions(&deny, true, &with_resource_tag(&tags)), ConditionMatch::Unresolvable ); } @@ -181,13 +181,15 @@ fn ambiguous_request_tag_as_a_policy_variable_fails_the_statement_closed() { assert_eq!( evaluate_statement_conditions( &embedded, + true, &with_resource_tag(&[("Env", "prod"), ("env", "dev")]) ), 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, &with_resource_tag(&[("team", "x")])), + evaluate_statement_conditions(&deny, true, &with_resource_tag(&[("team", "x")])), ConditionMatch::NoMatch ); // A brace that never closes names no variable. @@ -199,6 +201,7 @@ fn ambiguous_request_tag_as_a_policy_variable_fails_the_statement_closed() { assert_eq!( evaluate_statement_conditions( &unclosed, + true, &with_resource_tag(&[("Env", "prod"), ("env", "dev")]) ), ConditionMatch::NoMatch @@ -230,34 +233,35 @@ fn ambiguous_request_tag_as_a_defaulted_policy_variable_fails_the_statement_clos [("env", "dev"), ("Env", "prod")], ] { assert_eq!( - evaluate_statement_conditions(&deny, &with_resource_tag(&tags)), + evaluate_statement_conditions(&deny, true, &with_resource_tag(&tags)), ConditionMatch::Unresolvable ); } // One spelling expands to its value, and the default is not used. assert_eq!( - evaluate_statement_conditions(&deny, &with_resource_tag(&[("Env", "prod")])), + evaluate_statement_conditions(&deny, true, &with_resource_tag(&[("Env", "prod")])), ConditionMatch::Match ); assert_eq!( - evaluate_statement_conditions(&deny, &with_resource_tag(&[("env", "dev")])), + evaluate_statement_conditions(&deny, true, &with_resource_tag(&[("env", "dev")])), ConditionMatch::NoMatch ); // An absent request tag is not ambiguous: the default is used. assert_eq!( - evaluate_statement_conditions(&deny, &with_resource_tag(&[("team", "x")])), + evaluate_statement_conditions(&deny, true, &with_resource_tag(&[("team", "x")])), ConditionMatch::Match ); } #[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(), @@ -268,7 +272,7 @@ fn policy_variable_naming_aws_tagkeys_is_left_literal_for_every_tag_order() { ctx.resource_tags .insert("team".to_owned(), "env".to_owned()); assert_eq!( - evaluate_statement_conditions(&deny, &ctx), + evaluate_statement_conditions(&deny, true, &ctx), ConditionMatch::NoMatch, "tag keys {keys:?}" ); @@ -304,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), &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"), &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/evaluator.rs b/crates/auth/src/policy/evaluator.rs index f787fffc2..030f30b41 100644 --- a/crates/auth/src/policy/evaluator.rs +++ b/crates/auth/src/policy/evaluator.rs @@ -102,7 +102,7 @@ pub fn explicit_deny_matches_arc( stmt.effect == Effect::Deny && action_matches(stmt, action) && resource_matches(policy, stmt, resource_arn, context) - && deny_conditions_apply(stmt, context) + && deny_conditions_apply(policy, stmt, context) }) }) } @@ -131,7 +131,7 @@ where if stmt.effect == Effect::Deny && action_matches(stmt, action) && resource_matches(policy, stmt, resource_arn, context) - && deny_conditions_apply(stmt, context) + && deny_conditions_apply(policy, stmt, context) { return AuthzDecision::Deny; } @@ -144,7 +144,7 @@ where stmt.effect == Effect::Allow && action_matches(stmt, action) && resource_matches(boundary, stmt, resource_arn, context) - && allow_conditions_grant(stmt, context) + && allow_conditions_grant(boundary, stmt, context) }); if !boundary_allows { return AuthzDecision::Deny; @@ -157,7 +157,7 @@ where stmt.effect == Effect::Allow && action_matches(stmt, action) && resource_matches(session, stmt, resource_arn, context) - && allow_conditions_grant(stmt, context) + && allow_conditions_grant(session, stmt, context) }); if !session_allows { return AuthzDecision::Deny; @@ -170,7 +170,7 @@ where if stmt.effect == Effect::Allow && action_matches(stmt, action) && resource_matches(policy, stmt, resource_arn, context) - && allow_conditions_grant(stmt, context) + && allow_conditions_grant(policy, stmt, context) { return AuthzDecision::Allow; } @@ -234,26 +234,44 @@ fn resource_matches( } } -/// Outcome of a statement's `Condition` block, three-valued. -fn statement_conditions(statement: &Statement, context: &impl ConditionContext) -> ConditionMatch { - evaluate_statement_conditions(&statement.conditions, context) +/// Outcome of a statement's `Condition` block, three-valued. Policy variables +/// in condition values are expanded only when `policy` is a `2012-10-17` +/// document. +fn statement_conditions( + policy: &PolicyDocument, + statement: &Statement, + context: &impl ConditionContext, +) -> ConditionMatch { + evaluate_statement_conditions( + &statement.conditions, + policy.expands_policy_variables(), + context, + ) } /// Whether a `Deny` applies: its conditions match, or depend on a key the /// service populates but the context cannot resolve (fail closed, so the Deny /// blocks rather than being silently dropped). -fn deny_conditions_apply(statement: &Statement, context: &impl ConditionContext) -> bool { +fn deny_conditions_apply( + policy: &PolicyDocument, + statement: &Statement, + context: &impl ConditionContext, +) -> bool { matches!( - statement_conditions(statement, context), + statement_conditions(policy, statement, context), ConditionMatch::Match | ConditionMatch::Unresolvable ) } /// Whether an `Allow` grants: its conditions definitely match. An unresolvable /// key does not grant (fail closed). -fn allow_conditions_grant(statement: &Statement, context: &impl ConditionContext) -> bool { +fn allow_conditions_grant( + policy: &PolicyDocument, + statement: &Statement, + context: &impl ConditionContext, +) -> bool { matches!( - statement_conditions(statement, context), + statement_conditions(policy, statement, context), ConditionMatch::Match ) } diff --git a/crates/auth/src/policy/evaluator/tests.rs b/crates/auth/src/policy/evaluator/tests.rs index 0dbd08a9f..e1e02f729 100644 --- a/crates/auth/src/policy/evaluator/tests.rs +++ b/crates/auth/src/policy/evaluator/tests.rs @@ -59,6 +59,8 @@ fn parse(json: &str) -> PolicyDocument { mod case_three; mod combined; +mod condition_variables; +mod condition_variables_arn_context; mod core; mod resource_variables; mod resource_variables_fields; diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs index 55314cb85..71a5d6d26 100644 --- a/crates/auth/src/policy/evaluator/tests/case_three.rs +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -326,3 +326,45 @@ 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}" + ); + } + } +} 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..01fe45e4f --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/condition_variables.rs @@ -0,0 +1,358 @@ +// 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. + +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_variable_value_wildcards_are_literal_in_string_like() { + // Rule 2 for a wildcard that comes from a variable value rather than an + // escape, the same as a Resource pattern (issue #533). + let p = deny_if(V2012, "StringLike", OWNER, "${aws:PrincipalTag/team}"); + let ctx = owner("abc").with(TEAM, vec!["a*"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Allow); + let ctx = owner("a*").with(TEAM, vec!["a*"]); + assert_eq!(get(&p, &ctx), AuthzDecision::Deny); +} + +#[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_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/variables.rs b/crates/auth/src/policy/variables.rs index f6e248d96..af05e184c 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 @@ -39,16 +45,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") @@ -78,7 +84,7 @@ pub(super) fn validate_version(json: &str) -> Result<(), PolicyParseError> { /// 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 bytes. Star, /// `?` written in the policy text: matches exactly one byte. @@ -204,6 +210,16 @@ fn resolve<'a>( } } +/// The token for a byte written in the policy text. +fn written_token(byte: u8) -> Token { + match byte { + b'*' => Token::Star, + b'?' => Token::Question, + b':' => Token::Colon, + other => Token::Byte(other), + } +} + /// Expand `pattern` into tokens. Fails with [`Missing::Absent`] when a /// variable names a key the request does not carry and gives no default, /// since that alone makes the pattern match nothing, and otherwise with @@ -214,12 +230,7 @@ fn expand_tokens(pattern: &str, context: &impl ConditionContext) -> Result tokens.extend(text.bytes().map(|b| match b { - b'*' => Token::Star, - b'?' => Token::Question, - b':' => Token::Colon, - other => Token::Byte(other), - })), + Part::Text(text) => tokens.extend(text.bytes().map(written_token)), Part::Ref(_, Reference::Escape(byte)) => tokens.push(Token::Byte(byte)), Part::Ref(_, Reference::Key { name, default }) => { match resolve(name, default, context) { @@ -290,31 +301,122 @@ pub fn variable_key_names(value: &str) -> impl Iterator { }) } +/// A condition value after policy variable expansion for one request. +/// +/// 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 the string equality, numeric, date, + /// boolean and address operators. + 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, .. } => match_tokens(tokens, value.as_bytes()), + 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. /// -/// `${key}` and `${key, 'default'}` are replaced by their value; 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`]). +/// With `expand` false (a `2008-10-17` document, or one with no `Version`), 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`]). #[must_use] -pub fn expand_condition_value(value: &str, context: &impl ConditionContext) -> String { - if !value.contains("${") { - return value.to_owned(); +pub fn expand_condition_value( + value: &str, + expand: bool, + context: &impl ConditionContext, +) -> ConditionValue { + if !expand || !value.contains("${") { + return ConditionValue::Literal(value.to_owned()); } - let mut result = String::with_capacity(value.len()); + 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.bytes().map(written_token)); + } + Part::Ref(_, Reference::Escape(byte)) => { + text.push(char::from(byte)); + tokens.push(Token::Byte(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.bytes().map(Token::Byte)); } } } - result + ConditionValue::Expanded { text, tokens } } /// 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 909a62233..b7c39d2fd 100644 --- a/crates/auth/src/policy/variables/tests.rs +++ b/crates/auth/src/policy/variables/tests.rs @@ -317,36 +317,117 @@ 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}", &ctx), - "team-blue" + text_of(&expand_condition_value( + "${aws:username, 'anon'}", + true, + &ctx + )), + Some("anon") ); assert_eq!( - expand_condition_value("${aws:username, 'anon'}", &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'}", &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!( + 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("${aws:username}", &ctx), - "${aws:username}" + 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!( - expand_condition_value("a${*}b${?}c${$}", &ctx), - "a${*}b${?}c${$}" + text_of(&expand_condition_value("${open", true, &ctx)), + Some("${open") ); - assert_eq!(expand_condition_value("plain", &ctx), "plain"); - assert_eq!(expand_condition_value("${open", &ctx), "${open"); } #[test] @@ -374,7 +455,11 @@ fn variable_key_names_strip_the_default_and_skip_escapes() { 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", &ctx), - "xy" + text_of(&expand_condition_value( + "x${aws:PrincipalTag/team}y", + true, + &ctx + )), + Some("xy") ); } diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index 0be4b5295..ed80aace6 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -508,7 +508,11 @@ enum ConditionOutcome { fn evaluate_conditions(stmt: &Value, context: &AssumeRoleContext) -> ConditionOutcome { let normalized = normalize_condition_scalars(&stmt["Condition"]); match parse_conditions(&normalized) { - Ok(conds) => match evaluate_statement_conditions(&conds, context) { + // Policy variables in trust-policy condition values are expanded + // whatever the document's `Version`, as before this evaluator took + // the document's choice; the identity-policy evaluator expands them + // only in a `2012-10-17` document. + Ok(conds) => match evaluate_statement_conditions(&conds, true, context) { ConditionMatch::Match => ConditionOutcome::Pass, ConditionMatch::NoMatch => ConditionOutcome::Fail, // A condition on a key STS populates but ExtendDB cannot resolve diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 80c1f671b..908f03389 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -76,7 +76,7 @@ adaptation when switching between ExtendDB and the real service. |------|----------|------| | 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. The account and region checks come first (another account's backup ARN stays `AccessDeniedException`, 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/10-security-model.md b/docs/manuals/10-security-model.md index c1c7c52cb..24e131f1c 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -117,7 +117,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. The `${key, 'default'}` form expands to `default` when the request does not carry `key`. 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`. 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. ### 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}"}}) ) From b293373ffa36956ef9add6aa35f165c5a905b784 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:54:54 +0000 Subject: [PATCH 23/32] fix(auth): apply the trust policy's Version and expand variables only for string and ARN operators Two rules measured on IAM after the condition-variable change: - A trust policy follows the same Version rule as an identity policy. With AssumeRole on fresh roles, a 2008-10-17 trust policy, and one with no Version, compares ${aws:PrincipalArn} in a condition as literal text, and a 2012-10-17 one expands it. The trust evaluator expanded variables in every trust policy. - Only the string and ARN operators expand variables, with or without a set qualifier or IfExists. NumericEquals and the date and address operators compare a value written as a variable as written; Null and Bool read it as a boolean, as below. Tests pin both, and the measured cases for a wildcard inside a substituted value, IfExists and the set qualifiers with an unresolved variable. The security model states the operator rule, the trust policy rule, and how a negated operator behaves for a caller without the key. The IAM policy simulator reads a Null or Bool value written as a variable as the boolean false. A Deny with Null aws:PrincipalTag/team: ${aws:PrincipalTag/flag} applies whenever the team tag is present, whatever flag holds or defaults to. A Deny with Bool aws:SecureTransport: ${aws:PrincipalTag/flag} applies whenever SecureTransport is false. Before this change ExtendDB expanded the variable under both operators and, with flag set to true, allowed both requests. A Null or Bool value that contains ${ is now read as a boolean, true only when the text is true ignoring case. Null then requires the key to be present and Bool matches a request value that is not true. Other values compare as before. The tests pin the eleven measured cells, N1a to N1f for Null (N1f is the Allow with the condition alone) and N2a to N2e for Bool. Two of #535's tests in variable_values.rs change with the Null reading. the_default_form_expands_in_a_null_condition is renamed a_null_value_written_with_a_default_reads_as_false. Its two decisions still hold, because Null reads the text as false and needs the key present; the default is not used. The Null assertion of a_multivalued_key_in_a_condition_value_is_not_expanded_under_any_form now expects Deny: the text reads as false and team is present, the shape of measured cell N1a. The variable_values.rs module doc says that Null expands neither form and that the default form was measured under StringEquals. A condition value naming a key the context reports unresolvable fails the condition closed only under an operator that expands it. A case_three.rs test pins that for ArnLike and ArnNotLike, with and without a default, and that the same value in a 2008-10-17 document is compared as written. The evaluate_condition_match doc says that a value that also names an absent key with no default fails closed, although it matches nothing. --- crates/auth/src/policy/condition.rs | 83 +++++++-- .../src/policy/condition/tests/three_case.rs | 7 +- crates/auth/src/policy/evaluator/tests.rs | 1 + .../src/policy/evaluator/tests/case_three.rs | 91 ++++++++++ .../tests/condition_variables_operators.rs | 162 ++++++++++++++++++ .../policy/evaluator/tests/variable_values.rs | 27 ++- crates/server/src/management/assume_role.rs | 65 +++++-- .../assume_role/trust_version_tests.rs | 91 ++++++++++ docs/manuals/10-security-model.md | 2 +- 9 files changed, 493 insertions(+), 36 deletions(-) create mode 100644 crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs create mode 100644 crates/server/src/management/assume_role/trust_version_tests.rs diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index a1c3ad82e..f3bdd201f 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -51,6 +51,11 @@ pub fn evaluate_condition_with( ) -> bool { let context_values = context.resolve_key(&condition.key); let is_multivalued = context.is_multivalued_key(&condition.key); + // 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 = expand_variables && operator_expands_variables(&condition.operator); // Expand policy variables (e.g. `${aws:PrincipalTag/Team}`) in condition values. let expanded_values: Vec = condition @@ -65,7 +70,7 @@ pub fn evaluate_condition_with( expanded_values .first() .and_then(ConditionValue::text) - .is_some_and(|v| (v == "true" && key_absent) || (v == "false" && !key_absent)) + .is_some_and(|v| null_holds(v, key_absent)) } ConditionOperator::ForAllValues(inner) => { let (_absent_passes, base_op) = unwrap_if_exists(inner); @@ -195,20 +200,27 @@ pub enum ConditionMatch { /// Evaluate one condition three-valued. /// /// Returns [`ConditionMatch::Unresolvable`] when the key is unresolved and the -/// context reports it as service-populated (case 3), and, with -/// `expand_variables`, 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. Without `expand_variables` the value is compared as written and -/// reads no key. Otherwise returns `Match` or `NoMatch` from the ordinary -/// evaluation, so cases 1 and 2 are unchanged. +/// context reports it as service-populated (case 3), and, when the value is +/// expanded (`expand_variables` 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. Without `expand_variables`, and under an +/// operator that does not expand variables ([`operator_expands_variables`]), +/// the value is not expanded and reads no +/// key. Otherwise returns `Match` or `NoMatch` from the ordinary evaluation, +/// so cases 1 and 2 are unchanged. pub fn evaluate_condition_match( condition: &Condition, expand_variables: bool, context: &impl ConditionContext, ) -> ConditionMatch { if is_unresolvable(&condition.key, context) - || (expand_variables && reads_unresolvable_variable(condition, context)) + || (expand_variables + && operator_expands_variables(&condition.operator) + && reads_unresolvable_variable(condition, context)) { return ConditionMatch::Unresolvable; } @@ -295,6 +307,30 @@ fn expand_policy_variables( 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 documents +/// variables for string comparisons and ARNs; `NumericEquals` and `Bool` were +/// measured to compare a variable as written, and the date, address and `Null` +/// operators are treated the same way. +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 + | ConditionOperator::ArnEquals + | ConditionOperator::ArnNotEquals + | ConditionOperator::ArnLike + | ConditionOperator::ArnNotLike => true, + _ => false, + } +} + /// Unwrap an `IfExists` wrapper if present. /// /// Returns `(absent_passes, base_operator)` where `absent_passes` is true @@ -467,7 +503,7 @@ fn compare_single(op: &ConditionOperator, context_value: &str, value: &Condition 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, @@ -501,6 +537,33 @@ fn compare_single(op: &ConditionOperator, context_value: &str, value: &Condition } } +/// 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). +fn null_holds(policy_value: &str, key_absent: bool) -> bool { + if policy_value.contains("${") { + return policy_value.eq_ignore_ascii_case("true") == 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). +fn bool_matches(context_value: &str, policy_value: &str) -> bool { + if policy_value.contains("${") { + return context_value.eq_ignore_ascii_case("true") + == policy_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/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/evaluator/tests.rs b/crates/auth/src/policy/evaluator/tests.rs index e1e02f729..7bebefe43 100644 --- a/crates/auth/src/policy/evaluator/tests.rs +++ b/crates/auth/src/policy/evaluator/tests.rs @@ -61,6 +61,7 @@ mod case_three; mod combined; mod condition_variables; mod condition_variables_arn_context; +mod condition_variables_operators; mod core; mod resource_variables; mod resource_variables_fields; diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs index 71a5d6d26..f74aa5541 100644 --- a/crates/auth/src/policy/evaluator/tests/case_three.rs +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -368,3 +368,94 @@ fn a_2008_or_no_version_condition_value_naming_an_unresolvable_key_is_compared_a } } } + +#[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_variables_operators.rs b/crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs new file mode 100644 index 000000000..a37c6dd9d --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs @@ -0,0 +1,162 @@ +// 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. + +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:?}"); +} + +// --- 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 fb06a4596..add0849ed 100644 --- a/crates/auth/src/policy/evaluator/tests/variable_values.rs +++ b/crates/auth/src/policy/evaluator/tests/variable_values.rs @@ -4,14 +4,18 @@ //! 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, `IfExists` and `Null`. +//! 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`. //! -//! None of these expectations was measured on its own. Each follows from a +//! 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`). +//! 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 -//! not measured. +//! measured under `StringEquals` only (cell E8, `condition_variables.rs`). use super::*; @@ -145,8 +149,10 @@ fn the_default_form_expands_under_set_operators_and_if_exists() { } #[test] -fn the_default_form_expands_in_a_null_condition() { - // With no `flag` tag the value is `false`, so the Allow needs `team`. +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", @@ -174,11 +180,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/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index ed80aace6..76dfd38f0 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -375,7 +375,21 @@ async fn evaluate_trust_policy( epoch_time, }; - trust_decision(&statements, caller_arn, &context, has_session_tags) + trust_decision_for( + &statements, + caller_arn, + &context, + has_session_tags, + trust_policy_expands_variables(trust_policy), + ) +} + +/// Whether a trust policy expands policy variables in its condition values: +/// only a `2012-10-17` document does. A `2008-10-17` one, or one with no +/// `Version`, compares `${...}` as literal text (measured with AssumeRole on +/// fresh roles, issue #534). +fn trust_policy_expands_variables(trust_policy: &Value) -> bool { + trust_policy.get("Version").and_then(Value::as_str) == Some("2012-10-17") } /// The `aws:PrincipalType` for a caller ARN: `User` for an IAM user ARN, @@ -410,17 +424,33 @@ fn statements_vec(trust_policy: &Value) -> Option> { } } +/// Decide assumption from resolved statements and context, for a +/// `2012-10-17` trust policy. Kept for the unit tests, whose statements stand +/// for such a document. +#[cfg(test)] +fn trust_decision( + statements: &[Value], + caller_arn: &str, + context: &AssumeRoleContext, + has_session_tags: bool, +) -> bool { + trust_decision_for(statements, caller_arn, context, has_session_tags, true) +} + /// Decide assumption from resolved statements and context. Split out so the /// per-action Deny-over-Allow ordering is unit-testable without a store. -fn trust_decision( +/// `expand_variables` is whether the trust policy's `Version` expands policy +/// variables in condition values. +fn trust_decision_for( statements: &[Value], caller_arn: &str, context: &AssumeRoleContext, has_session_tags: bool, + expand_variables: bool, ) -> bool { requested_actions(has_session_tags) .iter() - .all(|action| action_permitted(statements, caller_arn, context, action)) + .all(|action| action_permitted(statements, caller_arn, context, action, expand_variables)) } /// The STS actions an `AssumeRole` request exercises. @@ -440,17 +470,18 @@ fn action_permitted( caller_arn: &str, context: &AssumeRoleContext, action: &str, + expand_variables: bool, ) -> bool { for stmt in statements { if effect_of(stmt).eq_ignore_ascii_case("Deny") - && deny_statement_blocks(stmt, caller_arn, context, action) + && deny_statement_blocks(stmt, caller_arn, context, action, expand_variables) { return false; } } for stmt in statements { if effect_of(stmt).eq_ignore_ascii_case("Allow") - && allow_statement_grants(stmt, caller_arn, context, action) + && allow_statement_grants(stmt, caller_arn, context, action, expand_variables) { return true; } @@ -471,12 +502,13 @@ fn deny_statement_blocks( caller_arn: &str, context: &AssumeRoleContext, action: &str, + expand_variables: bool, ) -> bool { if !principal_matches(&stmt["Principal"], caller_arn) || !action_matches(stmt, action) { return false; } matches!( - evaluate_conditions(stmt, context), + evaluate_conditions(stmt, context, expand_variables), ConditionOutcome::Pass | ConditionOutcome::Unparseable ) } @@ -488,11 +520,15 @@ fn allow_statement_grants( caller_arn: &str, context: &AssumeRoleContext, action: &str, + expand_variables: bool, ) -> bool { if !principal_matches(&stmt["Principal"], caller_arn) || !action_matches(stmt, action) { return false; } - matches!(evaluate_conditions(stmt, context), ConditionOutcome::Pass) + matches!( + evaluate_conditions(stmt, context, expand_variables), + ConditionOutcome::Pass + ) } /// Outcome of a statement's `Condition` block for a given context. @@ -505,14 +541,14 @@ enum ConditionOutcome { /// Evaluate a statement's `Condition` block. Scalar condition values (numbers, /// booleans) are coerced to strings, matching IAM. An unknown operator or a /// structurally invalid block is reported as `Unparseable`. -fn evaluate_conditions(stmt: &Value, context: &AssumeRoleContext) -> ConditionOutcome { +fn evaluate_conditions( + stmt: &Value, + context: &AssumeRoleContext, + expand_variables: bool, +) -> ConditionOutcome { let normalized = normalize_condition_scalars(&stmt["Condition"]); match parse_conditions(&normalized) { - // Policy variables in trust-policy condition values are expanded - // whatever the document's `Version`, as before this evaluator took - // the document's choice; the identity-policy evaluator expands them - // only in a `2012-10-17` document. - Ok(conds) => match evaluate_statement_conditions(&conds, true, context) { + Ok(conds) => match evaluate_statement_conditions(&conds, expand_variables, context) { ConditionMatch::Match => ConditionOutcome::Pass, ConditionMatch::NoMatch => ConditionOutcome::Fail, // A condition on a key STS populates but ExtendDB cannot resolve @@ -1018,6 +1054,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..d9f96e32b --- /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_expands_variables}; +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_expands_variables(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/manuals/10-security-model.md b/docs/manuals/10-security-model.md index 24e131f1c..9cfbc5b2b 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -117,7 +117,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; 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`. 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. +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`. 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. Only the string and ARN operators 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`. Trust policies follow the same `Version` rule. 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 From 1decbc70f3ec98821f50342d09a8bbd1036a79a6 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:20:48 +0000 Subject: [PATCH 24/32] fix(auth): fail Arn* conditions closed on values without ARN fields A condition value such as ${aws:ResourceTag/blockedPrincipal} has no ":" written outside its reference. The ArnLike token matcher splits a value into fields only at a written ":", so this value was one field against the six of the request ARN: ArnLike never matched and ArnNotLike always held. An ArnNotLike Allow on aws:PrincipalArn then granted the principal that the tag names, and the ArnLike Deny written the same way stopped applying to that principal. The IAM policy simulator refuses a policy with such a value (InvalidInput) and IAM Access Analyzer reports MISSING_ARN_FIELD, so IAM gives no decision to match. In a document that expands variables, an Arn* condition whose value holds a ${...} reference and fewer than five ":" outside its references now evaluates as Unresolvable, with or without a set qualifier or IfExists. A Deny with such a condition applies and an Allow does not grant. The tests pin the Deny and the Allow-only policies under ArnLike, ArnNotLike, ArnEquals and ArnNotEquals and three qualified forms, and that a value with five written colons and a variable in the resource field still matches as before. --- crates/auth/src/policy/condition.rs | 31 ++++- crates/auth/src/policy/document.rs | 2 +- crates/auth/src/policy/evaluator/tests.rs | 1 + .../tests/condition_variables_arn.rs | 108 ++++++++++++++++++ crates/auth/src/policy/variables.rs | 19 +++ crates/auth/src/policy/variables/tests.rs | 17 +++ docs/manuals/10-security-model.md | 2 +- 7 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 crates/auth/src/policy/evaluator/tests/condition_variables_arn.rs diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index f3bdd201f..747a44edf 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::variables::ConditionValue; +use super::document::{Condition, ConditionOperator, base_operator}; +use super::variables::{ConditionValue, arn_value_lacks_fields}; /// Evaluate a single condition against a context. /// @@ -193,7 +193,8 @@ 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 IAM refuses because it + /// cannot have the six fields of an ARN. The statement fails closed. Unresolvable, } @@ -210,8 +211,12 @@ pub enum ConditionMatch { /// pattern of that shape does not. Without `expand_variables`, and under an /// operator that does not expand variables ([`operator_expands_variables`]), /// the value is not expanded and reads no -/// key. Otherwise returns `Match` or `NoMatch` from the ordinary evaluation, -/// so cases 1 and 2 are unchanged. +/// key. It also returns `Unresolvable` when the document expands variables and +/// a value of an `Arn*` operator, with or without a set qualifier or +/// `IfExists`, holds a `${...}` reference and 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. pub fn evaluate_condition_match( condition: &Condition, expand_variables: bool, @@ -224,6 +229,22 @@ pub fn evaluate_condition_match( { 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. + if expand_variables + && 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; + } if evaluate_condition_with(condition, expand_variables, context) { ConditionMatch::Match } else { 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/tests.rs b/crates/auth/src/policy/evaluator/tests.rs index 7bebefe43..825103ec3 100644 --- a/crates/auth/src/policy/evaluator/tests.rs +++ b/crates/auth/src/policy/evaluator/tests.rs @@ -60,6 +60,7 @@ fn parse(json: &str) -> PolicyDocument { mod case_three; mod combined; mod condition_variables; +mod condition_variables_arn; mod condition_variables_arn_context; mod condition_variables_operators; mod core; 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..1cabeaeb0 --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/condition_variables_arn.rs @@ -0,0 +1,108 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! An `Arn*` condition value that holds a policy variable and 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 and +//! the qualified forms 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 { + parse(&format!( + r#"{{"Version":"2012-10-17","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 { + 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("Deny", &condition)]; + if get(&a7, &alice_blocked()) != AuthzDecision::Deny { + wrong.push(format!("A7 {op}")); + } + let a8 = vec![statement("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 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"); + } +} diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index af05e184c..ee913e32a 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -419,6 +419,25 @@ pub fn expand_condition_value( ConditionValue::Expanded { text, tokens } } +/// Whether the `Arn*` condition value `value` holds a `${...}` reference 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 /// [`arn_match`]. fn arn_match_tokens(pattern: &[Token], value: &str) -> bool { diff --git a/crates/auth/src/policy/variables/tests.rs b/crates/auth/src/policy/variables/tests.rs index b7c39d2fd..26b493081 100644 --- a/crates/auth/src/policy/variables/tests.rs +++ b/crates/auth/src/policy/variables/tests.rs @@ -463,3 +463,20 @@ fn condition_value_present_key_without_values_is_empty() { 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/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index 9cfbc5b2b..ccd62764f 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -117,7 +117,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; 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`. 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. Only the string and ARN operators 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`. Trust policies follow the same `Version` rule. 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. +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`. 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. Only the string and ARN operators 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 with a variable 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, so a `Deny` applies and an `Allow` does not grant. Trust policies follow the same `Version` rule. 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 From a1c10474b8f4e605622af59d9ada0115abbfdb7c Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:26:52 +0000 Subject: [PATCH 25/32] docs(auth): correct variable docs and drop a duplicate test The doc on ConditionValue::Expanded::text listed the numeric, date, boolean and address operators as readers of the expanded text. Only the string and ARN operators expand a value, so the text is read by StringEquals, StringNotEquals and their IgnoreCase forms; the Like and Arn operators match the tokens. derived_variable_value_wildcards_are_literal_in_string_like sat under the derived heading of condition_variables.rs, but its cell was measured on 2026-10-25, and string_like_reads_a_substituted_star_as_a_character in condition_variables_operators.rs already pins it. The derived copy is removed and the module doc points at the measured one. The comment on operator_expands_variables said IAM documents variables for string comparisons and ARNs. IAM's operator reference also lists the Boolean operators and says a variable cannot be used with Null. The comment now gives the reference and what the IAM policy simulator measured for each operator family. --- crates/auth/src/policy/condition.rs | 15 +++++++++++---- .../policy/evaluator/tests/condition_variables.rs | 15 +++------------ crates/auth/src/policy/variables.rs | 6 ++++-- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 747a44edf..be29d6f26 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -329,10 +329,17 @@ fn expand_policy_variables( } /// Whether `op` expands policy variables in its values: the string and ARN -/// operators, with or without a set qualifier or `IfExists`. IAM documents -/// variables for string comparisons and ARNs; `NumericEquals` and `Bool` were -/// measured to compare a variable as written, and the date, address and `Null` -/// operators are treated the same way. +/// 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) diff --git a/crates/auth/src/policy/evaluator/tests/condition_variables.rs b/crates/auth/src/policy/evaluator/tests/condition_variables.rs index 01fe45e4f..15323c1ec 100644 --- a/crates/auth/src/policy/evaluator/tests/condition_variables.rs +++ b/crates/auth/src/policy/evaluator/tests/condition_variables.rs @@ -7,7 +7,9 @@ //! `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 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::*; @@ -269,17 +271,6 @@ fn derived_set_operator_escaped_star_is_literal() { assert_eq!(get(&p, &ctx), AuthzDecision::Deny); } -#[test] -fn derived_variable_value_wildcards_are_literal_in_string_like() { - // Rule 2 for a wildcard that comes from a variable value rather than an - // escape, the same as a Resource pattern (issue #533). - let p = deny_if(V2012, "StringLike", OWNER, "${aws:PrincipalTag/team}"); - let ctx = owner("abc").with(TEAM, vec!["a*"]); - assert_eq!(get(&p, &ctx), AuthzDecision::Allow); - let ctx = owner("a*").with(TEAM, vec!["a*"]); - assert_eq!(get(&p, &ctx), AuthzDecision::Deny); -} - #[test] fn version_2008_string_like_wildcards_still_match() { // Rule 1 leaves written wildcards as wildcards: only variable expansion is diff --git a/crates/auth/src/policy/variables.rs b/crates/auth/src/policy/variables.rs index ee913e32a..57a094eb6 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -318,8 +318,10 @@ pub enum ConditionValue { Literal(String), /// The value with its variables, defaults and escapes expanded. Expanded { - /// The expanded text, compared by the string equality, numeric, date, - /// boolean and address operators. + /// 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 From 63627a7ddbc6ddde5075fde3464628b0f02b7f7e Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:29:04 +0000 Subject: [PATCH 26/32] test(auth): pin that date and address values are not expanded operator_expands_variables expands only the string and ARN operators, and the security model says a date or address value written as a variable is compared as written, but adding DateEquals or IpAddress to the expanding operators failed no test. The new test denies with DateEquals aws:CurrentTime against ${aws:PrincipalTag/when} when the tag holds the request time, and evaluates IpAddress aws:SourceIp against ${aws:PrincipalTag/net} when the tag holds a range containing the source address. Neither condition matches. The IpAddress half goes through parse_conditions and evaluate_statement_conditions, the trust policy path, because an identity policy with that value does not parse: the value is not a CIDR. --- .../evaluator/tests/condition_variables.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/auth/src/policy/evaluator/tests/condition_variables.rs b/crates/auth/src/policy/evaluator/tests/condition_variables.rs index 15323c1ec..fb8c92b1e 100644 --- a/crates/auth/src/policy/evaluator/tests/condition_variables.rs +++ b/crates/auth/src/policy/evaluator/tests/condition_variables.rs @@ -271,6 +271,41 @@ fn derived_set_operator_escaped_star_is_literal() { 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 From 7b79409687d5407b1b1ae5e5b51944668ac30652 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:33:51 +0000 Subject: [PATCH 27/32] docs(upgrade): list the v0.1.15 condition-value changes A stored policy whose condition values hold a policy variable can get a different decision after the upgrade. The v0.1.15 entry gives each case an example. The cases are the Version rule, the escapes, a wildcard from a variable value, a variable with no value or default, numeric, date and address values, Bool and Null values read as false, and an Arn* value without written ARN fields, which now fails closed. The entry names the commands that print stored policies, so an operator can search them for ${ before upgrading. No catalog migration is required. It follows the v0.1.15 entry for Resource and NotResource variables and corrects that entry's items 2 and 3, which described condition values as compared before this change. In a 2012-10-17 document a value naming a multivalued key the request carries now matches nothing, and the default form expands only under the string and ARN operators; Null and Bool read it as false, and the numeric and date operators compare it as written. It also corrects the v0.1.15 entry for the tag condition keys, which said a condition value ${aws:TagKeys} stays literal text: the variable has no value, so a positive operator is false against it and a negated one true. --- docs/manuals/07-upgrade-manual.md | 39 ++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index a0ad41108..641381ed8 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -340,17 +340,24 @@ issue #533). Three 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. A condition value written as `${key, 'default'}` 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 +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`. 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. @@ -378,6 +385,22 @@ condition whose value names a multivalued key), a `Deny` that now applies (the same two forms), and a `Deny NotResource` that now spares a table. No catalog migration is required. +### v0.1.15: IAM policy variables in condition values + +**What changed.** Policy variables in condition values now follow IAM's rules, each measured with the IAM policy simulator (issue #534). Only a `2012-10-17` document expands them, 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 a request value equal to that text: a positive operator such as `StringEquals` no longer matches it, and a negated operator such as `StringNotEquals` now holds for it. +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 with a variable 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/blockedPrincipal}`, and ExtendDB now fails that condition closed. 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. To compare a principal with an ARN stored whole in a tag, use `StringEquals`, which compares the expanded text. + +**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 numeric, date, `Bool` or `Null` value written as a variable, and an `Arn*` value that is a variable with 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`, @@ -712,9 +735,9 @@ A policy whose Arn values are literal ARNs, applied to a context value that is a **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. From f858eeb0c0242ca489a03dacb85bcf2a3684cc7d Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:40:51 +0000 Subject: [PATCH 28/32] fix(auth): fail Arn* values without ARN fields closed in any Version An Arn* condition value that contains ${ and has fewer than five : written outside its ${...} references cannot have an ARN's six fields. The rule that makes such a condition Unresolvable read the document's expand_variables flag, so in a 2008-10-17 or no-Version document the value went to the ordinary comparison: an ArnLike Deny on ${aws:ResourceTag/blockedPrincipal} applied to no request, and an ArnNotLike Allow on it granted every principal. IAM checks a value's fields whatever the Version, so the rule no longer reads the flag. The new tests run the A7 and A8 policies in a 2008-10-17 document and in one with no Version under ArnLike, ArnNotLike, ArnEquals and ArnNotEquals and expect Deny. A second test pins that a value with its five : written is still compared as written in those documents. The docs on evaluate_condition_match, ConditionMatch::Unresolvable and arn_value_lacks_fields, the security model and upgrade item 7 say the rule holds in any Version and applies to a value that contains ${. --- crates/auth/src/policy/condition.rs | 34 ++++----- .../tests/condition_variables_arn.rs | 76 +++++++++++++++++-- crates/auth/src/policy/variables.rs | 4 +- docs/manuals/07-upgrade-manual.md | 4 +- docs/manuals/10-security-model.md | 2 +- 5 files changed, 91 insertions(+), 29 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index be29d6f26..4af5dbeff 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -193,8 +193,9 @@ 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, or has an `Arn*` value that IAM refuses because it - /// cannot have the six fields of an ARN. 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, } @@ -211,12 +212,12 @@ pub enum ConditionMatch { /// pattern of that shape does not. Without `expand_variables`, and under an /// operator that does not expand variables ([`operator_expands_variables`]), /// the value is not expanded and reads no -/// key. It also returns `Unresolvable` when the document expands variables and +/// key. It also returns `Unresolvable`, whatever `expand_variables` says, when /// a value of an `Arn*` operator, with or without a set qualifier or -/// `IfExists`, holds a `${...}` reference and 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. +/// `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. pub fn evaluate_condition_match( condition: &Condition, expand_variables: bool, @@ -232,16 +233,15 @@ pub fn evaluate_condition_match( // 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. - if expand_variables - && matches!( - base_operator(&condition.operator), - ConditionOperator::ArnEquals - | ConditionOperator::ArnNotEquals - | ConditionOperator::ArnLike - | ConditionOperator::ArnNotLike - ) - && condition.values.iter().any(|v| arn_value_lacks_fields(v)) + // keeps an `Allow` from granting. IAM checks the value's fields whatever + // the document's `Version`, so the rule does not read `expand_variables`. + 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; } diff --git a/crates/auth/src/policy/evaluator/tests/condition_variables_arn.rs b/crates/auth/src/policy/evaluator/tests/condition_variables_arn.rs index 1cabeaeb0..041d72937 100644 --- a/crates/auth/src/policy/evaluator/tests/condition_variables_arn.rs +++ b/crates/auth/src/policy/evaluator/tests/condition_variables_arn.rs @@ -1,15 +1,16 @@ // Copyright 2026 ExtendDB contributors // SPDX-License-Identifier: Apache-2.0 -//! An `Arn*` condition value that holds a policy variable and fewer than five -//! `:` written outside its `${...}` references, such as +//! 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 and -//! the qualified forms apply the same rule and were not sent to the simulator. +//! 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::*; @@ -25,8 +26,14 @@ fn allow_all() -> PolicyDocument { } 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":"2012-10-17","Statement":[{{ + r#"{{{version}"Statement":[{{ "Effect":"{effect}","Action":"dynamodb:GetItem","Resource":"*", "Condition":{condition} }}]}}"# @@ -47,15 +54,20 @@ fn alice_blocked() -> Ctx { /// 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("Deny", &condition)]; + 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("Allow", &condition)]; + let a8 = vec![statement_in(version, "Allow", &condition)]; if get(&a8, &alice_blocked()) != AuthzDecision::Deny { wrong.push(format!("A8 {op}")); } @@ -79,6 +91,22 @@ fn the_rule_reads_the_operator_inside_if_exists_and_the_set_qualifiers() { 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 @@ -106,3 +134,37 @@ fn a_variable_after_five_written_colons_still_matches() { 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/variables.rs b/crates/auth/src/policy/variables.rs index 57a094eb6..f4289ab00 100644 --- a/crates/auth/src/policy/variables.rs +++ b/crates/auth/src/policy/variables.rs @@ -421,8 +421,8 @@ pub fn expand_condition_value( ConditionValue::Expanded { text, tokens } } -/// Whether the `Arn*` condition value `value` holds a `${...}` reference and -/// has fewer than five `:` written outside its references, as +/// 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 diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 641381ed8..cabfcd24f 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -395,9 +395,9 @@ migration is required. 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 a request value equal to that text: a positive operator such as `StringEquals` no longer matches it, and a negated operator such as `StringNotEquals` now holds for it. 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 with a variable 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/blockedPrincipal}`, and ExtendDB now fails that condition closed. 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. To compare a principal with an ARN stored whole in a tag, use `StringEquals`, which compares the expanded text. +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 `2008-10-17` or no-`Version` policy was not sent to the simulator). 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. To compare a principal with an ARN stored whole in a tag, use `StringEquals` in a `2012-10-17` document, which compares the expanded text. -**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 numeric, date, `Bool` or `Null` value written as a variable, and an `Arn*` value that is a variable with fewer than five written `:`. No catalog migration is required. +**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 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. --- diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index ccd62764f..c3d735080 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -117,7 +117,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; 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`. 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. Only the string and ARN operators 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 with a variable 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, so a `Deny` applies and an `Allow` does not grant. Trust policies follow the same `Version` rule. 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. +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`. 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. Only the string and ARN operators 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. Trust policies follow the same `Version` rule. 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 From 3f7f978910873b715d0efaa73103b896d4df6e63 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:43:58 +0000 Subject: [PATCH 29/32] docs(auth): name the measured cases for variables in condition values The v0.1.15 upgrade entry said every rule was measured with the IAM policy simulator. Its first sentence now names the measured cases: the string, ARN, numeric, Bool and Null operators in 2012-10-17 documents, and StringEquals in 2008-10-17 and no-Version documents. The date and address operators follow the numeric case. The docs on null_holds and bool_matches say that cells N1a to N2e are 2012-10-17 documents and that a 2008-10-17 or no-Version document is read the same way, because IAM reads a Null or Bool text without expanding it whatever the Version. That reading was not measured. No code path changes. --- crates/auth/src/policy/condition.rs | 9 +++++++-- docs/manuals/07-upgrade-manual.md | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 4af5dbeff..bcee2c804 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -570,7 +570,10 @@ fn compare_single(op: &ConditionOperator, context_value: &str, value: &Condition /// 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). +/// (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("${") { return policy_value.eq_ignore_ascii_case("true") == key_absent; @@ -583,7 +586,9 @@ fn null_holds(policy_value: &str, key_absent: bool) -> bool { /// 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). +/// 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("${") { return context_value.eq_ignore_ascii_case("true") diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index cabfcd24f..0462618bd 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -387,7 +387,7 @@ migration is required. ### v0.1.15: IAM policy variables in condition values -**What changed.** Policy variables in condition values now follow IAM's rules, each measured with the IAM policy simulator (issue #534). Only a `2012-10-17` document expands them, and in that document only the string and ARN operators do. Decisions change for stored policies in these cases: +**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. From fd9b57d15fa22558e0f7a62b1424001082848bf8 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:45:32 +0000 Subject: [PATCH 30/32] test(auth): pin how a variable Bool value reads a request TRUE A Bool value written as a variable is read as false, and the request value is compared with true ignoring case, as a Bool value without a variable compares it. No test pinned the request side: comparing it with == "true" in that branch passed every test. The new test is cell N2c with aws:SecureTransport spelled TRUE and expects Allow. It is labelled not measured. --- .../tests/condition_variables_operators.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs b/crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs index a37c6dd9d..4e6795dee 100644 --- a/crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs +++ b/crates/auth/src/policy/evaluator/tests/condition_variables_operators.rs @@ -9,6 +9,7 @@ //! `*` 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::*; @@ -111,6 +112,18 @@ fn bool_reads_a_variable_as_false() { 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] From 44a7b7e8f4e7035f98b834a25077b97464cdb07e Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:47:42 +0000 Subject: [PATCH 31/32] docs(auth): state the wildcard and Arn cases and tidy two branches Upgrade item 4 said an unresolved variable changes the decision only for a request value equal to its text. Under StringLike, ArnLike or ArnEquals a value that also has a written * or ? changed for every request value the text matched as a pattern, and the item says so. The "Who is affected" list names that shape: a string or ARN value with a variable that has no default. Item 7 says that only the ArnLike Deny and the ArnNotLike Allow in 2012-10-17 documents were sent to the IAM policy simulator, and that the ArnEquals, ArnNotEquals, qualified, 2008-10-17 and no-Version forms follow the same rule without a measurement. The ${ branches of null_holds and bool_matches no longer compare the policy text with true, which a text that contains ${ never is. They return !key_absent and !context_value.eq_ignore_ascii_case("true"), with a comment saying why. The decisions do not change. --- crates/auth/src/policy/condition.rs | 9 ++++++--- docs/manuals/07-upgrade-manual.md | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index bcee2c804..288e81abe 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -576,7 +576,9 @@ fn compare_single(op: &ConditionOperator, context_value: &str, value: &Condition /// `Version` (not measured). fn null_holds(policy_value: &str, key_absent: bool) -> bool { if policy_value.contains("${") { - return policy_value.eq_ignore_ascii_case("true") == key_absent; + // 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) } @@ -591,8 +593,9 @@ fn null_holds(policy_value: &str, key_absent: bool) -> bool { /// the text without expanding it whatever the `Version` (not measured). fn bool_matches(context_value: &str, policy_value: &str) -> bool { if policy_value.contains("${") { - return context_value.eq_ignore_ascii_case("true") - == policy_value.eq_ignore_ascii_case("true"); + // 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) } diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 0462618bd..ecbe5d96b 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -392,12 +392,12 @@ migration is required. 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 a request value equal to that text: a positive operator such as `StringEquals` no longer matches it, and a negated operator such as `StringNotEquals` now holds for it. +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 `2008-10-17` or no-`Version` policy was not sent to the simulator). 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. To compare a principal with an ARN stored whole in a tag, use `StringEquals` in a `2012-10-17` document, which compares the expanded text. +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 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. +**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. --- From 662f85b3bea8dbc47fd27a134f35f9eb08913894 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:25:29 +0000 Subject: [PATCH 32/32] test(auth): match #535's tests and docs to the condition-value rules A condition value now expands only in a 2012-10-17 document and only under the string and ARN operators, and Null and Bool read a variable value as false. A variable naming a key the context reports unresolvable fails the condition closed only where the value is expanded. A test comment and several docs from #535 described the earlier comparison. In case_three.rs the test for an unresolvable key in a condition value keeps its name and decisions, and its comment now names the string and ARN operators. The FAIL_CLOSED_GLOBAL_KEYS doc and the case_three.rs module doc say that a value naming such a key fails the condition closed only in a 2012-10-17 document under a string or ARN operator, and that a value that is not expanded reads no key. REQ-ABAC-006 and the Policy Variables note and limit 4 of 05-component-auth.md no longer list the #534 gaps, and the backlog item for them is removed, because the Version rule and the escape expansion close them. The security model sentence on multivalued keys says a written default is used only when the request does not carry the key. --- crates/auth/src/policy/context.rs | 8 +++++--- crates/auth/src/policy/evaluator/tests/case_three.rs | 9 ++++++--- docs/backlog.md | 1 - docs/design/01-requirements.md | 2 +- docs/design/05-component-auth.md | 4 ++-- docs/manuals/10-security-model.md | 2 +- 6 files changed, 15 insertions(+), 11 deletions(-) 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/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs index f74aa5541..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'}"), 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 df7ce422a..85c4b02ef 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 (`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/manuals/10-security-model.md b/docs/manuals/10-security-model.md index c3d735080..da454e86f 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -117,7 +117,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; 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`. 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. Only the string and ARN operators 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. Trust policies follow the same `Version` rule. 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. +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`. 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 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. Trust policies follow the same `Version` rule. 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