Skip to content

fix(auth): expand policy variables in Resource and NotResource - #535

Merged
robinnsc merged 30 commits into
mainfrom
fix/resource-policy-variables
Nov 4, 2026
Merged

robinnsc merged 30 commits into
mainfrom
fix/resource-policy-variables

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #533.

Summary

A policy variable in a statement's Resource or NotResource was compared as literal text, so table/${aws:PrincipalTag/team} matched no table. A Deny written that way denied nothing and an Allow granted nothing. This change expands variables in Resource and NotResource patterns before matching, following IAM's rules as measured with the IAM policy simulator:

  • Variables expand only in a 2012-10-17 document. A 2008-10-17 document, or one with no Version, matches the pattern as written. PolicyDocument.version is now Option<String>, so an absent Version is no longer read as 2012-10-17; the evaluator is the field's only reader. Since the value now decides expansion, the user, group and role policy, permissions boundary and console writes refuse a Version that is not the string 2012-10-17 or 2008-10-17, such as "2012-10-18", "2012-10-17 " or the number 20121017. They already required the member. A stored document with another value keeps parsing and matches its patterns as written. An AssumeRole session policy is still checked only for the member.
  • Text written in the policy keeps * and ? as wildcards. Text that comes from a variable value, a default or an escape is matched literally, and a tag value of bl* matches only a table named bl*.
  • Only a : written in the policy separates ARN fields. A : inside a value is a character of its field: a region-field value of us-east-1:123456789012 matches no table ARN. Variables in the region and account fields expand, as two simulator rows show, although IAM's policy variables page says a variable cannot replace parts of the ARN before the fifth colon.
  • A variable whose key the request does not carry makes the pattern match nothing, so a Resource statement does not apply and a NotResource statement applies to every resource (both measured, for Allow and Deny). An unresolved variable before a wildcard does not widen the pattern to table/*. A tag whose value is the empty string is not unresolved: it expands to no characters, so table/${aws:PrincipalTag/team}* matches every table for a caller tagged team="", as IAM evaluates it, and a StringLike condition ?* on the tag refuses such a caller (both measured).
  • A variable naming a key the request context reports unresolvable fails closed in Resource and NotResource, as such a key does in a condition, whether it is the condition key or is read through a ${key} or ${key, 'default'} variable in one of the condition's values: when no pattern matches, a Deny applies and an Allow does not grant, and a default written beside the key is not used, for a multivalued key as for a single-valued one. In this build the context reports one such key, aws:RequestTag/<name> when the request names the tag under two spellings that fold to one name with different values (fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522), so a pattern naming that tag fails closed for such a request. FAIL_CLOSED_GLOBAL_KEYS, the list of global keys that fail closed, is empty.
  • A multivalued condition key such as dynamodb:LeadingKeys is never a variable, in Resource or in a condition value, with one value in the request or several. v0.1.14 used the first value in a condition value; main has left such a variable as written since fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522.
  • ${key, 'default'} expands to default when the key is absent, in Resource, and in a condition value of a 2012-10-17 document under a string or ARN operator. Elsewhere a condition value compares it as written, as main does; IAM reads the default form only there (measured). Code points up to U+0020 around the key name, around the comma and before the closing brace are ignored in both forms, as IAM does, and a space inside the quotes of a default is kept. ${*}, ${?} and ${$} stand for a literal character in Resource, with or without spaces inside the braces.
  • * and ? count UTF-16 code units in a pattern with a variable, as arn_match does since fix(auth): count policy wildcards in UTF-16 code units, as IAM does #581.

Patterns without ${ take the same arn_match path as before. The expansion lives in crates/auth/src/policy/variables.rs; resource_matches gains the document and the request context at its five call sites, including the permissions boundary and session policy checks and explicit_deny_matches_arc. In condition.rs, expand_policy_variables delegates to the new module, and #522's fail-closed check takes its variable names from it. evaluate_statement_conditions and evaluate_condition_match take whether the statement's document has Version 2012-10-17: the evaluator passes each document's, and the AssumeRole trust path the trust policy's. The delegation conflicted with #522's rewrite of that function (see Rebase on #522); the lines #471 changed are not touched.

Measurements

aws iam simulate-custom-policy, 2026-10-24, action dynamodb:GetItem, Allow dynamodb:* on * beside the statement under test unless the row says otherwise. ARNs are arn:aws:dynamodb:us-east-1:123456789012:table/....

Statement Context Resource IAM
Deny table/${aws:PrincipalTag/team} team=blue table/blue explicitDeny
same team=blue table/red allowed
same no team table/blue allowed
Deny table/${aws:PrincipalTag/team}* team=blue table/blue-x explicitDeny
same no team table/blue allowed
Allow only table/${aws:username} aws:username=alice table/alice allowed
same aws:username=alice table/bob implicitDeny
Deny table/${aws:PrincipalTag/team}, Version 2008-10-17 team=blue table/blue allowed
same, no Version team=blue table/blue allowed
Deny NotResource table/${aws:PrincipalTag/team} team=blue table/blue allowed
same team=blue table/red explicitDeny
same no team table/blue explicitDeny
Allow only NotResource table/${aws:PrincipalTag/team} no team table/blue, table/red allowed
Deny table/${aws:PrincipalTag/team, 'none'} no team table/none explicitDeny
Deny table/${aws:PrincipalTag/team,'none'} no team table/none explicitDeny
Deny table/${*} none table/x allowed
Deny table/${aws:PrincipalTag/team} team=bl* table/blue allowed
same team=bl* table/bl* explicitDeny
same team=blue:x table/blue:x explicitDeny
Deny table/b?${aws:PrincipalTag/team} team=ue table/blue explicitDeny
Deny table/${dynamodb:LeadingKeys} LeadingKeys=[blue, red] or [blue] table/blue, table/red allowed
Allow only table/${dynamodb:LeadingKeys} LeadingKeys=[blue, red] table/blue implicitDeny
Deny on * if StringEquals aws:PrincipalTag/team: ${dynamodb:LeadingKeys} team=blue, LeadingKeys=[blue, red] or [blue] table/x allowed
Deny arn:aws:dynamodb:${aws:RequestedRegion}:123456789012:table/blue RequestedRegion=us-east-1 table/blue explicitDeny
Deny arn:aws:dynamodb:us-east-1:${aws:PrincipalAccount}:table/blue PrincipalAccount=123456789012 table/blue explicitDeny
Deny arn:aws:dynamodb:${aws:PrincipalTag/region}:*:table/T region=us-east-1:123456789012 table/T, table/A/stream/x:table/T allowed

aws iam simulate-custom-policy, 2026-11-03, two samples per row with the same answer, action dynamodb:GetItem, Version 2012-10-17 unless the row says otherwise, and Allow dynamodb:* on * in the same document beside each Deny. team is aws:PrincipalTag/team.

Statement Context Resource IAM
Allow only table/${team}* team="" table/red, table/blue allowed
same no team table/red implicitDeny
Deny table/${team}* team="" table/red explicitDeny
Deny NotResource table/${team}* team="" table/red allowed
Allow only *${aws:PrincipalTag/suffix} suffix="" table/red allowed
Allow only table/${team}x, or table/x${team} team="" table/x allowed
Allow only table/${team} team="" table/x implicitDeny
Allow only table/${team}* if StringLike team: "?*" team="" table/red implicitDeny
same team=blue table/blue-x allowed
Deny table/${ team }, table/${team } or table/${ team} team=blue table/blue explicitDeny
Deny table/${ team , 'none'} or table/${team ,'none'} team=blue table/blue explicitDeny
Deny table/${ team , 'none'}, table/${team, 'none'} or table/${team, 'none' } no team table/none explicitDeny
Deny table/${team, ' none '} no team table/ none explicitDeny
same no team table/none allowed
Deny with a tab, U+000B, U+0001, U+000C, U+000D or U+001F on both sides of the key, or a newline before it team=blue table/blue explicitDeny
Deny with U+00A0 before the key, or U+0085 or U+3000 on both sides team=blue table/blue allowed
Deny table/${ * } none table/* explicitDeny
same none table/x allowed
Deny table/a${ $ } none table/a$ explicitDeny
Deny on * if StringEquals aws:ResourceTag/owner: "${ aws:username }" alice, owner=alice table/x explicitDeny
2008-10-17, Deny on * if StringNotEquals team: "${aws:username, 'x'}" alice, team=alice table/x explicitDeny
2008-10-17, Allow only on * if StringEquals team: "${aws:username, 'x'}" alice, team=alice table/x implicitDeny
same, no Version same table/x implicitDeny
same, 2012-10-17 same table/x allowed
same, 2008-10-17 team holds ${aws:username, 'x'} table/x allowed
Allow only on * if NumericEquals aws:MultiFactorAuthAge: "${aws:PrincipalTag/m, '300'}" age 300, no m table/x implicitDeny
Deny, same condition same table/x allowed
Deny if DateGreaterThan aws:CurrentTime: "${aws:PrincipalTag/d, '2000-01-01T00:00:00Z'}" no d table/x allowed
Deny if Bool aws:SecureTransport: "${aws:PrincipalTag/f, 'true'}" true, no f table/x allowed
same false, no f table/x explicitDeny
Deny if Null team: "${aws:PrincipalTag/f, 'true'}" no team, no f table/x allowed
Deny if IpAddress aws:SourceIp: "${aws:PrincipalTag/c, '10.0.0.0/8'}" 10.1.1.1, no c table/x allowed
Deny if StringEquals aws:ResourceTag/owner: "${aws:username, 'anon'}" no user name, owner=anon table/x explicitDeny
Deny if ArnLike aws:SourceArn: "arn:aws:iam::123456789012:user/${aws:PrincipalTag/n, 'alice'}" user/alice, no n table/x explicitDeny

Testing

  • On a6826763b, after the merge with main: one evaluator test per 2026-11-03 row except two, in variable_spelling.rs (spaces, control characters and escapes), empty_values.rs, and condition_variable_scope.rs (the Version and operator rows, the group, boundary and session policy paths, and a defaulted variable naming an unresolvable key), and trust_policy_reads_the_default_form_only_with_version_2012 in assume_role.rs. The IpAddress row has no test because ExtendDB refuses a document whose IpAddress value is not a CIDR, and the Bool row with SecureTransport=false none because ExtendDB compares that value as written (see Related behaviour). The UTF-16 tests in variables/tests.rs run the Resource rows of matcher/utf16_tests.rs on the token path. Negative controls: the byte comparison put back (3 tests fail), the trimming of 57861f592 put back, str::trim on the default form only (7), the Version ignored in condition values (6), the operator ignored (5), and every trust policy read as 2012-10-17 (1).
  • tests/test_resource_policy_variables.py (26 tests, ExtendDB only, since it provisions IAM users through the management API): a user tagged team=blue under Deny Resource and Deny NotResource on table/${aws:PrincipalTag/team} reads blue and red, and each refused Version is sent through the user, group and role policy writes, the user and role boundary writes and the console's policy forms.
  • One evaluator-level test per row of the first table, and evaluator tests that were not measured: the ${?} and ${$} escapes, the 2008-10-17 and no-Version documents for NotResource, the default form in a condition value, and the boundary, session policy and explicit_deny_matches_arc paths. Further evaluator tests pin rules that follow from the measured ones: a substituted value holding ${ is not expanded again, a * or ? in a default is literal, and the default form and the multivalued rule hold under ForAnyValue, ForAllValues, IfExists and Null. The headers of the evaluator test files for policy variables say which expectations repeat a simulator case and which are derived, and the header of case_three.rs says its expectations are ExtendDB rules with no service measurement. Unit tests cover the parser, the token matcher and the Version write check, whose message is ExtendDB's own.
  • Each rule has a negative control that fails when the rule is reverted, counted on 57861f592: expansion off (22 tests fail: 11 in resource_variables.rs, 6 in resource_variables_fields.rs, 2 in variable_values.rs, 3 in case_three.rs), the version check removed (7), the default form removed (16), ${*} read as a wildcard (2), the multivalued check removed (10), a value's : read as a field separator (1), a key the context cannot resolve read as absent (3), the Version write check removed (1), the Version gate removed from the fail-closed rule for Resource (1), a multivalued key expanded to its first value under Null only (1), the unresolvable check placed after the multivalued one (1), the fail-closed check reading only the first value of a condition (1), and an unresolvable variable deciding a pattern before an absent one written after it (1). Using the default for such a key in a condition value now fails no test, because the condition fails closed before its values are compared.
  • Evaluator tests in case_three.rs cover a key the service populates but ExtendDB cannot resolve; their expectations are an ExtendDB rule with no service measurement. A condition value naming such a key, as ${aws:PrincipalOrgID} or ${aws:PrincipalOrgID, 'blue'}, fails the condition closed and its default is not used, while the defaulted value takes the default when the key is absent and the key's value when it is present. A 2008-10-17 or no-Version document whose Resource names such a key matches the pattern as written and does not fail closed. A multivalued key the context reports unresolvable, dynamodb:LeadingKeys in the test context, fails closed in Resource and NotResource like any other, and its default is not used.
  • Two evaluator tests in resource_variables_fields.rs pin the multivalued-key default rule above on dynamodb:LeadingKeys (present with two values, present with one, absent; in Resource and in a condition value). Control: the previous rule (the default used whenever the key is multivalued) fails exactly these two tests.
  • Rust 1.97.0 on a6826763b: cargo fmt --all --check, cargo clippy --locked -p extenddb-auth -p extenddb-server --all-targets -- -D warnings and cargo check --locked --workspace --all-targets. Rust 1.88.0: cargo test --locked -p extenddb-auth (371 unit tests and 3 doc tests) and cargo test --locked -p extenddb-server --lib (253). On a SQLite server built from a6826763b, test_resource_policy_variables.py (26 tests) passed, and so did test_tag_request_conditions.py (32), test_tag_request_variable_conditions.py (8), test_tag_request_repeated_keys.py (2), test_tag_request_array_conditions.py (20), test_resource_tag_migration.py (2), test_abac.py (69), test_auth_integration.py (82), test_authz_resource_arn.py (43) and test_fgac_leading_keys.py (31).
  • Rust 1.97.0 on 57861f592: cargo fmt --all --check, cargo clippy --locked --workspace --all-targets -- -D warnings, cargo check --locked --workspace --all-targets, and cargo test --locked -p extenddb-auth: 341 unit tests and 3 doc tests passed. Rust 1.88.0: cargo check --locked --workspace. cargo test --locked -p extenddb-auth --lib also passes at each of the first five commits (291, 311, 311, 311 and 320 tests). On a SQLite server built from 57861f592, test_tag_request_conditions.py (32 tests), test_tag_request_variable_conditions.py (8), test_tag_request_repeated_keys.py (2), test_tag_request_array_conditions.py (20), test_resource_tag_migration.py (2), test_abac.py (69), test_auth_integration.py (82), test_authz_resource_arn.py (43) and test_fgac_leading_keys.py (31) passed, as did the console test for a user policy the service refuses. Against the same server, each refused Version sent through the user, group and role policy, boundary and console writes was refused, and a GetItem by a user tagged team=blue was denied on blue and allowed on red under Deny Resource table/${aws:PrincipalTag/team}, and the reverse under Deny NotResource. CI runs the workspace tests.
  • CI passed all 35 checks on 57861f592 after one rerun of the MongoDB jobs. The first run failed tests/python/test_rfc0003_concurrency.py::TestRfc0003UnconditionalPutOnHotKey::test_all_writers_succeed, which is MongoDB: fifty concurrent unconditional PutItem calls on one key occasionally answer InternalServerError (test_all_writers_succeed, three CI runs) #577.

Behaviour change for existing policies

  • A Resource or NotResource pattern with a variable in a 2012-10-17 document now matches the expanded value. A stored policy that relied on the literal text matching nothing now applies.
  • A condition value naming a multivalued key such as dynamodb:LeadingKeys is compared as written instead of being expanded to the key's first value, as on main since fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522. Against v0.1.14, under a positive operator such as StringEquals the condition no longer matches, and under a negated operator such as StringNotEquals it now matches, so an Allow with such a negated condition can now grant and a Deny with one can now apply.
  • In a 2012-10-17 document, a condition value written as ${key, 'default'} under a string or ARN operator now expands to the request's value for key, or to default when the key is absent, where the whole text was compared before. In a 2008-10-17 document, in one with no Version, and under the numeric, date, Bool and Null operators it is still compared as written (a policy or boundary write refuses an IpAddress value written as a variable as a malformed CIDR). A role trust policy follows its own Version.
  • Code points up to U+0020 around a variable's key name are ignored, in Resource and NotResource and in the condition values above. An Allow with StringEquals aws:ResourceTag/owner: "${ aws:username }" now grants alice a table tagged owner=alice; before, the spaces were part of the key name, the value was compared as written and the Allow granted nothing.
  • A variable naming a multivalued key such as dynamodb:LeadingKeys or aws:TagKeys with a default (${aws:TagKeys, 'x'}) uses the default only when the request does not carry the key. When the request carries it, with one value or several, a Resource pattern matches nothing and a condition value is compared as written. Measured with the IAM policy simulator on 2026-10-28: aws:TagKeys as [x, y], [x] and absent against table/${aws:TagKeys, 'x'} in Resource answer implicitDeny, implicitDeny, allowed, and against ${aws:TagKeys, 'x'} as a StringEquals value on aws:PrincipalTag/team = x the same three answers.
  • A policy write whose Version is not the string 2012-10-17 or 2008-10-17 is refused with Invalid policy document: policy parse error: invalid Version (must be the string 2012-10-17 or 2008-10-17): <value>, and a permissions boundary write with Invalid permissions boundary document: policy parse error: invalid Version .... Before, the document was stored and its Resource variables matched as literal text. A document stored with such a value keeps evaluating as before, but sending it again unchanged is now refused, and its Resource variables match as written until its Version is changed to 2012-10-17. IAM refuses such a document: Access Analyzer reports INVALID_VERSION ("The version 2012-10-18 is not valid. Use one of the following versions: 2008-10-17, 2012-10-17.") and the policy simulator answers InvalidInput for it (measured 2026-10-28, two samples each for 2012-10-18 and 2008-10-18); the message text is ExtendDB's own.

Related behaviour

Two gaps in condition values remain, both present on main before this change and filed as #534: ${key} in a condition value of 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 *. The ${key, 'default'} form has neither gap, because it is read only in a 2012-10-17 document. ${key} also still expands under the numeric, date, Bool and Null operators, where IAM does not substitute variables, and IAM reads a Bool or Null value written as a variable as false where ExtendDB compares the text; #541 covers both.

An AssumeRole session policy is checked only for its Version and Statement members and is parsed when a request uses the session, so it gets neither the Version value check nor the Sid and CIDR write checks.

Merge with main

The branch merges main at 5ff323beb (merge commit 41b48857c). The merge conflicted only in documentation. It brought #581, after which matcher::wildcard_match and arn_match count UTF-16 code units while this branch's token matcher compared bytes; 5a1f13f83 makes the token path count UTF-16 code units too.

Rebase on #522

This branch is rebuilt on main at 1d0d8f4a2, which includes #522. One block conflicted: #522 had rewritten the body of expand_policy_variables in condition.rs, and this branch makes the function delegate to variables::expand_condition_value. The delegation is kept. #522's check, which fails a condition closed when one of its values names an unresolvable key through a ${key} variable, runs before the expansion, and the conflict resolution left it unchanged; the first commit below then extends it to the ${key, 'default'} form.

In this build the context reports one key as unresolvable: aws:RequestTag/<name> when the request names the tag under two spellings that fold to one name with different values. FAIL_CLOSED_GLOBAL_KEYS is still empty.

Two commits on top follow from #522's rule:

  • fix(auth): fail closed on a defaulted variable naming an unresolvable key. fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522's 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, and the value was compared as written. With a key the context reports unresolvable, such as an ambiguous env tag, a StringEquals Deny with that value did not apply and a StringNotEquals Allow granted. The check now reads the name the way the expansion does, without the default, so both forms fail closed: a Deny applies and an Allow does not grant, whatever the operator, and the default is not used. A key the request does not carry still takes the default, and a present key uses its own value.
  • docs(auth): say a condition value naming an unresolvable key fails closed. The FAIL_CLOSED_GLOBAL_KEYS comment said such a condition does not fail closed. Three comments in context.rs said an unresolved global key such as aws:PrincipalOrgID or aws:SourceIp fails closed, but with the list empty such a key keeps absent-key semantics. Each now says which keys the context reports unresolvable: an ambiguous aws:RequestTag/<name> in the request context, and a key listed in FAIL_CLOSED_GLOBAL_KEYS in both contexts.

The case_three.rs test a_condition_value_naming_an_unresolvable_key_is_compared_as_written is renamed a_condition_value_naming_an_unresolvable_key_fails_closed_with_or_without_a_default. It expects the Deny to apply under StringEquals and StringNotEquals and an Allow gated the same way not to grant under either operator. New tests: variable_key_names_strip_the_default_and_skip_escapes, variable_naming_unresolvable_key_is_unresolvable_with_or_without_default (three operators, four spellings, and the default form as the second of two values) and ambiguous_request_tag_as_a_defaulted_policy_variable_fails_the_statement_closed. With condition.rs as it was before these commits, variable_naming_unresolvable_key_is_unresolvable_with_or_without_default, ambiguous_request_tag_as_a_defaulted_policy_variable_fails_the_statement_closed and the renamed case_three.rs test fail, and variable_key_names_strip_the_default_and_skip_escapes passes, because variable_key_names lives in variables.rs. With variable_key_names reading the whole text between the braces as the name, as main does, all four fail. #522's test_ambiguous_request_tag_as_a_policy_variable_is_refused is unchanged and passes.

These expectations are an ExtendDB rule with no service measurement: the service has one value for the tag, so there is no service case to compare with. For a key the request does not carry, the IAM policy simulator (2026-10-30, 80 rows, two samples each) substitutes the default, as this branch does. Without a default it gives the variable no value: a positive operator is false and a negated operator true, and a compared value equal to the variable's text does not match it. main compares that text as written, so the two differ only when the compared value contains the variable's text: equal to it, matched by a pattern written around it, or equal ignoring case. This branch keeps main's handling of that case. No issue is filed for it, because #541, stacked on this PR, makes such a variable match nothing.

Checklist

  • Tests added for every measured behaviour that ExtendDB can evaluate, and wire tests in tests/test_resource_policy_variables.py
  • cargo fmt, cargo clippy clean
  • Docs updated (docs/manuals/10-security-model.md, docs/manuals/07-upgrade-manual.md under v0.1.15, docs/design/01-requirements.md, docs/design/05-component-auth.md, docs/design/05-component-auth-v3.md, docs/backlog.md)
  • Policy write paths change only to refuse a Version other than 2012-10-17 or 2008-10-17

@lanami-amz lanami-amz added this to the 1.0 milestone Oct 27, 2026
LeeroyHannigan added a commit that referenced this pull request Oct 28, 2026
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.
LeeroyHannigan added a commit that referenced this pull request Oct 28, 2026
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.
LeeroyHannigan added a commit that referenced this pull request Oct 28, 2026
A condition value now expands only in a 2012-10-17 document and only
under the string and ARN operators, Null and Bool read a variable value
as false, and a variable naming a key the context cannot resolve makes
the value match nothing, as an absent key with no default does. Two
tests and several docs from #535 described the earlier comparison.

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. In case_three.rs the test for an
unresolvable key in a condition value is renamed to say the value
matches nothing; its decisions are unchanged.

The FAIL_CLOSED_GLOBAL_KEYS doc and the case_three.rs module doc say
that a value naming such a key matches nothing. The variable_values.rs
module doc says that Null expands neither form and that the default
form was measured under StringEquals. 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.
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.
LeeroyHannigan added a commit that referenced this pull request Oct 31, 2026
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.
@eliseong eliseong assigned eliseong and unassigned eliseong Nov 2, 2026
@robinnsc robinnsc self-assigned this Nov 2, 2026

@robinnsc robinnsc 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.

We used three separate reviewers on this one (authorization bypasses, compatibility and cost, and a verifier that re-ran every finding against the base and against a build of main with this branch merged). The measured rows reproduce on the head SQLite build: the Resource and NotResource cases, the absent-key and unresolvable-key rows, boundaries, group and session policies, per-item resources in BatchGetItem and TransactGetItems, index and stream ARNs, and the Version refusal on every write path the description lists. A 0.1.14 catalog's policies, odd Version values included, still load and evaluate after migrate --yes on both head and main, the auth tests pass (341 on head, 343 merged with main), the nine auth and tag Python files pass on both, and a 100-key BatchGetItem under 50 statements shows no measurable cost with or without variables. Three of the negative controls reproduce with mutants.

The first inline comment is the one I'd hold this for: the merge with main is clean apart from the docs, but it brings back the byte-counted ? that #581 removed, on the token path only. The next two are worth settling before merge too: the default form now expands in condition values of non-2012 documents where the base compared it as written, and an empty tag value before a wildcard widens a pattern to the whole prefix, which both reviewers found independently and which I think needs a simulator row.

The PR changes no file under tests/, and no wire test puts a variable in a Resource or NotResource or sends a
refused Version; the end-to-end checks in the description were manual. I repeated them and they hold, but a later
change to how the server builds the request context for the resource check, or to the console's validation, would
pass CI. I think one dual-target test (an IAM user tagged team=blue under Deny Resource and Deny NotResource
on table/${aws:PrincipalTag/team}, reading blue and red) and one ExtendDB-only test for the Version refusal
on the API and console paths would be enough.

resource_variables_fields.rs:133 pins that variables in the region and account fields expand, from two simulator
rows, while IAM's variables page says a variable cannot replace parts of the ARN before the fifth colon. It changes
no decision here, since values are literal and the server has one region and one resource account, but it might be
worth saying in that comment that the measurement contradicts the page, and that the service field follows the same
code without a measurement, so nobody later "fixes" one to match the other.

Writes now refuse an odd Version, which matches IAM, and that includes an unchanged re-PUT of a document 0.1.14
stored with "2012-10-18", "2012-10-17 " or 20121017 (I seeded those with the base binary and re-sent them to
head: 400 for the policy and the boundary). The upgrade entry could say how to find such documents before
upgrading, and that a stored Deny with a Resource variable in one of them keeps denying nothing until its
Version is changed to 2012-10-17.

Comment thread crates/auth/src/policy/variables.rs Outdated
fields
}

/// Greedy wildcard match over tokens, the same algorithm as

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.

This says the token matcher is the same algorithm as matcher::wildcard_match, which is true on this PR's base, but
main has since merged #581 and wildcard_match/arn_match now count UTF-16 code units while match_tokens still
compares bytes at 369-371. The merge with main is textually clean apart from the docs, so nothing will flag it: on a
build of main with this branch merged, a Deny on table/?zz applies to ézz and 日zz while the same Deny
written as table/?zz${aws:PrincipalTag/none, ''} does not (authorized, then ResourceNotFoundException). For
Resource alone that only changes the error code on names the service refuses anyway, since table names are ASCII,
but it makes #581's upgrade entry ("The rule applies to Action, NotAction, Resource and NotResource") untrue
for patterns with a variable, and #541 reuses match_tokens for StringLike values on dynamodb:LeadingKeys,
where #581 tells operators to review ? against caller data. I think the rebase onto main is the right place to
make the literal token a UTF-16 unit (encode the written text and the substituted values with encode_utf16) and
to port one case from matcher/utf16_tests.rs to the token path; every measured row here is ASCII, so none of them
changes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5a1f13f. The tokens now hold UTF-16 code units: the written text and each substituted value go through encode_utf16, and each ARN field of the request is encoded the same way before match_tokens compares it.

The new tests in variables/tests.rs run the Resource rows of matcher/utf16_tests.rs (? against é, 日 and 😀, and the *??bc backtracking rows) with an empty variable appended, so the pattern takes the token path, and check that the answer equals the one arn_match gives for the same pattern without the variable. table/?zz against ézz and 日zz is among them, and a third test puts a ? after a substituted 日. With the byte comparison put back, the three tests fail. A Deny on table/?zz${aws:PrincipalTag/none, ''} now applies to ézz and 日zz as the one on table/?zz does, so #581's upgrade entry holds for patterns with a variable.

Comment thread crates/auth/src/policy/condition.rs Outdated
}
result.push_str(rest);
result
super::variables::expand_condition_value(value, context)

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.

The condition path does not get the document's Version, so the new ${key, 'default'} form now expands in a
2008-10-17 document, and in a trust policy whatever its Version, where the base compared the whole text as
written, which is what IAM's page says for a document without 2012-10-17. On the head binary a 2008-10-17 Deny
with StringNotEquals aws:PrincipalTag/team: "${aws:username, 'x'}" no longer applies to a user tagged with their
own name (base: explicit deny), the Allow with StringEquals starts granting (base: implicit deny), and the same
holds for a group policy, a boundary, a session policy and a no-Version trust policy (AssumeRole 201 vs 403 on
base); the ${aws:username} control gives the same answer on both binaries. So the description's "present on main
before this change" is true for ${key} but not for the default form. The same delegation also expands the default
form under NumericEquals (an Allow on "${aws:PrincipalTag/m, '5'}" grants with no m tag, base denies), which
IAM's page rules out. #541 already threads the flag through (68ea9bb, and b293373 for the trust path in
assume_role.rs), so it might be simplest to ship the two together; if #535 can release without #541, I think it is
worth gating just the default form here, including reads_unresolvable_variable, and adjusting item 3 at
07-upgrade-manual.md:350 and the "Related behaviour" sentence. None of the measured rows or evaluator tests has a
condition variable in a non-2012 document, so either version of the gate leaves them as they are.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 86cd184 by gating the default form in this PR, so it does not depend on #541. I measured the rule with the IAM policy simulator on 2026-11-03, two samples per row:

  • In a 2008-10-17 document, a Deny with StringNotEquals aws:PrincipalTag/team: "${aws:username, 'x'}" applies to alice tagged team=alice. An Allow with StringEquals and the same value gives implicitDeny, and it grants when the tag holds the text as written. With no Version the Allow also gives implicitDeny, and the 2012-10-17 control grants.
  • In a 2012-10-17 document a default takes no part under NumericEquals: an Allow like yours, on NumericEquals aws:MultiFactorAuthAge: "${aws:PrincipalTag/m, '300'}" with no m tag, gives implicitDeny, and the same Deny does not apply. The same holds under DateGreaterThan, Bool, Null and IpAddress. Under StringEquals and ArnLike the default is used.

evaluate_statement_conditions and evaluate_condition_match now take whether the statement's document has Version 2012-10-17. The identity, group, boundary and session policy paths pass their own document's, and the trust policy path its own (trust_policy_is_version_2012). For such a document and a string or ARN operator, with or without a set qualifier or IfExists, the text between the braces is read with the default form and the trimmed key. Elsewhere it is the key name as written, as on main, so ${key, 'default'} is compared as written and ${key} expands as before. reads_unresolvable_variable reads the names the same way, so a defaulted variable naming an unresolvable key fails closed only where the default form is read.

The rows are tests in evaluator/tests/condition_variable_scope.rs, which also covers the group, boundary and session policy paths and the unresolvable key. trust_policy_reads_the_default_form_only_with_version_2012 in assume_role.rs covers a no-Version and a 2008-10-17 trust policy with the Allow and the Deny. Ignoring the Version fails 6 tests, ignoring the operator fails 5, and treating every trust policy as 2012-10-17 fails the trust test.

Two rows have no test. ExtendDB refuses a document whose IpAddress value is not a CIDR, so that row never reaches evaluation. IAM reads a Bool value written as a variable as false: the Deny on Bool aws:SecureTransport: "${aws:PrincipalTag/f, 'true'}" applies when SecureTransport is false. ExtendDB does not do that for either form, and #541 covers the Bool and Null reading and ${key} outside a 2012-10-17 document. Item 3 of the upgrade entry and the security model now give the scope (ada402c), and I rewrote the "Related behaviour" section of the description.

Comment thread crates/auth/src/policy/variables.rs Outdated
Part::Ref(_, Reference::Escape(byte)) => tokens.push(Token::Byte(byte)),
Part::Ref(_, Reference::Key { name, default }) => {
match resolve(name, default, context) {
Ok(value) => tokens.extend(value.bytes().map(Token::Byte)),

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.

A present tag whose value is the empty string adds no tokens here, so table/${aws:PrincipalTag/team}* becomes
table/*. On the head binary a user tagged team="" under Allow table/${aws:PrincipalTag/team}* reads blue,
red and zzz, Allow *${aws:PrincipalTag/suffix} grants everything, and Deny NotResource table/${aws:PrincipalTag/team}* spares every table; the base denies all of these, and with no team tag head
denies them too, so the description's "an unresolved variable before a wildcard does not widen the pattern" holds
only for an absent key. A role session gets there through its own session tag where the trust policy allows
sts:TagSession, and the session tag overrides a role tag team=blue. IAM allows empty tag values (an omitted
value is the empty string, so a key-only marker tag is this case), and its page
says only that a key with "no value" makes the variable null, without saying whether "" counts, so I could not
settle IAM's answer from the docs. Treating "" as absent would make Deny Resource table/${team}* deny nothing,
so it is not an obvious fix either. Could you add one simulator row (team="" against an Allow-only
table/${aws:PrincipalTag/team}*, target table/red) and pin whichever answer IAM gives? If it is substitution, a
sentence in 10-security-model.md naming the case and the usual guard (StringLike aws:PrincipalTag/team: "?*") might
be worth having, since the suffix-wildcard form is the shape AWS's own ABAC examples use.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

IAM substitutes the empty string, as this branch does, so the code is unchanged. The simulator rows (2026-11-03, two samples each), with team="":

  • Allow-only table/${aws:PrincipalTag/team}* grants red and blue.
  • A Deny on it denies red, and a Deny NotResource on it spares red.
  • Allow-only *${aws:PrincipalTag/suffix} with suffix="" grants red.
  • table/${team}x and x${team} match table/x, x${team}y matches table/xy, and table/${team} does not match table/x.

With no team tag the same Allow gives implicitDeny. 7024319 pins these rows in evaluator/tests/empty_values.rs.

StringLike aws:PrincipalTag/team: "?*" on the Allow gives implicitDeny for team="" and grants team=blue on blue-x, so the security model now names the case and that guard (ada402c). The description's sentence about an unresolved variable before a wildcard now says that it holds for an absent key and not for an empty value.

Comment thread crates/auth/src/policy/variables.rs Outdated
let default = default.trim();
if default.len() >= 2 && default.starts_with('\'') && default.ends_with('\'') {
return Reference::Key {
name: name.trim(),

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.

The default form trims the key name and the plain form at 128 does not, so ${ aws:PrincipalTag/team , 'none'}
reads the team tag while ${ aws:PrincipalTag/team } reads a key that never resolves; on the head binary a Deny table/${ aws:PrincipalTag/team } denies nothing for team=blue while the defaulted spelling denies blue. IAM's
handling of spaces inside the braces is not documented, so I would lean towards treating both forms alike and
pinning it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in a9d4362. IAM trims both forms (simulator, 2026-11-03, two samples per row). ${ team }, ${team } and ${ team} read the tag in Resource, and ${ aws:username } does in a StringEquals value. Spaces before and after the comma and before the closing brace leave the default form intact, and a space inside the quotes is kept: ' none ' matches table/ none and not table/none.

The trimmed set is every code point up to U+0020, the set Java's String.trim removes. Tab, newline, U+000B, U+000C, U+000D, U+0001 and U+001F are trimmed, and U+0085, U+00A0 and U+3000 are not. The escapes are trimmed too: ${ * } denies the table named * and not x, and ${ $ } stands for $.

parse_reference now trims that set from the whole text and again from the name and the quoted default. It no longer uses str::trim, which trims U+0085, U+00A0 and U+3000 and keeps U+0001 and U+001F. Each measured row is a test in evaluator/tests/variable_spelling.rs, and the parser has unit tests. With the trimming this branch had before put back (str::trim, on the default form only), 7 tests fail. In a condition value the trimmed key is read where the default form is (see the reply on condition.rs).

@navareamol navareamol added the v1 Needed for the 1.0 release label Nov 3, 2026
…riable

A Resource or NotResource pattern with a policy variable is matched by
match_tokens in variables.rs, which compared bytes of the value's UTF-8
encoding. #581 made matcher::wildcard_match and arn_match count UTF-16 code
units, as IAM does, so after the merge with main a ? beside a variable took
one byte where the same pattern without a variable took one unit. A Deny on
table/?zz applied to a table named with é or 日 before zz, and the same Deny
written as table/?zz${aws:PrincipalTag/none, ''} did not.

The tokens now hold UTF-16 code units. The text written in the policy and
each substituted value are encoded with encode_utf16, and each ARN field of
the request is encoded the same way before the comparison. ASCII patterns
and values match as before.

The new tests run the Resource rows of matcher/utf16_tests.rs, measured with
the IAM policy simulator, on the token path and check that the answer equals
the one arn_match gives without the variable. With the byte comparison put
back, three of them fail: question_mark_takes_one_utf16_unit_beside_a_variable,
star_backtracks_by_utf16_units_beside_a_variable and
a_substituted_value_is_matched_by_utf16_units.
parse_reference trimmed the key name of the default form and not of the
plain form, so ${ aws:PrincipalTag/team , 'none'} read the team tag while
${ aws:PrincipalTag/team } named a key no request carries. A Deny on
table/${ aws:PrincipalTag/team } denied nothing to a caller tagged
team=blue, and the defaulted spelling denied the table blue.

The IAM policy simulator trims both forms (measured on 2026-11-03, two
samples per row): ${ team }, ${team } and ${ team} read the tag in Resource
and in a StringEquals condition value, and spaces before or after the comma
and before the closing brace leave the default form intact. A space inside
the quotes of the default is kept. IAM trims every code point up to U+0020:
tab, newline, U+000B, U+000C, U+000D, U+0001 and U+001F are trimmed, and
U+0085, U+00A0 and U+3000 are not. It trims around the escapes too:
${ * } stands for a literal * and ${ $ } for a literal $.

parse_reference now trims that set from the text between the braces, and
from the key name and the quoted default of the default form. Rust's
str::trim is not used, because it trims U+0085, U+00A0 and U+3000 and keeps
U+0001 and U+001F.

The tests in evaluator/tests/variable_spelling.rs repeat the measured rows,
and unit tests pin the parser.
…eads it

The condition path did not get the document's Version, so the
${key, 'default'} form this branch added expanded in every condition
value: in a 2008-10-17 document, in one with no Version, in a trust policy
whatever its Version, and under every operator. main compared the whole
text as written in all of these, because it took the text between the
braces as the key name.

The IAM policy simulator reads the default form only in a 2012-10-17
document and under the string and ARN operators (2026-11-03, two samples
per row). In a 2008-10-17 document and in one with no Version, a Deny on
StringNotEquals aws:PrincipalTag/team "${aws:username, 'x'}" applies to
alice tagged team=alice, and an Allow on StringEquals with the same value
does not grant until the tag holds the text as written. In a 2012-10-17
document a default does not take part under NumericEquals,
DateGreaterThan, Bool or Null, and it does under StringEquals and ArnLike.

evaluate_statement_conditions and evaluate_condition_match now take
whether the statement's document has Version 2012-10-17. The identity,
group, boundary and session policy paths pass their own document's
Version, and the trust policy path its own. Where IAM reads the syntax,
the default form and the trimmed key are read as in Resource. Elsewhere
the text between the braces is the key name as written, as on main, so
${key, 'default'} is compared as written and ${key} expands as it did.
The fail-closed check for a variable naming an unresolvable key reads the
names the same way, so a defaulted variable outside that scope no longer
fails closed. evaluate_condition keeps its signature and reads values as
in a 2012-10-17 document.

the_default_form_expands_in_a_null_condition becomes
the_default_form_is_not_read_in_a_null_condition and repeats the measured
Null row. Two rows are not repeated: ExtendDB refuses a document whose
IpAddress value is not a CIDR, so the IpAddress row cannot be evaluated,
and IAM reads a Bool value written as a variable as false, which ExtendDB
does not do for either form. The ${key} form keeps main's behaviour
outside a 2012-10-17 document, which #534 tracks, and under the other
operators.
…ttern

A tag whose value is the empty string adds no characters to a Resource or
NotResource pattern, so table/${aws:PrincipalTag/team}* reads as table/*
for a caller tagged team="", while with no team tag the pattern matches
nothing. The IAM policy simulator gives the same answers (2026-11-03, two
samples per row): with team="" an Allow-only table/${team}* grants red and
blue, a Deny on it denies red, a Deny NotResource on it spares red, and an
Allow-only *${suffix} with suffix="" grants red. With the team tag absent
the same Allow gives implicitDeny. table/${team}x and x${team} match
table/x, and x${team}y matches table/xy. A StringLike condition "?*" on
aws:PrincipalTag/team beside table/${team}* refuses the empty value and
grants team=blue on blue-x.

The behaviour does not change. evaluator/tests/empty_values.rs repeats the
rows.
…'s page

resource_variables_fields.rs pins, from two simulator rows, that variables
in the region and account fields of a Resource ARN expand. IAM's policy
variables page says a variable cannot replace parts of the ARN before the
fifth colon. The comment now says that the tests follow the measurement,
and that a variable in the service field goes through the same code
without a measurement, so neither rule is changed without measuring both.
…pty values

The security model says where a condition value reads the default form (a
2012-10-17 document and a string or ARN operator, a trust policy by its own
Version), that code points up to U+0020 around a key name are ignored, and
that a tag whose value is the empty string expands to no characters, so
table/${aws:PrincipalTag/team}* matches every table for a caller tagged
team="". It names the StringLike "?*" condition on the tag that refuses
such a caller, which the IAM policy simulator confirms.

The v0.1.15 upgrade entry limits item 3 to the same scope and adds item 4
for the trimmed key name. Its Version paragraph now says that a stored
document with another Version is refused when it is sent again unchanged,
that a stored Deny with a Resource variable in such a document keeps
denying what the pattern as written matches until its Version is changed
to 2012-10-17, and how to find such documents before upgrading. The auth
design note gives the default form's scope.
No wire test put a variable in a Resource or NotResource pattern or sent a
refused Version, so a change to how the server builds the request context
for the resource check, or to the console's validation, would pass CI.

tests/test_resource_policy_variables.py adds two classes. TestResourceVariable
gives a user tagged team=blue an Allow dynamodb:* on * and a Deny
dynamodb:GetItem on table/${aws:PrincipalTag/team}, written as Resource
and as NotResource, and reads the tables blue and red: under Resource the
read of blue is denied and red is allowed, and under NotResource the
reverse, as the IAM policy simulator rows in the PR description give.
TestVersionRefused sends documents with Version "2012-10-18",
"2012-10-17 " and 20121017 through the user, group and role policy writes
and the user and role boundary writes of the management API, and through
the console's user, group and role policy forms. Each is refused, nothing
is stored, and the same document with Version 2008-10-17 is stored.

Both classes provision IAM principals through the management API, so they
run against ExtendDB only; the module docstring says so. On a SQLite server
built from this branch the 26 tests pass.
The upgrade entry's example for the trimmed key name used a Resource
pattern, but a Resource variable was not expanded at all before this
change, so the old answer came from the literal pattern, not from the
spaces. The example is now a StringEquals condition value, where a
released build read the spaces as part of the key name.

The entry also listed IpAddress among the operators that compare the text
as written; a policy or boundary write refuses an IpAddress value written
as a variable as a malformed CIDR, so the value never reaches evaluation.
The "Who is affected" paragraph now counts the condition value changes in
items 2 to 4.

The security model's guard for an empty tag value applies to an Allow
statement. A Deny NotResource statement with such a pattern spares every
table for that caller, as the IAM policy simulator shows, and the same
condition on it changes nothing.
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. The branch now merges main at 5ff323beb (41b4885), and the commits on top answer each point. I re-measured with the IAM policy simulator on 2026-11-03, two samples per row with the same answer; the rows are in the description's second table.

Merge with main: the merge brought #581, so the token matcher now counts UTF-16 code units like arn_match (5a1f13f), as in the inline reply.

Default form outside a 2012-10-17 document and under other operators: fixed in this PR (86cd184), so it does not wait for #541. A condition value reads ${key, 'default'} and the trimmed key only in a 2012-10-17 document and under a string or ARN operator, and each policy passes its own Version, the trust policy included. Elsewhere the text between the braces is the key name as written, as on main, and the fail-closed check reads names the same way. Item 3 of the upgrade entry, the security model and the "Related behaviour" section now give that scope.

Empty tag value: IAM substitutes the empty string as this branch does, so table/${team}* matches every table for team="" and nothing for an absent tag. The rows are pinned (7024319), and the security model names the case and the StringLike ?* guard, which the simulator confirms.

Spaces inside the braces: both forms now trim every code point up to U+0020 around the key, the comma, the closing brace and the escapes, as IAM does, and a space inside the quotes is kept (a9d4362).

Wire tests: tests/test_resource_policy_variables.py (a682676) has the Deny Resource and Deny NotResource case on table/${aws:PrincipalTag/team} for a user tagged team=blue reading blue and red, and the Version refusal on the user, group and role policy and boundary writes and the console forms. It runs against ExtendDB only. The dual-target harness has a DynamoDB client and no way to create IAM users on the service, so the expected answers come from the simulator rows in the description.

resource_variables_fields.rs: the comment now says that the two rows contradict IAM's page, and that the service field goes through the same code without a measurement (f4148a4).

Upgrade entry: the Version paragraph now says that a stored document with another Version is refused when sent again unchanged, that a Deny with a Resource variable in such a document keeps denying what the pattern as written matches until its Version is 2012-10-17, and how to list the stored documents to find them before upgrading (ada402c). 054c262 corrects three statements there and in the security model: the trimmed-key example now uses a condition value, IpAddress is no longer listed among the operators that compare the text as written (a write refuses such a value as a malformed CIDR), and the empty-value guard is stated for an Allow statement.

Tests: Rust 1.97.0 fmt --check, clippy -D warnings on extenddb-auth and extenddb-server, and check --workspace --all-targets. Rust 1.88.0 cargo test -p extenddb-auth (371 unit, 3 doc) and cargo test -p extenddb-server --lib (253). Each new rule has a negative control, listed in the description. On a SQLite server built from this head, test_resource_policy_variables.py (26 tests) and the nine existing auth and tag wire files the description lists (289 tests) passed.

@navareamol navareamol added the release-blocker Must ship for its milestone's release label Nov 3, 2026
@robinnsc
robinnsc added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit eeda407 Nov 4, 2026
39 checks passed
@robinnsc
robinnsc deleted the fix/resource-policy-variables branch November 4, 2026 23:18
LeeroyHannigan added a commit that referenced this pull request Nov 5, 2026
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.
Dodothereal pushed a commit to Dodothereal/extenddb that referenced this pull request Nov 6, 2026
ExtendDB#541)

* 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 ExtendDB#533.

* test(auth): cover policy variables in Resource and NotResource at the evaluator

One test per measured row of issue ExtendDB#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.

* docs(security): state that Resource and NotResource expand policy variables

* test(auth): borrow the condition default-form policy as a slice

* 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.

* docs(security): multivalued keys and colons in policy variable values

* 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.

* 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/<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: ExtendDB#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.

* 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 ExtendDB#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.

* 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.

* 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.

* 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 ExtendDB#534.

* 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.

* 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.

* 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.

* 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 ExtendDB#535 measurements table. Both places now say so.

* 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(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.

* 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.

* 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/<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.

* 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/<name>, and in both contexts a key listed in
FAIL_CLOSED_GLOBAL_KEYS.

* 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). ExtendDB#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 ExtendDB#534.

* 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 ExtendDB#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.

* 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.

* 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.

* 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.

* 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.

* 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 ${.

* 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.

* 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.

* 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.

* test(auth): match ExtendDB#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 ExtendDB#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
ExtendDB#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-blocker Must ship for its milestone's release v1 Needed for the 1.0 release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Policy variables in Resource and NotResource are not expanded

5 participants