Repository navigation
fix(auth): expand policy variables in Resource and NotResource - #535
Conversation
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 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.
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.
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.
robinnsc
left a comment
There was a problem hiding this comment.
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.
| fields | ||
| } | ||
|
|
||
| /// Greedy wildcard match over tokens, the same algorithm as |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| } | ||
| result.push_str(rest); | ||
| result | ||
| super::variables::expand_condition_value(value, context) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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-17document, aDenywithStringNotEquals aws:PrincipalTag/team: "${aws:username, 'x'}"applies toalicetaggedteam=alice. AnAllowwithStringEqualsand the same value gives implicitDeny, and it grants when the tag holds the text as written. With noVersiontheAllowalso gives implicitDeny, and the2012-10-17control grants. - In a
2012-10-17document a default takes no part underNumericEquals: anAllowlike yours, onNumericEquals aws:MultiFactorAuthAge: "${aws:PrincipalTag/m, '300'}"with nomtag, gives implicitDeny, and the sameDenydoes not apply. The same holds underDateGreaterThan,Bool,NullandIpAddress. UnderStringEqualsandArnLikethe 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.
| 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)), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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-onlytable/${aws:PrincipalTag/team}*grantsredandblue.- A
Denyon it deniesred, and aDeny NotResourceon it sparesred. Allow-only*${aws:PrincipalTag/suffix}withsuffix=""grantsred.table/${team}xandx${team}matchtable/x,x${team}ymatchestable/xy, andtable/${team}does not matchtable/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.
| let default = default.trim(); | ||
| if default.len() >= 2 && default.starts_with('\'') && default.ends_with('\'') { | ||
| return Reference::Key { | ||
| name: name.trim(), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
…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.
|
Thanks for the review. The branch now merges Merge with main: the merge brought #581, so the token matcher now counts UTF-16 code units like Default form outside a Empty tag value: IAM substitutes the empty string as this branch does, so 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:
Upgrade entry: the Tests: Rust 1.97.0 |
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.
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.
Fixes #533.
Summary
A policy variable in a statement's
ResourceorNotResourcewas compared as literal text, sotable/${aws:PrincipalTag/team}matched no table. ADenywritten that way denied nothing and anAllowgranted nothing. This change expands variables inResourceandNotResourcepatterns before matching, following IAM's rules as measured with the IAM policy simulator:2012-10-17document. A2008-10-17document, or one with noVersion, matches the pattern as written.PolicyDocument.versionis nowOption<String>, so an absentVersionis no longer read as2012-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 aVersionthat is not the string2012-10-17or2008-10-17, such as"2012-10-18","2012-10-17 "or the number20121017. They already required the member. A stored document with another value keeps parsing and matches its patterns as written. AnAssumeRolesession policy is still checked only for the member.*and?as wildcards. Text that comes from a variable value, a default or an escape is matched literally, and a tag value ofbl*matches only a table namedbl*.:written in the policy separates ARN fields. A:inside a value is a character of its field: a region-field value ofus-east-1:123456789012matches 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.Resourcestatement does not apply and aNotResourcestatement applies to every resource (both measured, forAllowandDeny). An unresolved variable before a wildcard does not widen the pattern totable/*. A tag whose value is the empty string is not unresolved: it expands to no characters, sotable/${aws:PrincipalTag/team}*matches every table for a caller taggedteam="", as IAM evaluates it, and aStringLikecondition?*on the tag refuses such a caller (both measured).ResourceandNotResource, 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, aDenyapplies and anAllowdoes 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.dynamodb:LeadingKeysis never a variable, inResourceor in a condition value, with one value in the request or several. v0.1.14 used the first value in a condition value;mainhas left such a variable as written since fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522.${key, 'default'}expands todefaultwhen the key is absent, inResource, and in a condition value of a2012-10-17document under a string or ARN operator. Elsewhere a condition value compares it as written, asmaindoes; 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 inResource, with or without spaces inside the braces.*and?count UTF-16 code units in a pattern with a variable, asarn_matchdoes since fix(auth): count policy wildcards in UTF-16 code units, as IAM does #581.Patterns without
${take the samearn_matchpath as before. The expansion lives incrates/auth/src/policy/variables.rs;resource_matchesgains the document and the request context at its five call sites, including the permissions boundary and session policy checks andexplicit_deny_matches_arc. Incondition.rs,expand_policy_variablesdelegates to the new module, and #522's fail-closed check takes its variable names from it.evaluate_statement_conditionsandevaluate_condition_matchtake whether the statement's document hasVersion2012-10-17: the evaluator passes each document's, and theAssumeRoletrust 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, actiondynamodb:GetItem,Allow dynamodb:*on*beside the statement under test unless the row says otherwise. ARNs arearn:aws:dynamodb:us-east-1:123456789012:table/....table/${aws:PrincipalTag/team}team=bluetable/blueteam=bluetable/redteamtable/bluetable/${aws:PrincipalTag/team}*team=bluetable/blue-xteamtable/bluetable/${aws:username}aws:username=alicetable/aliceaws:username=alicetable/bobtable/${aws:PrincipalTag/team},Version2008-10-17team=bluetable/blueVersionteam=bluetable/blueNotResourcetable/${aws:PrincipalTag/team}team=bluetable/blueteam=bluetable/redteamtable/blueNotResourcetable/${aws:PrincipalTag/team}teamtable/blue,table/redtable/${aws:PrincipalTag/team, 'none'}teamtable/nonetable/${aws:PrincipalTag/team,'none'}teamtable/nonetable/${*}table/xtable/${aws:PrincipalTag/team}team=bl*table/blueteam=bl*table/bl*team=blue:xtable/blue:xtable/b?${aws:PrincipalTag/team}team=uetable/bluetable/${dynamodb:LeadingKeys}LeadingKeys=[blue, red]or[blue]table/blue,table/redtable/${dynamodb:LeadingKeys}LeadingKeys=[blue, red]table/blue*ifStringEquals aws:PrincipalTag/team: ${dynamodb:LeadingKeys}team=blue,LeadingKeys=[blue, red]or[blue]table/xarn:aws:dynamodb:${aws:RequestedRegion}:123456789012:table/blueRequestedRegion=us-east-1table/bluearn:aws:dynamodb:us-east-1:${aws:PrincipalAccount}:table/bluePrincipalAccount=123456789012table/bluearn:aws:dynamodb:${aws:PrincipalTag/region}:*:table/Tregion=us-east-1:123456789012table/T,table/A/stream/x:table/Taws iam simulate-custom-policy, 2026-11-03, two samples per row with the same answer, actiondynamodb:GetItem,Version2012-10-17unless the row says otherwise, andAllow dynamodb:*on*in the same document beside eachDeny.teamisaws:PrincipalTag/team.table/${team}*team=""table/red,table/blueteamtable/redtable/${team}*team=""table/redNotResourcetable/${team}*team=""table/red*${aws:PrincipalTag/suffix}suffix=""table/redtable/${team}x, ortable/x${team}team=""table/xtable/${team}team=""table/xtable/${team}*ifStringLike team: "?*"team=""table/redteam=bluetable/blue-xtable/${ team },table/${team }ortable/${ team}team=bluetable/bluetable/${ team , 'none'}ortable/${team ,'none'}team=bluetable/bluetable/${ team , 'none'},table/${team, 'none'}ortable/${team, 'none' }teamtable/nonetable/${team, ' none '}teamtable/ noneteamtable/noneteam=bluetable/blueteam=bluetable/bluetable/${ * }table/*table/xtable/a${ $ }table/a$*ifStringEquals aws:ResourceTag/owner: "${ aws:username }"alice,owner=alicetable/x2008-10-17, Deny on*ifStringNotEquals team: "${aws:username, 'x'}"alice,team=alicetable/x2008-10-17, Allow only on*ifStringEquals team: "${aws:username, 'x'}"alice,team=alicetable/xVersiontable/x2012-10-17table/x2008-10-17teamholds${aws:username, 'x'}table/x*ifNumericEquals aws:MultiFactorAuthAge: "${aws:PrincipalTag/m, '300'}"300, nomtable/xtable/xDateGreaterThan aws:CurrentTime: "${aws:PrincipalTag/d, '2000-01-01T00:00:00Z'}"dtable/xBool aws:SecureTransport: "${aws:PrincipalTag/f, 'true'}"true, noftable/xfalse, noftable/xNull team: "${aws:PrincipalTag/f, 'true'}"team, noftable/xIpAddress aws:SourceIp: "${aws:PrincipalTag/c, '10.0.0.0/8'}"10.1.1.1, noctable/xStringEquals aws:ResourceTag/owner: "${aws:username, 'anon'}"owner=anontable/xArnLike aws:SourceArn: "arn:aws:iam::123456789012:user/${aws:PrincipalTag/n, 'alice'}"user/alice, nontable/xTesting
a6826763b, after the merge withmain: one evaluator test per 2026-11-03 row except two, invariable_spelling.rs(spaces, control characters and escapes),empty_values.rs, andcondition_variable_scope.rs(theVersionand operator rows, the group, boundary and session policy paths, and a defaulted variable naming an unresolvable key), andtrust_policy_reads_the_default_form_only_with_version_2012inassume_role.rs. TheIpAddressrow has no test because ExtendDB refuses a document whoseIpAddressvalue is not a CIDR, and theBoolrow withSecureTransport=falsenone because ExtendDB compares that value as written (see Related behaviour). The UTF-16 tests invariables/tests.rsrun theResourcerows ofmatcher/utf16_tests.rson the token path. Negative controls: the byte comparison put back (3 tests fail), the trimming of57861f592put back,str::trimon the default form only (7), theVersionignored in condition values (6), the operator ignored (5), and every trust policy read as2012-10-17(1).tests/test_resource_policy_variables.py(26 tests, ExtendDB only, since it provisions IAM users through the management API): a user taggedteam=blueunderDeny ResourceandDeny NotResourceontable/${aws:PrincipalTag/team}readsblueandred, and each refusedVersionis sent through the user, group and role policy writes, the user and role boundary writes and the console's policy forms.${?}and${$}escapes, the2008-10-17and no-Versiondocuments forNotResource, the default form in a condition value, and the boundary, session policy andexplicit_deny_matches_arcpaths. 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 underForAnyValue,ForAllValues,IfExistsandNull. The headers of the evaluator test files for policy variables say which expectations repeat a simulator case and which are derived, and the header ofcase_three.rssays its expectations are ExtendDB rules with no service measurement. Unit tests cover the parser, the token matcher and theVersionwrite check, whose message is ExtendDB's own.57861f592: expansion off (22 tests fail: 11 inresource_variables.rs, 6 inresource_variables_fields.rs, 2 invariable_values.rs, 3 incase_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), theVersionwrite check removed (1), theVersiongate removed from the fail-closed rule forResource(1), a multivalued key expanded to its first value underNullonly (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.case_three.rscover 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. A2008-10-17or no-Versiondocument whoseResourcenames such a key matches the pattern as written and does not fail closed. A multivalued key the context reports unresolvable,dynamodb:LeadingKeysin the test context, fails closed inResourceandNotResourcelike any other, and its default is not used.resource_variables_fields.rspin the multivalued-key default rule above ondynamodb:LeadingKeys(present with two values, present with one, absent; inResourceand in a condition value). Control: the previous rule (the default used whenever the key is multivalued) fails exactly these two tests.a6826763b:cargo fmt --all --check,cargo clippy --locked -p extenddb-auth -p extenddb-server --all-targets -- -D warningsandcargo check --locked --workspace --all-targets. Rust 1.88.0:cargo test --locked -p extenddb-auth(371 unit tests and 3 doc tests) andcargo test --locked -p extenddb-server --lib(253). On a SQLite server built froma6826763b,test_resource_policy_variables.py(26 tests) passed, and so didtest_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) andtest_fgac_leading_keys.py(31).57861f592:cargo fmt --all --check,cargo clippy --locked --workspace --all-targets -- -D warnings,cargo check --locked --workspace --all-targets, andcargo 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 --libalso passes at each of the first five commits (291, 311, 311, 311 and 320 tests). On a SQLite server built from57861f592,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) andtest_fgac_leading_keys.py(31) passed, as did the console test for a user policy the service refuses. Against the same server, each refusedVersionsent through the user, group and role policy, boundary and console writes was refused, and a GetItem by a user taggedteam=bluewas denied onblueand allowed onredunderDeny Resource table/${aws:PrincipalTag/team}, and the reverse underDeny NotResource. CI runs the workspace tests.57861f592after one rerun of the MongoDB jobs. The first run failedtests/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
ResourceorNotResourcepattern with a variable in a2012-10-17document now matches the expanded value. A stored policy that relied on the literal text matching nothing now applies.dynamodb:LeadingKeysis compared as written instead of being expanded to the key's first value, as onmainsince fix(auth): resolve aws:TagKeys and aws:RequestTag for the tag operations #522. Against v0.1.14, under a positive operator such asStringEqualsthe condition no longer matches, and under a negated operator such asStringNotEqualsit now matches, so anAllowwith such a negated condition can now grant and aDenywith one can now apply.2012-10-17document, a condition value written as${key, 'default'}under a string or ARN operator now expands to the request's value forkey, or todefaultwhen the key is absent, where the whole text was compared before. In a2008-10-17document, in one with noVersion, and under the numeric, date,BoolandNulloperators it is still compared as written (a policy or boundary write refuses anIpAddressvalue written as a variable as a malformed CIDR). A role trust policy follows its ownVersion.ResourceandNotResourceand in the condition values above. AnAllowwithStringEquals aws:ResourceTag/owner: "${ aws:username }"now grantsalicea table taggedowner=alice; before, the spaces were part of the key name, the value was compared as written and theAllowgranted nothing.dynamodb:LeadingKeysoraws:TagKeyswith 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, aResourcepattern matches nothing and a condition value is compared as written. Measured with the IAM policy simulator on 2026-10-28:aws:TagKeysas[x, y],[x]and absent againsttable/${aws:TagKeys, 'x'}inResourceanswer implicitDeny, implicitDeny, allowed, and against${aws:TagKeys, 'x'}as aStringEqualsvalue onaws:PrincipalTag/team=xthe same three answers.Versionis not the string2012-10-17or2008-10-17is refused withInvalid policy document: policy parse error: invalid Version (must be the string 2012-10-17 or 2008-10-17): <value>, and a permissions boundary write withInvalid permissions boundary document: policy parse error: invalid Version .... Before, the document was stored and itsResourcevariables matched as literal text. A document stored with such a value keeps evaluating as before, but sending it again unchanged is now refused, and itsResourcevariables match as written until itsVersionis changed to2012-10-17. IAM refuses such a document: Access Analyzer reportsINVALID_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 answersInvalidInputfor it (measured 2026-10-28, two samples each for2012-10-18and2008-10-18); the message text is ExtendDB's own.Related behaviour
Two gaps in condition values remain, both present on
mainbefore this change and filed as #534:${key}in a condition value of a2008-10-17or no-Versiondocument 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 a2012-10-17document.${key}also still expands under the numeric, date,BoolandNulloperators, where IAM does not substitute variables, and IAM reads aBoolorNullvalue written as a variable asfalsewhere ExtendDB compares the text; #541 covers both.An
AssumeRolesession policy is checked only for itsVersionandStatementmembers and is parsed when a request uses the session, so it gets neither theVersionvalue check nor theSidand CIDR write checks.Merge with main
The branch merges
mainat5ff323beb(merge commit41b48857c). The merge conflicted only in documentation. It brought #581, after whichmatcher::wildcard_matchandarn_matchcount UTF-16 code units while this branch's token matcher compared bytes;5a1f13f83makes the token path count UTF-16 code units too.Rebase on #522
This branch is rebuilt on
mainat1d0d8f4a2, which includes #522. One block conflicted: #522 had rewritten the body ofexpand_policy_variablesincondition.rs, and this branch makes the function delegate tovariables::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_KEYSis 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'}namedaws: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 ambiguousenvtag, aStringEqualsDenywith that value did not apply and aStringNotEqualsAllowgranted. The check now reads the name the way the expansion does, without the default, so both forms fail closed: aDenyapplies and anAllowdoes 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. TheFAIL_CLOSED_GLOBAL_KEYScomment said such a condition does not fail closed. Three comments incontext.rssaid an unresolved global key such asaws:PrincipalOrgIDoraws:SourceIpfails closed, but with the list empty such a key keeps absent-key semantics. Each now says which keys the context reports unresolvable: an ambiguousaws:RequestTag/<name>in the request context, and a key listed inFAIL_CLOSED_GLOBAL_KEYSin both contexts.The
case_three.rstesta_condition_value_naming_an_unresolvable_key_is_compared_as_writtenis renameda_condition_value_naming_an_unresolvable_key_fails_closed_with_or_without_a_default. It expects theDenyto apply underStringEqualsandStringNotEqualsand anAllowgated 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) andambiguous_request_tag_as_a_defaulted_policy_variable_fails_the_statement_closed. Withcondition.rsas 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_closedand the renamedcase_three.rstest fail, andvariable_key_names_strip_the_default_and_skip_escapespasses, becausevariable_key_nameslives invariables.rs. Withvariable_key_namesreading the whole text between the braces as the name, asmaindoes, all four fail. #522'stest_ambiguous_request_tag_as_a_policy_variable_is_refusedis 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.
maincompares 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 keepsmain'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/test_resource_policy_variables.pycargo fmt,cargo clippycleandocs/manuals/10-security-model.md,docs/manuals/07-upgrade-manual.mdunder v0.1.15,docs/design/01-requirements.md,docs/design/05-component-auth.md,docs/design/05-component-auth-v3.md,docs/backlog.md)Versionother than2012-10-17or2008-10-17