Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e7397ac
fix(auth): expand policy variables in Resource and NotResource patterns
LeeroyHannigan Oct 31, 2026
00eadf1
test(auth): cover policy variables in Resource and NotResource at the…
LeeroyHannigan Oct 31, 2026
810cd7b
docs(security): state that Resource and NotResource expand policy var…
LeeroyHannigan Oct 31, 2026
d0644c9
test(auth): borrow the condition default-form policy as a slice
LeeroyHannigan Oct 31, 2026
b6dad4b
fix(auth): treat multivalued keys as unset and keep a value's colons …
LeeroyHannigan Oct 31, 2026
883e3ea
docs(security): multivalued keys and colons in policy variable values
LeeroyHannigan Oct 31, 2026
3cc7663
test(auth): pin policy variable rules that had no evaluator test
LeeroyHannigan Oct 28, 2026
07ba980
fix(auth): fail closed on an unresolvable key in a Resource variable
LeeroyHannigan Oct 28, 2026
e71157a
docs(auth): name the measured cases instead of matrix cell labels
LeeroyHannigan Oct 28, 2026
c638947
test(auth): say which policy variable expectations were measured
LeeroyHannigan Oct 28, 2026
c2ac656
fix(auth): refuse a policy Version other than the two IAM values
LeeroyHannigan Oct 28, 2026
011c6cb
docs(design): describe policy variables in Resource as implemented
LeeroyHannigan Oct 28, 2026
026c861
docs(upgrade): note the policy variable decision changes in v0.1.15
LeeroyHannigan Oct 28, 2026
cb153c1
test(auth): pin a condition value naming an unresolvable key
LeeroyHannigan Oct 28, 2026
fd8d513
test(auth): pin the Version gate on an unresolvable Resource key
LeeroyHannigan Oct 28, 2026
918f09a
docs: say a Resource variable with a default uses the default
LeeroyHannigan Oct 28, 2026
06522e5
docs(upgrade): cover negated operators in the multivalued key item
LeeroyHannigan Oct 28, 2026
056e0b9
docs(auth): correct policy variable labels and upgrade wording
LeeroyHannigan Oct 28, 2026
a6aba3c
fix(auth): a present multivalued key ignores a variable default
LeeroyHannigan Oct 28, 2026
3afcf10
fix(auth): fail closed on a defaulted variable naming an unresolvable…
LeeroyHannigan Oct 31, 2026
57861f5
docs(auth): say a condition value naming an unresolvable key fails cl…
LeeroyHannigan Oct 31, 2026
68ea9bb
fix(auth): expand policy variables in condition values only for 2012-…
LeeroyHannigan Oct 25, 2026
b293373
fix(auth): apply the trust policy's Version and expand variables only…
LeeroyHannigan Oct 25, 2026
1decbc7
fix(auth): fail Arn* conditions closed on values without ARN fields
LeeroyHannigan Oct 28, 2026
a1c1047
docs(auth): correct variable docs and drop a duplicate test
LeeroyHannigan Oct 28, 2026
63627a7
test(auth): pin that date and address values are not expanded
LeeroyHannigan Oct 28, 2026
7b79409
docs(upgrade): list the v0.1.15 condition-value changes
LeeroyHannigan Oct 28, 2026
f858eeb
fix(auth): fail Arn* values without ARN fields closed in any Version
LeeroyHannigan Oct 28, 2026
3f7f978
docs(auth): name the measured cases for variables in condition values
LeeroyHannigan Oct 28, 2026
fd9b57d
test(auth): pin how a variable Bool value reads a request TRUE
LeeroyHannigan Oct 28, 2026
44a7b7e
docs(auth): state the wildcard and Arn cases and tidy two branches
LeeroyHannigan Oct 28, 2026
662f85b
test(auth): match #535's tests and docs to the condition-value rules
LeeroyHannigan Oct 28, 2026
078cc6b
Merge origin/main into fix/condition-policy-variables
LeeroyHannigan Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 192 additions & 98 deletions crates/auth/src/policy/condition.rs

Large diffs are not rendered by default.

63 changes: 48 additions & 15 deletions crates/auth/src/policy/condition/tests/multivalued.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,26 +284,35 @@ 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",
vec!["${aws:PrincipalTag/Missing}"]
),
&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",
Expand All @@ -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()
Expand Down
7 changes: 4 additions & 3 deletions crates/auth/src/policy/condition/tests/three_case.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
8 changes: 5 additions & 3 deletions crates/auth/src/policy/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 32 additions & 15 deletions crates/auth/src/policy/context/request_tag_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ fn ambiguous_request_tag_as_a_policy_variable_fails_the_statement_closed() {
),
ConditionMatch::Unresolvable
);
// An absent request tag is not ambiguous: the variable stays literal, as before.
// An absent request tag is not ambiguous: with no default the variable has
// no value, so the value matches nothing.
assert_eq!(
evaluate_statement_conditions(&deny, true, &with_resource_tag(&[("team", "x")])),
ConditionMatch::NoMatch
Expand Down Expand Up @@ -253,13 +254,14 @@ fn ambiguous_request_tag_as_a_defaulted_policy_variable_fails_the_statement_clos
}

