Skip to content

fix(auth): follow IAM's rules for policy variables in condition values - #541

Merged
LeeroyHannigan merged 33 commits into
mainfrom
fix/condition-policy-variables
Nov 5, 2026
Merged

LeeroyHannigan merged 33 commits into
mainfrom
fix/condition-policy-variables

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #534. Stacked on #535: this branch builds on fix/resource-policy-variables and reuses its variables.rs module, so the diff below is the eleven commits on top of it.

Summary

Policy variables in condition values now follow IAM's rules, each measured with the IAM policy simulator or, for trust policies, with AssumeRole:

  • Only a 2012-10-17 document expands variables in condition values. A 2008-10-17 document, or one with no Version, compares ${aws:username} as the literal text, and the default form too. This applies to identity policies, permissions boundaries, session policies and trust policies.
  • ${*}, ${?} and ${$} expand to the literal characters. In StringLike and ArnLike (and ArnEquals) the escaped character, and any character that comes from a substituted value, is matched literally, while a * or ? written in the policy is a wildcard.
  • ${key, 'default'} expands to default when the key is absent.
  • A variable with no default whose key the request does not carry makes that value match nothing. A positive operator does not match it and a negated operator does, with IfExists and the set qualifiers following the same rule, except that every Arn* operator is false against a context value that is not an ARN, whatever the value. Before this change the variable was left as literal text and compared.
  • 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 the IAM policy simulator reads it, whatever the variable holds or defaults to: Null holds when the key is present, and Bool holds when the request value is false (measured, rows N1a to N2e below). Other Bool and Null values compare as before. IAM Access Analyzer reports a variable in either operator as INVALID_VARIABLE_FOR_OPERATOR; the simulator evaluates it anyway.
  • In an Arn* operator, with or without a set qualifier or IfExists, a value that contains ${ and has fewer than five : written outside its ${...} references, such as ${aws:ResourceTag/blockedPrincipal}, fails the condition closed in a document of any Version: a Deny applies and an Allow does not grant. The ARN matcher splits a value into fields only at a : written in the policy, so such a value cannot have an ARN's six fields. The IAM policy simulator refuses a 2012-10-17 policy with such a value (InvalidInput) and IAM Access Analyzer reports MISSING_ARN_FIELD, so IAM gives no decision to match. IAM checks a value's fields whatever the Version, so a 2008-10-17 or no-Version document, which does not expand the variable, fails closed too; those documents were not sent to the simulator. A value with its five : written, such as arn:aws:iam::123456789012:user/${aws:username}, expands and matches as before in a 2012-10-17 document and is compared as written in the others.
  • A variable naming a multivalued key such as dynamodb:LeadingKeys has no value when the request carries the key, and a written default is used when it does not (from fix(auth): expand policy variables in Resource and NotResource #535).

evaluate_condition keeps its signature for its existing callers and expands as a 2012-10-17 document does; evaluate_condition_with and evaluate_statement_conditions take the document's choice, which the evaluator passes from each identity policy, boundary and session policy and the trust evaluator from the trust policy's Version.

Measurements

IAM policy simulator, 24 and 25 October 2026, action dynamodb:GetItem on arn:aws:dynamodb:us-east-1:123456789012:table/blue, Allow dynamodb:* on * beside Deny dynamodb:GetItem on * with the condition below. Keys are aws:PrincipalTag/team (team), aws:ResourceTag/owner (owner) and aws:SourceArn unless named.

Condition Version Context IAM
StringLike team a${*} 2012 team abc allowed
same 2012 team a* explicitDeny
StringLike team a* 2012 team abc explicitDeny
StringEquals team ${?} 2012 team ? explicitDeny
StringLike team a${?} 2012 team ab allowed
same 2012 team a? explicitDeny
StringEquals team ${$} 2012 team $ explicitDeny
StringEquals team a${$}{x} 2012 team a${x} explicitDeny
StringEquals team ${*} 2008 team * allowed
same 2008 team ${*} explicitDeny
StringEquals owner ${aws:username} none user alice, owner alice allowed
same none user alice, owner ${aws:username} explicitDeny
same 2008 user alice, owner ${aws:username} explicitDeny
ArnLike SourceArn user/a${*} 2012 SourceArn user/abc allowed
same 2012 SourceArn user/a* explicitDeny
StringEquals owner ${aws:username, 'anon'} 2012 no user, owner anon explicitDeny
same 2008 no user, owner anon allowed
StringEquals owner ${aws:username} 2012 no user, owner ${aws:username} allowed
StringNotEquals owner ${aws:username} 2012 no user, owner alice explicitDeny
StringLike owner ${aws:PrincipalTag/team} 2012 team a*, owner abc allowed
same 2012 team a*, owner a* explicitDeny
ArnLike or ArnEquals SourceArn user/${aws:PrincipalTag/name} 2012 name alice, SourceArn user/alice explicitDeny
same 2012 name al*, SourceArn user/alice allowed
StringEqualsIfExists owner ${aws:username} 2012 owner alice, no user allowed
StringNotEqualsIfExists same 2012 owner alice, no user explicitDeny
ForAllValues:StringNotEquals aws:TagKeys ${aws:username} 2012 TagKeys [a, b], no user explicitDeny
ForAnyValue:StringEquals same 2012 TagKeys [a, b], no user allowed
NumericEquals aws:MultiFactorAuthAge ${aws:PrincipalTag/age} 2012 age 300, MultiFactorAuthAge 300 allowed
Bool aws:SecureTransport ${aws:PrincipalTag/flag} 2012 flag true, SecureTransport true allowed

IAM policy simulator, 28 October 2026, two samples each, the same two policies except that N1f and A8 are an Allow dynamodb:GetItem with the condition and nothing else. Keys are aws:PrincipalTag/team (team), aws:PrincipalTag/flag (flag) and aws:SecureTransport.

Cell Condition Context IAM
N1a Null team ${aws:PrincipalTag/flag} team blue explicitDeny
N1b Null team ${aws:PrincipalTag/flag, 'false'} team blue explicitDeny
N1c Null team ${aws:PrincipalTag/flag} team blue, flag true explicitDeny
N1d same no team, flag true allowed
N1e same no team, no flag allowed
N1f Null team ${aws:PrincipalTag/flag, 'false'}, Allow alone team blue, no flag allowed
N2a Bool SecureTransport ${aws:PrincipalTag/flag} SecureTransport false, flag false explicitDeny
N2b same SecureTransport false, flag true explicitDeny
N2c same SecureTransport true, flag true allowed
N2d same SecureTransport true, flag false allowed
N2e same SecureTransport false, no flag explicitDeny
A7 ArnLike aws:PrincipalArn ${aws:ResourceTag/blockedPrincipal} tag equal to the caller's ARN InvalidInput
A8 ArnNotLike, same value, Allow alone same InvalidInput

IAM Access Analyzer ValidatePolicy on the same day: the ArnLike value is an ERROR finding MISSING_ARN_FIELD, and a variable in Null or Bool is an ERROR finding INVALID_VARIABLE_FOR_OPERATOR ("Policy variables can only be used with String and ARN operators.").

Trust policies, AssumeRole on fresh roles whose trust policy allows the caller's role under the condition on aws:PrincipalArn against ${aws:PrincipalArn}:

Condition Version AssumeRole
StringEquals 2012 succeeded
StringNotEquals 2012 refused
StringEquals 2008, or none refused
StringNotEquals 2008, or none succeeded

Testing

  • One evaluator-level test per simulator row above (the 28 October rows N1a to N2e as two table-driven tests) and three trust-policy tests, plus unit tests for the expansion and the token matching, and the boundary, session policy and explicit_deny_matches_arc paths. The simulator refuses rows A7 and A8, so condition_variables_arn.rs pins their policies as Deny (failed closed) under all four Arn* operators, under ArnNotLikeIfExists, ForAllValues:ArnNotLike and ForAnyValue:ArnLike, and in a 2008-10-17 document and one with no Version. Only the ArnLike Deny and the ArnNotLike Allow in 2012-10-17 documents were sent to the simulator; the ArnEquals, ArnNotEquals, qualified, 2008-10-17 and no-Version rows were not. The same file pins that a value with five written : still matches in a 2012-10-17 document and is compared as written in the other two. A test with aws:SecureTransport spelled TRUE under a Bool variable value pins that the request value is read ignoring case; it is not measured. A derived test pins that DateEquals and IpAddress values with a variable are not expanded; its IpAddress half drives parse_conditions and evaluate_statement_conditions, the trust policy path, because an identity policy with that value does not parse.
  • Each rule has a negative control that fails when the rule is reverted, counted on 662f85b3b with cargo test -p extenddb-auth --lib: the document's Version ignored by condition values (11 tests fail), escapes left as text (10), an escaped * or ? read as a wildcard (6), an unresolved variable left as text (8), every operator expanding (7), the Arn* fail-closed rule removed (3), the Arn* rule applied only when the document expands variables (1), the Null and Bool reading of a variable as false removed (4), a request value under a variable Bool value compared with true case-sensitively (1), and DateEquals or IpAddress added to the expanding operators (1 each). The trust policy's Version ignored fails 2 of the 3 tests in crates/server's trust_version_tests.rs, which cargo test -p extenddb-auth does not run.
  • cargo fmt --all --check and cargo clippy --locked --workspace --all-targets -- -D warnings on Rust 1.97.0, cargo check --locked --workspace on Rust 1.88.0, and cargo test --locked -p extenddb-auth --lib and --doc on 1.97.0: 406 unit tests and 3 doc tests passed; cargo test --locked -p extenddb-server --lib: 238 passed. Each of the eleven commits passes cargo test --locked -p extenddb-auth --lib. On a SQLite server and on a PostgreSQL server built from this head, the 72 tests of tests/test_tag_request_conditions.py, tests/test_tag_request_variable_conditions.py, tests/test_tag_request_repeated_keys.py, tests/test_tag_request_array_conditions.py and tests/test_authz_arn_conditions.py passed on each. CI runs the workspace tests.
  • CI passed all 30 checks on 662f85b3b.

Behaviour change for existing policies

  • 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 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.
  • ${*}, ${?} and ${$} now stand for the characters *, ? and $, so StringEquals with the value ${*} matches * where it matched the text ${*}. In StringLike, ArnLike and ArnEquals the escaped character matches only itself.
  • A * or ? that comes from a variable value is now a 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.
  • 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 no longer matches them and a negated operator 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.
  • A numeric or date value written as a variable is no longer expanded and matches no request value, so 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. An IP address value written as a variable still fails to parse outside trust policies (it is not a CIDR); in a trust policy it is no longer expanded and matches no request.
  • A Bool or Null value written as a variable is read as false instead of being replaced by the variable's value (rows N1a to N2e). A Deny with Null on aws:PrincipalTag/team and the value ${aws:PrincipalTag/flag} now applies to every caller with a team tag (N1a, N1c), and no longer applies to a caller without one whose flag is true (N1d). A Deny with Bool on aws:SecureTransport and the same value now applies to every request made without TLS (N2a, N2b, N2e) and no longer applies to a TLS request whose flag is true (N2c).
  • An Arn* value that contains ${ and has fewer than five written : now fails closed in a document of any Version. Before, the substituted ARN was compared as a whole, so a Deny with ArnLike on aws:PrincipalArn and the value ${aws:ResourceTag/blockedPrincipal} applied only to the principal the tag named; it now applies to every principal. The Allow written with ArnNotLike granted every other principal and now grants none. To compare a principal with an ARN stored whole in a tag, use StringEquals in a 2012-10-17 document.
  • The variable ${aws:TagKeys} in a condition value has no value, in identity and trust policies. Since fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522, main compares its text, and before fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522 a trust policy expanded it to one session tag key. A positive operator is now false against it and a negated one true, so a Deny with StringNotEquals on aws:PrincipalTag/env and that value now applies to every caller, and an Allow written that way grants every caller (IAM policy simulator, 31 October 2026).
  • A condition value naming a key the context reports unresolvable, in this build an aws:RequestTag/<name> that the request names under two spellings with different values, fails the condition closed only in a 2012-10-17 document under a string or ARN operator. Since fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522, main fails it closed under any operator in a document of any Version. In a 2008-10-17 or no-Version document, and under the numeric, date, address, Null and Bool operators, the value is compared as written, or read as false under Null and Bool.

Rebase on #535 and main

This branch is rebased onto the rebuilt #535 (57861f592), which is main at 1d0d8f4a2 with #535's commits on top. main now has #471, which changes the Arn* operators, and #522, which adds the tag condition keys. Both change code this branch changes, and in three places their rules and this branch's decide the same condition differently. The first two are resolved as IAM decides them, measured with the IAM policy simulator (SimulateCustomPolicy, read-only) on 31 October 2026: two samples per row, and both samples agreed on all 32 rows. Every policy in the two tables below is a 2012-10-17 Allow dynamodb:GetItem on * with the condition and nothing else. The third concerns a state the service does not have, so it follows this branch's rule for where a variable is expanded.

1. Arn* operators (#471)

#471 makes a context value that is not an ARN match none of the four Arn* operators, and evaluates ArnEquals as ArnLike. This branch makes a value whose variable has no value match nothing, so a negated operator holds against it. The two rules give different answers when such a value is compared with a context value that is not an ARN. The value is arn:aws:dynamodb:us-east-1:123456789012:table/${aws:PrincipalTag/team} on aws:SourceArn, and ARN x is arn:aws:dynamodb:us-east-1:123456789012:table/x.

Operator No team, SourceArn ARN x No team, SourceArn notanarn team=x, SourceArn ARN x team=x, SourceArn notanarn
ArnLike implicitDeny implicitDeny allowed implicitDeny
ArnEquals implicitDeny implicitDeny allowed implicitDeny
ArnNotLike allowed implicitDeny implicitDeny implicitDeny
ArnNotEquals allowed implicitDeny implicitDeny implicitDeny

So the non-ARN rule is checked first. compare_single returns false for every Arn* operator when the context value is not an ARN, and only after that gives a value with no value its answer: false for ArnLike and ArnEquals, true for ArnNotLike and ArnNotEquals. Otherwise ArnEquals and ArnLike call value.arn_matches(context_value) and the negated pair its negation, so ArnEquals now also matches a * or ? from a substituted value or an escape as that character. An Arn* value without its written ARN fields still fails the condition closed. condition_variables_arn_context.rs has one test per cell. With the value's answer given before the non-ARN check, the tests of the two notanarn cells with no team fail: ArnNotLike and ArnNotEquals allow there, where IAM does not. With this branch's earlier Arn* arms, which had no non-ARN check and compared ArnEquals exactly, 24 tests fail: 20 of #471's tests and the four negated notanarn cells.

2. ${aws:TagKeys} (#522)

#522 left a condition value that names a multivalued key as text, and two of its tests said that text equals itself. This branch gives such a variable no value whether or not the request carries the key, as IAM does: IAM gives it no value whether the key holds two values, one, or none, and does not compare the variable's text. The value is ${aws:TagKeys} on aws:PrincipalTag/env.

Operator TagKeys [a, b], env=a TagKeys [a, b], env=${aws:TagKeys} TagKeys [a], env=a No TagKeys, env=${aws:TagKeys}
StringEquals implicitDeny implicitDeny implicitDeny implicitDeny
StringLike implicitDeny implicitDeny implicitDeny implicitDeny
StringNotEquals allowed allowed allowed allowed
StringNotLike allowed allowed allowed allowed

policy_variable_naming_aws_tagkeys_is_left_literal in multivalued.rs is renamed policy_variable_naming_aws_tagkeys_has_no_value and checks these sixteen cells. trust_policy_variable_naming_aws_tagkeys_is_left_literal in request_tag_tests.rs is renamed trust_policy_variable_naming_aws_tagkeys_has_no_value; it expects StringEquals to be false and StringNotEquals true, including for a principal tag that holds the variable's text. Two other #522 tests passed unchanged but had "left literal" in their names; they are now policy_variable_naming_aws_tagkeys_has_no_value_for_every_tag_order and policy_variables_naming_the_dynamodb_multivalued_keys_have_no_value. #522's v0.1.15 upgrade entry said the variable stays literal text in identity and trust policies, and so did its row in docs/differences-from-dynamodb.md and the docstrings of tests/test_tag_request_variable_conditions.py. Each now says the variable has no value. The decisions of that wire file are unchanged, because no tag value in its requests equals the variable's text. With #522's rule put back, policy_variable_naming_aws_tagkeys_has_no_value and trust_policy_variable_naming_aws_tagkeys_has_no_value fail.

3. Keys the context reports unresolvable (#522 and #535)

#522, with #535's change for the default form, fails a condition closed when one of its values names a key the context reports unresolvable, through ${key} or ${key, 'default'}. In this build that key is aws:RequestTag/<name> when the request names the tag under two spellings with different values. This branch decides where a variable is expanded, so the check now runs only there: in a 2012-10-17 document and under a string or ARN operator. In a 2008-10-17 or no-Version document, and under the numeric, date, address, Null and Bool operators, the value is not expanded and reads no key. It is compared as written, or read as false under Null and Bool, and does not fail closed. A value that also names a key the request does not carry, with no default, fails closed too, although it matches nothing whatever the unresolvable key holds; a Resource pattern of that shape does not. This is an ExtendDB rule with no service measurement, because the service has one value for the key.

Earlier, this description and the expand_condition_value doc said such a value matches nothing and does not fail closed. The doc, ConditionValue::Unresolved, the variables.rs module doc, the FAIL_CLOSED_GLOBAL_KEYS comment and the case_three.rs module doc now state the rule above. #535's a_condition_value_naming_an_unresolvable_key_fails_closed_with_or_without_a_default keeps its name and decisions, so the last commit no longer renames it. Three tests are new: the same value in a 2008-10-17 document and in one with no Version is compared as written, and so is the value under NumericLessThan, each with and without a default; and under ArnLike and ArnNotLike the value fails the condition closed with and without a default, and is compared as written in a 2008-10-17 document. Removing the Version gate fails the 2008-10-17 test and the Arn* test, removing the operator gate fails the NumericLessThan test, and removing the check fails five tests, among them the default form and the Arn* test. Taking only the Arn* operators out of the check fails the Arn* test alone.

4. The text of a variable with no value (#535)

On main and on #535, a variable with no default whose key the request does not carry is left as its text, and the text is compared. #535 measured that IAM does not compare it (IAM policy simulator, 30 October 2026, two samples per row, both samples agreeing on all 80 rows): with aws:PrincipalTag/env set to the exact text ${aws:PrincipalTag/team} and no team tag, StringEquals is false. This branch gives such a variable no value, so StringEquals is false here too, and the case needs no separate issue.

Other changes in the rebase

Rust 1.97.0 on 662f85b3b: cargo fmt --all --check, cargo clippy --locked --workspace --all-targets -- -D warnings, and cargo test --locked -p extenddb-auth --lib and --doc: 406 unit tests and 3 doc tests passed; cargo test --locked -p extenddb-server --lib: 238 passed. Rust 1.88.0: cargo check --locked --workspace. On a SQLite server and on a PostgreSQL server built from 662f85b3b, the 62 tests of tests/test_tag_request_conditions.py, tests/test_tag_request_variable_conditions.py, tests/test_tag_request_repeated_keys.py and tests/test_tag_request_array_conditions.py passed on each backend, and so did the 10 of tests/test_authz_arn_conditions.py.

Built on #535

This branch was rebased onto #535's tip (57861f592). Beside the three resolutions above, the rebase made these changes:

  1. resolve returns Result<&str, Missing> on fix(auth): expand policy variables in Resource and NotResource #535. expand_condition_value maps both Missing::Absent and Missing::Unresolvable to ConditionValue::Unresolved, so a condition value naming a key the context cannot resolve matches nothing when it is compared; in a 2012-10-17 document under a string or ARN operator the condition fails closed before that comparison (section 3 above). The doc of ConditionValue::Unresolved names the three ways to reach it: an absent key with no default, a multivalued key the request carries, and a key the context cannot resolve.
  2. Two of fix(auth): expand policy variables in Resource and NotResource #535's tests in variable_values.rs changed. the_default_form_expands_in_a_null_condition was renamed a_null_value_written_with_a_default_reads_as_false; its decisions held, because Null reads the text ${aws:PrincipalTag/flag, 'false'} as false and does not use the default. In a_multivalued_key_in_a_condition_value_is_not_expanded_under_any_form the Null assertion now expects Deny: the text ${dynamodb:LeadingKeys} reads as false and team is present, the decision of row N1a. Its context carries dynamodb:LeadingKeys as true, so an expansion to the first value would read team as absent and let the request through. The comments of both give that reason.
  3. fix(auth): expand policy variables in Resource and NotResource #535 statements that described a condition value as compared as written were reworded. The doc on FAIL_CLOSED_GLOBAL_KEYS in context.rs and the module doc of case_three.rs now 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; a_condition_value_naming_an_unresolvable_key_fails_closed_with_or_without_a_default keeps its name and decisions. The module doc of variable_values.rs names the measured cells behind its Null cases. The backlog item "Policy-variable gaps in condition values (Policy variables in condition values: Version 2008-10-17 and the escapes #534)" was removed, and REQ-ABAC-006 in docs/design/01-requirements.md and the Policy Variables note and limit 4 of docs/design/05-component-auth.md no longer say that Policy variables in condition values: Version 2008-10-17 and the escapes #534 gaps remain.
  4. The upgrade manual keeps three v0.1.15 entries: fix(auth): expand policy variables in Resource and NotResource #535's for Resource and NotResource, this branch's for condition values, and fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522's for the tag condition keys. Two items of fix(auth): expand policy variables in Resource and NotResource #535's entry and one sentence of fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522's were corrected. Item 2 now says that in a 2012-10-17 document a value naming a multivalued key the request carries matches nothing, and that a written default is used only when the request does not carry the key. Item 3 now says that the default form expands only under the string and ARN operators in a 2012-10-17 document, that Null and Bool read it as false, and that the numeric and date operators compare it as written. fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522's entry now says that the variable ${aws:TagKeys} in a condition value has no value, in identity and trust policies, where it said the value stays literal text.
  5. The security model sentence "A multivalued key such as dynamodb:LeadingKeys is not expanded" gained fix(auth): expand policy variables in Resource and NotResource #535's rule: when the request carries the key the value matches nothing, and a written default is used only when the request does not carry it.

#535 checks Version on user, group and role policy, boundary and console writes, and the rebase does not extend that check to trust policies. A trust policy goes through validate_trust_policy, which accepts any Version, and on this branch the trust policy's Version decides whether its condition values expand. A trust policy stored with "2012-10-17 " or "2012-10-18" therefore compares its variables as text, with no error at write. Whether CreateRole refuses such a value was not measured.

Related behaviour

  • An Arn* value that contains ${ and has fewer than five : written outside its ${...} references is not refused at write. The IAM policy simulator refuses it and IAM Access Analyzer reports an error for it; whether PutUserPolicy and CreateRole refuse it was not measured, so the value is stored and its condition fails closed when evaluated, in a document of any Version.
  • Null and Bool values that are not variables compare as before: Null matches nothing for a value other than the exact true or false, and Bool compares the request value with the text. How IAM reads a text that is not a boolean, such as "True" in Null or "no" in Bool, was not measured.
  • A Null or Bool value written as a variable is read as false in a document of any Version. Rows N1a to N2e are 2012-10-17 documents. A 2008-10-17 or no-Version document gets the same reading, without a measurement, because IAM reads the text without expanding it whatever the Version.

Checklist

  • Tests added for every measured behaviour
  • cargo fmt, cargo clippy clean
  • Docs updated (docs/manuals/10-security-model.md, docs/manuals/07-upgrade-manual.md under v0.1.15)

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.
… 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.
…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.
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.
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/<name>` 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
… 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/<name> 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.
…osed

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/<name>, and in both contexts a key listed in
FAIL_CLOSED_GLOBAL_KEYS.
…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.
… 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.
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.
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.
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.
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.
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 ${.
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.
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.
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.
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.
@LeeroyHannigan
LeeroyHannigan force-pushed the fix/condition-policy-variables branch from 2f501a5 to 662f85b Compare October 31, 2026 17:54
Base automatically changed from fix/resource-policy-variables to main November 4, 2026 23:18
eliseong
eliseong previously approved these changes Nov 5, 2026

@eliseong eliseong left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LeeroyHannigan LGTM, other than needing a rebase.

This change makes a policy variable in a condition value follow IAM's rules. I confirmed each rule against live IAM.

  • A document with Version 2012-10-17 expands the variable. A document with Version 2008-10-17, and a document with no Version, compares the value as written text.
  • The escapes ${*}, ${?} and ${$} become the characters *, ? and $. In StringLike, ArnLike and ArnEquals this character stays literal. A * or ? that the policy writes stays a wildcard.
  • A GetItem against a running server gives the same decisions. A 2008-10-17 Deny on ${aws:username} does not apply to the caller's own table. The 2012-10-17 form of that Deny applies to it.

One separate point, not part of this change: the write path accepts a condition value with an unterminated ${, and the evaluator compares it as literal text. IAM refuses such a policy. This behaviour is present before this change. I filed it as #834, but if it's simple enoough to roll it in to this PR then we should do so.

crates/auth/src/policy/condition.rs: main passes the document's Version
into the condition functions as version_2012 and reads the default form
and a trimmed key name only in a 2012-10-17 document under a string or
ARN operator, while ${key} kept expanding everywhere. The branch expands
a value only in those places, returns a ConditionValue, matches a
substituted wildcard literally, fails Arn* values without written ARN
fields closed, and reads a Null or Bool variable as false. The merge
keeps main's signatures and names and applies the branch's rules: the
helper expands_variables replaces reads_iam_variable_syntax and gates
the whole expansion, and a value that is not expanded is compared as
written. Main's measured rows for the default form stay in its doc.

crates/auth/src/policy/variables.rs: main counts wildcards in UTF-16
code units, trims the code points up to U+0020 around a key name, and
added an iam_syntax switch under which ${key, 'default'} names the key
as written. The branch adds ConditionValue, arn_value_lacks_fields and
the expand switch on expand_condition_value. The merge keeps the UTF-16
tokens and the trimming and builds the branch's ConditionValue on them.
The iam_syntax switch is superseded: where it was false the branch does
not expand the value at all, so parts and variable_key_names take no
such argument.

crates/auth/src/policy/variables/tests.rs: main's expand_condition_value
tests compare a String and one test pins the iam_syntax false path. The
branch's tests compare a ConditionValue. The merge keeps the branch's
tests, drops the second argument of variable_key_names, and rewrites the
iam_syntax test as without_expansion_the_whole_value_is_literal_text.
Main's trimming and UTF-16 tests stay.

crates/auth/src/policy/evaluator.rs: both sides pass the document to the
condition helpers in the same way. The merge keeps main's text and says
that only a 2012-10-17 document expands the values.

crates/auth/src/policy/evaluator/tests.rs: main adds the modules
condition_variable_scope, empty_values and variable_spelling, the branch
the four condition_variables modules. The merge lists all seven.

crates/auth/src/policy/evaluator/tests/variable_values.rs: both sides
renamed the Null default-form test. The merge keeps both tests, main's
row N5 and the branch's cell N1f, and joins the module docs.

crates/auth/src/policy/evaluator/tests/case_three.rs,
crates/auth/src/policy/context.rs,
crates/auth/src/policy/condition/tests/three_case.rs and
crates/auth/src/policy/context/request_tag_tests.rs: main changed these
only through #535, whose commits this branch already carries, and the
branch's own commits changed them further. The merge keeps the branch's
versions.

crates/auth/src/policy/evaluator/tests/resource_variables_fields.rs:
the branch's own commits do not touch it and main adds a comment. The
merge keeps main's version.

crates/server/src/management/assume_role.rs: both sides pass the trust
policy's Version through trust_decision_for. The merge keeps main's
names, version_2012 and trust_policy_is_version_2012, says in the doc
that only such a policy expands its condition variables, and adds the
branch's trust_version_tests module, whose import takes main's name.

docs/backlog.md and docs/design/01-requirements.md: the branch's own
commits changed them and main did not beyond #535. The merge keeps the
branch's versions, which no longer list #534 as open.

docs/design/05-component-auth.md: main describes where the default form
is read; the branch describes where condition values expand at all. The
merge keeps the branch's paragraph.

docs/differences-from-dynamodb.md: main rewrote the backup ARN row; the
branch rewrote the ${aws:TagKeys} sentence of the tag condition keys
row. The merge keeps both.

docs/manuals/07-upgrade-manual.md: main added item 4 (trimmed key
names) and separators to the Resource entry; the branch corrected items
2 and 3 and added its own v0.1.15 condition values entry. The merge
keeps items 1 to 4 with the branch's wording of item 3 plus main's
sentences on IpAddress and trust policies, and places the branch's entry
between separators after the Resource entry.

docs/manuals/10-security-model.md: main added the trimming rule and the
operator list for the default form; the branch rewrote the paragraph on
condition values. The merge keeps the branch's paragraph with main's
trimming sentence and operator list.

Beyond the conflicts, the module doc and one comment of
crates/auth/src/policy/evaluator/tests/condition_variable_scope.rs now
describe the merged rule, since the file said that a Bool variable is
not read as false.
@eliseong
eliseong added this pull request to the merge queue Nov 5, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 5, 2026
@LeeroyHannigan
LeeroyHannigan added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 6ab74d5 Nov 5, 2026
45 checks passed
@LeeroyHannigan
LeeroyHannigan deleted the fix/condition-policy-variables branch November 5, 2026 17:45
LeeroyHannigan added a commit that referenced this pull request Nov 5, 2026
Brings in #541, which applies IAM's rules for policy variables in
condition values. No file conflicted. The auth and server crates pass
fmt, clippy and the auth library tests on the merged tree.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Policy variables in condition values: Version 2008-10-17 and the escapes

2 participants