#[test]
fn policy_variable_naming_aws_tagkeys_is_left_literal_for_every_tag_order() {
fn policy_variable_naming_aws_tagkeys_has_no_value_for_every_tag_order() {
use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions};
use crate::policy::document::{Condition, ConditionOperator};

// A Deny on aws:ResourceTag/team equal to ${aws:TagKeys} on a table tagged
// team=env. The variable names a multivalued key, so it stays literal and
// the statement applies to neither order of the request's tag keys.
// team=env. The variable names a multivalued key the request carries, so
// it has no value and the statement applies to neither order of the
// request's tag keys.
let deny = [Condition {
operator: ConditionOperator::StringEquals,
key: "aws:ResourceTag/team".to_owned(),
Expand Down Expand Up @@ -306,36 +308,51 @@ fn assume_role_ctx(principal: &[(&str, &str)], session: &[(&str, &str)]) -> Assu
}

#[test]
fn trust_policy_variable_naming_aws_tagkeys_is_left_literal() {
fn trust_policy_variable_naming_aws_tagkeys_has_no_value() {
use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions};
use crate::policy::document::{Condition, ConditionOperator};

// AssumeRoleContext reports aws:TagKeys as multivalued, so a trust policy
// condition value ${aws:TagKeys} stays literal text. Expanded, it would be
// the first session tag key in the map's iteration order, which a test
// cannot choose. One context holds both session tag keys in one map, and
// each of its two principal tags holds one key, so an expansion to either
// key matches the condition on the principal tag that holds it.
let compare_with_tag_keys = |principal_tag: &str| {
// condition value ${aws:TagKeys} has no value: a positive operator is
// false against it and a negated one true, even for a principal tag that
// holds the variable's text. The IAM policy simulator gives these answers
// for an identity policy (2026-10-31, rows in the description of PR
// #541); the trust policy case was not measured. Expanded, it would be the
// first session tag key in the map's iteration order, which a test cannot
// choose. One context holds both session tag keys in one map, and each of
// its two principal tags holds one key, so an expansion to either key
// matches the condition on the principal tag that holds it.
let compare_with_tag_keys = |operator: ConditionOperator, principal_tag: &str| {
[Condition {
operator: ConditionOperator::StringEquals,
operator,
key: format!("aws:PrincipalTag/{principal_tag}"),
values: vec!["${aws:TagKeys}".to_owned()],
}]
};
let equals = |tag: &str| compare_with_tag_keys(ConditionOperator::StringEquals, tag);
let not_equals = |tag: &str| compare_with_tag_keys(ConditionOperator::StringNotEquals, tag);
let session = [("env", "x"), ("owner", "x")];
let ctx = assume_role_ctx(&[("key_env", "env"), ("key_owner", "owner")], &session);
for tag in ["key_env", "key_owner"] {
assert_eq!(
evaluate_statement_conditions(&compare_with_tag_keys(tag), true, &ctx),
evaluate_statement_conditions(&equals(tag), true, &ctx),
ConditionMatch::NoMatch,
"${{aws:TagKeys}} expanded to the session tag key in {tag}"
);
assert_eq!(
evaluate_statement_conditions(&not_equals(tag), true, &ctx),
ConditionMatch::Match,
"${{aws:TagKeys}} expanded to the session tag key in {tag}"
);
}
// The variable is text, so it equals the same text.
// The variable's text is not compared.
let ctx = assume_role_ctx(&[("team", "${aws:TagKeys}")], &session);
assert_eq!(
evaluate_statement_conditions(&compare_with_tag_keys("team"), true, &ctx),
evaluate_statement_conditions(&equals("team"), true, &ctx),
ConditionMatch::NoMatch
);
assert_eq!(
evaluate_statement_conditions(&not_equals("team"), true, &ctx),
ConditionMatch::Match
);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/auth/src/policy/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions crates/auth/src/policy/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,9 @@ fn resource_matches(
}

/// Outcome of a statement's `Condition` block, three-valued. `policy` is the
/// statement's document: its `Version` decides how the condition values read
/// policy variables (`condition::evaluate_condition_with`).
/// statement's document: its `Version` decides whether the condition values
/// expand policy variables, which only a `2012-10-17` document does
/// (`condition::evaluate_condition_with`).
fn statement_conditions(
policy: &PolicyDocument,
statement: &Statement,
Expand Down
4 changes: 4 additions & 0 deletions crates/auth/src/policy/evaluator/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ fn parse(json: &str) -> PolicyDocument {
mod case_three;
mod combined;
mod condition_variable_scope;
mod condition_variables;
mod condition_variables_arn;
mod condition_variables_arn_context;
mod condition_variables_operators;
mod core;
mod empty_values;
mod resource_variables;
Expand Down
Loading
Loading