Skip to content

fix(auth): enforce trust-policy Deny and match IAM condition evaluation - #420

Merged
LeeroyHannigan merged 58 commits into
mainfrom
fix/trust-policy-deny
Oct 22, 2026
Merged

LeeroyHannigan merged 58 commits into
mainfrom
fix/trust-policy-deny

Conversation

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

What

A Deny statement in a role's trust policy is now enforced, and trust policies and identity-policy conditions are evaluated the way IAM and STS evaluate them. This builds on #402 by @navareamol, whose commit is the first one here, kept with its authorship.

Trust policies:

  • Evaluation follows IAM order: a matching explicit Deny blocks AssumeRole before any Allow is considered. The decision covers sts:AssumeRole, and sts:TagSession as well when the call passes session tags.
  • AssumeRole resolves the condition keys STS populates for the call, including aws:PrincipalArn, aws:PrincipalAccount, aws:PrincipalTag/*, aws:RequestTag/*, aws:TagKeys, sts:ExternalId, sts:RoleSessionName, aws:SourceIp and aws:SecureTransport. A key STS does not populate is absent and follows IAM absent-key semantics.
  • A trust policy IAM refuses at CreateRole is refused at write: NotPrincipal, a Resource field, an action outside the STS trust set, a bare "*" principal, a non-alphanumeric Sid, and any character above U+00FF. The web console now runs the same checks as the management API.
  • Condition key names and tag keys on the trust path match without regard to ASCII case, as STS does.

Identity policies on DynamoDB requests:

  • Every global key ExtendDB has data for is resolved, including aws:SecureTransport, aws:RequestedRegion, aws:CurrentTime, aws:PrincipalType, aws:userid, aws:SourceIp, aws:UserAgent, aws:TokenIssueTime and aws:MultiFactorAuthPresent (the last two for role sessions only). Other keys are absent, as on a standalone AWS account.
  • Condition key names and tag keys match under Unicode full case folding, as DynamoDB does. A tag lookup prefers the exact stored key and is deterministic when two stored keys fold to the same string.
  • IpAddress and NotIpAddress are evaluated as IPv4 and IPv6 CIDR membership. An IPv4 client on a dual-stack listener is evaluated as IPv4. A malformed CIDR, an IPv4-mapped IPv6 literal and a non-alphanumeric Sid are rejected at write, as IAM rejects them.
  • IAM tag writes (TagUser, TagRole, session tags) apply IAM's tag character set, length limits, the reserved aws: prefix and its rule that keys differing only in ASCII case collide.

The creation-time caches the new keys use are reported at /management/auth-cache-metrics.

Why

evaluate_trust_policy skipped every statement whose effect was not Allow, so a Deny in a trust policy did nothing. A broad Allow still granted AssumeRole to a principal the Deny was meant to block. Fixing that exposed the condition keys behind most Deny statements: a key ExtendDB did not resolve read as absent, so a Deny gated on it never matched and a negated Allow gated on it granted. Each expected result in this change was measured against IAM, STS and DynamoDB first.

Testing done

  • tests/test_auth_integration.py (53 new tests) and tests/test_abac.py (56 new tests) cover the trust-policy Deny cases, every resolved key, absent-key semantics, key name case and Unicode folding, tag resolution, CIDR forms, Sid and tag rules at write, and the role-session keys. The four auth wire files pass on SQLite and PostgreSQL (207 tests each), and the trust file passes on MongoDB and Cassandra (82 each). The new cases fail on main.
  • tests/test_console_integration.py: four new tests show the console refuses trust and identity policies the management API refuses. They fail on the build before the console change.
  • Unit tests in crates/auth/src/policy/ and crates/server/src/, including a guard test for the generated case-fold table (devtools/generate-casefold-table, Unicode 15.0.0).
  • cargo fmt --check, cargo clippy on 1.97.0 with -D warnings, cargo test --workspace on stable and 1.88.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality
  • I have updated documentation if behavior changed
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: docs/adr/0022-trust-policy-deny-over-allow.md.

Breaking changes

docs/manuals/07-upgrade-manual.md and docs/manuals/05-admin-guide.md have the full notes. The main ones:

  • A trust-policy Deny now takes effect.
  • Passing session tags to a role now needs sts:TagSession in its trust policy.
  • A condition on a key ExtendDB did not resolve before now evaluates against its value. A Deny on such a key can start to apply, and a negated Allow on it can stop granting.
  • Trust and identity policies IAM refuses are refused at write. Policies already stored keep their stored text. A stored identity policy with a malformed CIDR fails closed: every request by that principal is denied with AccessDeniedException until the policy is corrected. A stored Sid is not re-checked.
  • A stored policy whose condition key name or tag key contains a non-ASCII letter that folds to a resolvable key may evaluate differently.

Merge order

This PR conflicts with #419 in crates/server/src/request_helpers.rs, the matcher.rs tests and docs/manuals/05-admin-guide.md. It merges cleanly with #418. I will rebase it after #418 and #419 merge and resolve the conflicts.


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

Amol Navare and others added 30 commits October 21, 2026 10:51
evaluate_trust_policy() skipped every statement whose Effect was not
"Allow", so Deny statements in a role's trust policy were silently
ignored. An administrator relying on a Deny to revoke a principal's
ability to assume a role got no protection: a broad `Allow *` still
granted assumption to a principal an accompanying Deny was meant to
block.

Rework evaluation to follow IAM order, matching the identity-policy
evaluator (policy/evaluator.rs, phase 1): scan for a matching explicit
Deny first and block assumption if one is found, then look for a
matching Allow. Factor the decision into a store-free `trust_decision`
helper plus `statement_matches`/`effect_of` so the Deny-over-Allow
ordering is unit-testable, and add tests covering explicit-deny
override (both statement orders), principal-scoped Deny, conditional
Deny via aws:PrincipalTag, and the existing Allow/implicit-deny paths.

Record the decision in ADR-0022.

The AssumeRole endpoint is admin-only (Basic Auth) with an admin-supplied
caller ARN, which bounds real-world exposure, but the authorization logic
was incorrect.
…write

The Deny-over-Allow reorder in 9952f5f left several Deny shapes inert and
accepted documents IAM rejects. Measured against live IAM, STS and DynamoDB,
this corrects the trust evaluator and adds write-time validation.

Evaluation (crates/server/src/management/assume_role.rs):
- Decide per requested STS action. A plain AssumeRole exercises
  sts:AssumeRole; passing session tags also exercises sts:TagSession. Every
  exercised action needs a matching Allow and no matching Deny. This fixes the
  over-deny on a TagSession-only or SetSourceIdentity-only Deny, the fail-open
  on a TagSession-only Allow, and session tags passed without sts:TagSession.
- Compare Effect case-insensitively, matching the identity-policy parser.
- Match account principals: {"AWS": "<acct>:root"} and {"AWS": "<acct-id>"}
  match any caller in that account.
- Accept a single-object Statement.
- Fail closed on a Deny whose Condition does not parse, so a Deny with an
  operator ExtendDB does not evaluate blocks rather than being dropped.
- Coerce scalar condition values to strings, matching IAM.

Condition evaluator (crates/auth/src/policy/condition.rs):
- A negated operator on an absent single-valued key now evaluates true, as in
  IAM, so a Deny gated on StringNotEquals sts:ExternalId applies when the
  caller omits the external id. BR-7085 (bare operators never match a
  multivalued key) is preserved. Shared with identity-policy evaluation.

Write validation (create_role, crates/server/src/management/iam_role.rs):
- Reject the documents IAM rejects for a trust policy: missing or invalid
  Effect, NotPrincipal, missing Principal, a bare-string or wildcard-ARN
  principal, missing Action, an action outside the STS trust set, a Resource
  field, and an unknown or malformed Condition. Accept a missing Version, a
  single-object Statement, numeric condition values and unknown condition keys.

SQLite caller tags (crates/storage-sqlite/src/management_store/mod.rs):
- Resolve tags with strip_prefix("user/"/"role/"/"assumed-role/") like the
  other backends. The server passes user/<name>, so the old :user/ split never
  matched and tag conditions were inert on SQLite.
Adds TestTrustPolicyDeny to tests/test_auth_integration.py. Each expected
status is the outcome IAM and STS returned for the same trust policy:
Deny over Allow in both orders, a Deny on another principal, tag-conditioned
Deny, the ExternalId Deny with no ExternalId sent, account root and bare
account id principals, per-action matching on sts:TagSession, session tags
without sts:TagSession, lowercase Effect, a single-object Statement, and the
documents create_role must reject or accept.
Update the auth component design, the v3 design, the ADR and the admin guide to
describe the trust-policy model: IAM-order Deny-over-Allow decided per exercised
STS action, case-insensitive Effect, account-root and bare-account-id
principals, fail-closed on a Deny whose condition does not parse, and write-time
validation of the document. Remove the fail-open claim from the ADR.
…rces

Add a unit test that create/tag a user and a role and asserts fetch_caller_tags
resolves tags for user/<name>, role/<name> and assumed-role/<role>/<session>.
Passing session tags to AssumeRole now requires the trust policy to allow
sts:TagSession, matching IAM. The session-tag ABAC tests passed tags against a
trust policy that allowed only sts:AssumeRole, so the assume call is now denied.
Add sts:TagSession to the trust policy so the tests exercise their intent.
…ed on unresolvable keys

A trust-policy condition gated on a key the AssumeRole context could not
resolve was treated as a genuinely absent key. A Deny with a positive operator
was then dropped and a caller the policy denies was issued credentials, while
an Allow or a negated Deny on such a key evaluated against the wrong default.

Resolve the keys the AssumeRole context has: aws:PrincipalArn and
aws:PrincipalAccount from the caller ARN, and aws:RequestTag/* and aws:TagKeys
from the session tags. For a key the service always populates but the context
cannot resolve, the condition is now unresolvable and the statement fails
closed, so a Deny blocks and an Allow does not grant whatever the operator.
Keys that are only conditionally present keep IAM absent-key semantics.

The three-valued condition outcome is shared with identity-policy evaluation
through a new ConditionContext method.
The service resolves aws:PrincipalTag/<key>, aws:RequestTag/<key> and
dynamodb:ResourceTag/<key> without regard to the case of <key>. ExtendDB matched
the key case-sensitively, so a policy naming a tag key in a case that differed
from the stored tag read the key as absent. A Deny gated on such a key was not
applied, and with the negated-operator-on-absent rule an Allow over-granted and
a Deny over-denied on a case-mismatched key.

Resolve tag keys case-insensitively in both the identity RequestContext and the
AssumeRoleContext. Tag values stay case-sensitive.
The shared condition change makes a negated operator on an absent single-valued
key match. On the identity path a global key the service populates but ExtendDB
does not resolve, for example aws:PrincipalOrgID, was treated as absent, so a
negated operator spuriously matched: an Allow over-granted and a Deny evaluated
against the wrong default.

Resolve aws:PrincipalArn and aws:PrincipalAccount in the DynamoDB RequestContext
from the caller identity. Treat the other global keys the service always
populates as unresolvable, so a statement gated on one fails closed: a Deny
blocks and an Allow does not grant, whatever the operator. The evaluator now
applies a Deny when its conditions match or are unresolvable, and grants an
Allow only when its conditions definitely match.
…keys it does not evaluate

Resolving the caller and session keys closes the common over-denials, where an
Allow or Deny gated on aws:PrincipalArn, aws:PrincipalAccount, aws:RequestTag or
aws:TagKeys was refused. The organization keys (aws:PrincipalOrgID,
aws:PrincipalOrgPaths) and the other global keys STS populates are still not
evaluated, so a trust policy depending on one fails closed rather than being
silently granted. Document the resolved set and the unevaluated set so operators
know which keys are safe to gate on.
…ey set

ADR-0022 described Deny-over-Allow ordering and the unparseable-condition
fail-closed behaviour but not how an unresolved key is treated. State the rule
the evaluator now follows for every condition key: a resolved key evaluates
normally, a genuinely absent key keeps IAM absent-key semantics, and a key the
service always populates but the context cannot resolve fails closed. List the
keys each context resolves.
…ator

Write validation accepted StringNotEqualsIgnoreCase, which IAM accepts, but the
evaluator could not parse it, so a statement using it was unparseable: a Deny
always blocked and an Allow never granted regardless of the values. Implement
the operator as the case-insensitive negation of StringEqualsIgnoreCase and
classify it as a negated operator, so it follows the negated-operator-on-absent
rule like the other negated operators.

BinaryEquals, IpAddress and NotIpAddress stay accepted at write and unevaluated:
they compare data neither context carries, so a statement using one still fails
closed, which is the documented safe direction.
…d identity-policy conditions

ADR-0022 named a statement_matches helper that was never introduced; replace it
with the real helper names. Add an upgrade-impact note that passing session tags
now requires sts:TagSession in the trust policy and that create_role now rejects
the documents IAM rejects. In the admin guide, state that principal ARNs are
matched case-sensitively, that the assume endpoint authorizes on the trust
policy alone and does not evaluate the caller's identity permissions, and that
the three-case condition-key rule, including the negated-operator-on-absent rule
and fail-closed on an unresolved key, applies to identity policies as well.
Condition evaluation marked a fixed set of global keys unresolvable and
treated every other unresolved key as absent. That denied common guard
statements on keys ExtendDB can resolve (aws:SecureTransport,
aws:RequestedRegion, aws:CurrentTime, aws:EpochTime, aws:PrincipalType) and
granted statements on keys the service populates that ExtendDB did not
resolve (aws:ViaAWSService, aws:PrincipalIsAWSService, aws:ResourceTag).

Resolve every global key ExtendDB has the data for, in both the DynamoDB
request context and the trust policy context: aws:SecureTransport from the
listener TLS state, aws:RequestedRegion from the server region, the request
time for aws:CurrentTime and aws:EpochTime, aws:PrincipalType (User for an
IAM user, AssumedRole for a role session), aws:username for a user,
aws:ViaAWSService and aws:PrincipalIsAWSService as false, and aws:ResourceTag
on the identity path from the resource tag map. Keep an allowlist of keys
that are legitimately absent for ExtendDB's callers so they keep absent key
semantics. Any other unresolved aws: key fails closed whatever the operator,
so a Deny on it blocks and an Allow on it does not grant.

Behaviour measured on live DynamoDB, IAM and STS in us-east-1.
Identity path (test_abac.py): guard statements on keys ExtendDB now resolves
grant, matching live DynamoDB (aws:SecureTransport, aws:RequestedRegion,
aws:PrincipalType, aws:CurrentTime); keys the service populates evaluate as
present (aws:ViaAWSService, aws:PrincipalIsAWSService, aws:ResourceTag), a
non-MFA caller keeps absent-key semantics under BoolIfExists
aws:MultiFactorAuthPresent, and an unknown aws: key fails closed.

Trust path (test_auth_integration.py): the TLS guard on AssumeRole no longer
blocks over TLS, and aws:PrincipalType resolves for the calling principal.

Expected outcomes measured live on DynamoDB, IAM and STS in us-east-1. Pass on
SQLite and PostgreSQL (identity and trust) and on MongoDB and Cassandra (trust).
…pulated ones as absent

The earlier rule kept an allowlist of globally absent condition keys and
failed closed on every other aws: global key. That over-denied statements on
keys the service populates for ExtendDB's callers but ExtendDB had not yet
resolved, and on keys the service treats as absent.

Change the default for an unresolved aws: global key from fail closed to
absent, matching the service, and resolve the keys the service populates:

- Organization keys (aws:PrincipalOrgID, aws:PrincipalOrgPaths, and the
  resource and source organization keys) are absent. An ExtendDB account is a
  standalone AWS account, where IAM leaves these keys unset, so absent key
  semantics apply.
- aws:userid resolves to the principal unique id: an AIDA id for a user and
  role-id:session-name for a role session, derived deterministically from the
  catalog identity (new policy::principal_id module). The format and the
  role-session shape were measured live.
- aws:SourceIp and aws:UserAgent resolve from the request. IpAddress and
  NotIpAddress are now evaluated as IPv4 and IPv6 CIDR membership, with a
  family mismatch matching neither operator, as measured live.
- aws:ResourceAccount resolves to the resource owner account.

The fail-closed set is now empty, kept as an explicit list so a future global
key can be added if the service begins populating it before ExtendDB resolves
it. Unit tests cover the operators, the userid derivation, and the revised
classification.
…t to authorization

Supply the condition keys the auth crate now resolves. On the DynamoDB path the
handler reads the connection peer address and the User-Agent header and passes
them to authorize_request, which sets them on the request parameters for both
the single-table and batch/transact branches; the authorization layer sets
aws:userid (AIDA for a user, role-id:session for a role session) and
aws:ResourceAccount (the resource owner account). On the AssumeRole path the
trust evaluator receives the call's peer address and User-Agent and derives
aws:userid from the caller ARN and aws:ResourceAccount from the role account.
The peer address is used directly; an X-Forwarded-For header is never trusted.
…zation keys

Cover the round-three keys on the identity path (test_abac.py) and the trust
path (test_auth_integration.py): aws:userid (AIDA for a user), aws:SourceIp with
IpAddress and NotIpAddress on the loopback, aws:UserAgent with StringLike,
aws:ResourceAccount, and the organization keys absent for a standalone account.

Update the earlier organization-key and unknown-key rows to the revised rule:
an absent organization key and an unknown aws: key now keep absent-key
semantics (a positive operator does not match, a negated operator matches)
rather than failing closed, matching live IAM for a standalone account. The
unknown-key negated-allow row changes from DENY to ALLOW.
…d key list

Update ADR-0022 and the admin guide for the revised rule: ExtendDB resolves
every global key the service populates for its callers (adding aws:userid,
aws:SourceIp, aws:UserAgent and aws:ResourceAccount), treats every other aws:
global key as absent (the organization keys for a standalone account, the VPC,
federation, MFA and service keys, and any unknown key), and keeps an empty
fail-closed list. The admin guide gains the full classified key list: resolved,
absent, and fail-closed (none). IpAddress and NotIpAddress are now evaluated as
CIDR membership. differences-from-dynamodb.md is unchanged.
A user or role deleted and recreated with the same name kept the same
aws:userid, so an identity or trust policy pinned to the old id still
granted the new principal. IAM issues a new unique id for each
incarnation. Derive the id from the principal's creation time in addition
to its account and name, so each incarnation gets a distinct id. The
creation time comes from the existing iam_users and iam_roles created_at
columns, so there is no schema change; it is cached per principal and the
cache entry is dropped when the principal is deleted. Every place that
resolves aws:userid uses the same derivation: the identity path for a user
and for a role session, and the trust path for the caller.
The service populates aws:TokenIssueTime with a role session's issue time
and leaves it absent for an IAM user. ExtendDB left it absent for both, so
a role-session policy conditioning on the key (for example a DateGreaterThan
test) could not be satisfied. Resolve it for a role session from the
session's stored issue time (iam_sessions.created_at) formatted as RFC 3339,
threaded through the session-data fetch into the request context. IAM users
keep it absent, so their absent-key behaviour is unchanged. The MongoDB
session store now records the issue time at creation; the other backends
already stored it.
… recreate

aws:TokenIssueTime: a role session resolves it (Null true denies, a
DateGreaterThan on a past point grants, on a future point denies) and an
IAM user leaves it absent (Null true grants, a positive Date operator
denies). aws:userid: a policy pinned to the id of a user's or role's first
incarnation grants it and denies the same-named principal after a delete
and recreate. The expected id is derived in the test from the creation time
the management list returns, the same value the server hashes.
Formatting and a clippy redundant-closure fix in the principal creation-time
and TokenIssueTime code added for the two changes above.
An IPv4 client that reaches a server bound to :: arrives with the
IPv4-mapped peer address ::ffff:a.b.c.d. aws:SourceIp carried that form, so
an IPv4 CIDR in IpAddress never matched, and a Deny with NotIpAddress on an
IPv4 range never fired. DynamoDB evaluates such a client as its IPv4
address. aws:SourceIp now converts a mapped address back to IPv4 on the data
plane and on AssumeRole.
The live service resolves an IAM condition key name without regard to case,
including the aws: prefix and the tag prefix: aws:securetransport,
AWS:SecureTransport and aws:SecureTransport resolve the same key, and
aws:principaltag/team resolves the same tag as aws:PrincipalTag/team. ExtendDB
matched key names exactly, so a miscased resolvable key read as absent: a Deny
gated on a miscased key was dropped and a negated-operator Allow on one
over-granted.

Fold key-name case in both RequestContext and AssumeRoleContext resolve_key
(lower-case the key for the global/dynamodb/sts match, strip the tag prefixes
case-insensitively), and in is_multivalued_key and the fail-closed lookup. The
tag sub-key was already matched case-insensitively. Policy write validation
matches on operator names, not key names, and does not reject a miscased key,
matching the service.
The live service populates aws:MultiFactorAuthPresent for a role session (false
for a session assumed without MFA) and leaves it absent for an IAM user with
long-term keys. ExtendDB never resolved it, so the common deny-without-MFA
guard (Deny if Bool aws:MultiFactorAuthPresent=false) was dropped for every
role session and the request was granted where the service denies it.

Resolve the key to false for a role-session caller on the identity path and
leave it absent for an IAM user, matching the service measured live (role
session DENY, IAM user ALLOW on the guard). aws:MultiFactorAuthAge stays absent
for both callers, which also matches the service for a non-MFA session.
A condition value for IpAddress or NotIpAddress that is not a valid CIDR or
address was accepted and the statement then silently did nothing: an IpAddress
allow never granted and, more importantly, an IpAddress or NotIpAddress Deny
never blocked.

Measured live: IAM rejects a malformed CIDR (10.0.0.0/33, 999.0.0.0/8,
not-a-cidr, 10.0.0.0/) in an identity policy (PutUserPolicy, PutRolePolicy) and
a managed policy (CreatePolicy) with MalformedPolicyDocument, and accepts a
malformed CIDR in a trust policy (UpdateAssumeRolePolicy); live STS then drops
the malformed-CIDR trust statement rather than failing closed.

Match the service. PolicyDocument::from_json now rejects a malformed CIDR, so an
identity policy and a permissions boundary are rejected at write and a stored
identity policy fails closed at read (it no longer parses, so the request is
denied). The trust path is left unchanged: it accepts the value at write and the
evaluator drops the statement, which already matches the service. matcher gains
is_valid_cidr; the boundary write path now strict-parses the document.
The live service rejects a tag key or value outside its grammar
([\p{L}\p{Z}\p{N}_.:/=+\-@], excluding combining marks and most punctuation),
a key over 128 or value over 256 characters, and the reserved aws: key prefix,
with ValidationError; ExtendDB accepted them. Add validate_iam_tags and apply it
at tag_user, tag_role and the AssumeRole session tags, alongside the ASCII-case
duplicate-key check. Measured: the service accepts letters, digits, spaces, the
listed symbols, a precomposed accented letter and an empty value, and rejects a
combining mark, # and !, the aws: prefix, and over-length key or value. Unit and
wire tests for the accepted and rejected sets.
case_fold allocated and binary-searched the fold table for every character of
every condition key name and tag key, including the all-ASCII keys that are the
overwhelmingly common case. Add an ASCII fast path: an all-ASCII input folds to
its ASCII lowercase directly, since every ASCII letter's fold is its ASCII
lowercase. The byte-exact tag lookup already resolves the common case without
folding at all. Regenerated through devtools/generate-casefold-table; a unit test
pins the fast path's equivalence with the table on ASCII input.
…rules, CIDR forms

Add an upgrade-manual entry: a stored identity policy whose condition key name or
tag key contains a non-ASCII letter that folds to a resolvable key now evaluates
it on the request path (a Deny applies, a negated Allow no longer grants); the
trust path matches ASCII case only. Fold-colliding tag keys resolve
deterministically (exact key, then smallest by code point). Tag writes now apply
IAM's key/value charset, length, aws: prefix and ASCII-case uniqueness rules,
with tags already stored left in place. IpAddress accepts a leading-zero octet as
decimal and rejects an IPv4-mapped IPv6 literal.
The generator now pipes its output through rustfmt (edition 2024) so the
generated crates/auth/src/policy/casefold.rs is rustfmt-clean and regeneration is
idempotent with cargo fmt --check.
IAM's tag grammar is [\p{L}\p{Z}\p{N}_.:/=+\-@]: any Unicode Letter, Number or
Separator, plus the listed symbols. The previous is_iam_tag_char approximated
these with char::is_alphabetic, char::is_numeric and an ASCII-space literal,
which diverges from the service in both directions. is_alphabetic is the
Alphabetic property, a superset of \p{L} that admits Other_Alphabetic combining
marks such as U+0345 (category Mn) that IAM rejects, so ExtendDB stored a tag key
the service refuses. An ASCII-space-only separator check rejected the \p{Z}
separators U+00A0, U+2002, U+2003, U+3000, U+2028, U+2029 and U+202F that IAM
accepts, so ExtendDB refused keys the service stores and reversed its own prior
behaviour. Match the exact general categories through unicode-properties'
general_category_group (Letter = Lu Ll Lt Lm Lo, Number = Nd Nl No, Separator =
Zs Zl Zp), already in the lock file. Applied at tag keys and values for tag_user,
tag_role and the AssumeRole session tags. Unit tests for the seven separators,
Nl and No numbers, the two Mn marks and an emoji symbol.
Live IAM accepts a Sid only when it is empty or entirely ASCII letters and
digits, regardless of length, and rejects a hyphen, underscore, space, dot or
any non-ASCII character (including Latin-1 such as an accented letter) with
MalformedPolicyDocument, on both CreateRole (trust) and PutUserPolicy
(identity). ExtendDB stored any Sid. Apply the measured rule: from_json rejects
such a Sid on the identity-policy write and read path, and validate_trust_policy
rejects it on the trust write. Unit tests on both paths for the accepted
(alphanumeric, empty, long) and rejected (hyphen, underscore, space, dot,
accented) values.
The collision guard tagged one user and issued 20 GetItem calls. A principal's
tag map is cached per user, so all 20 calls saw a single HashMap order and the
test passed on the unfixed code about a third of the time, missing the
regression. Resolve a fresh user for each of 20 checks so each gets its own map
order, and cover both the byte-exact key (aws:PrincipalTag/mass) and the
no-exact folding key (aws:PrincipalTag/MASS, which folds to mass and must pick
the smallest fold-matching key by code point). Against the unfixed binary both
checks fail on every one of five runs; against the fixed head both pass.
The trust-path folding guard gated on aws:SecureTran[sharp-s]port, which case-folds to
aws:securetranssport and never equals the target key, so it passed on a folding
build too and did not exercise the behaviour change. Replace it with two keys
that fold to real keys: sts:RoleSe[sharp-s]ionName folds to
sts:rolesessionname and aws:PrincipalTag/cla[sharp-s] folds to
aws:PrincipalTag/class for a caller tagged class=prod. Both would match and
grant under Unicode folding; on the ASCII-only trust path the key reads absent
and the assume is denied. Verified: both deny on the fixed head and both grant
(assertion fails) on the unfixed binary.
Replace the upgrade-manual sentence saying re-tagging a principal requires
correcting a stored tag the new rule rejects: only the tags named in a request
are validated, so a stored tag does not block setting other tags and is rejected
only when a later request includes it, which is what both ExtendDB and IAM do.
State the tag grammar as the exact Unicode general categories (Letter, Number,
Separator) rather than an ASCII approximation. Add a note that a policy Sid is
accepted only when empty or ASCII alphanumeric on both the trust and
identity-policy writes, and a stored policy with a non-conforming Sid keeps
evaluating since the Sid is a label.
The Sid charset check ran inside PolicyDocument::from_json, which is also
the request path for stored identity policies. A policy stored before the
check existed with a Sid such as Allow-Read then failed to parse on every
request, and every request by that principal was denied, including those a
different valid policy allowed. The upgrade manual says such a policy keeps
evaluating.

Move the check to PolicyDocument::from_json_for_write, used by the inline
and boundary policy writes. from_json keeps the checks that apply when a
stored policy is read. Unit tests assert both: a non-conforming Sid is
rejected at write and still parses on the request path.
…ersions

is_iam_tag_char kept the doc comment from before the general-category fix,
which described an ASCII-space check that no longer exists. Remove it. Also
record that the tag charset uses Unicode 15.1 general categories and the
case-fold table is Unicode 15.0.0, and why the fold table holds under 15.1.
…e API

The console create-role and add-policy forms checked only that the document
parsed as JSON, then stored it. The management API runs the trust-policy
validation and the identity-policy write checks first, so a document it
rejects could still be stored through the console. A role created there
with Principal "*", Action "*" or a Resource field was then assumable,
by a caller in another account in the first case. The service rejects all
three at CreateRole.

Both console forms now run the same checks as the management API before
storing, and show the error on the form. Console tests cover the three
trust-policy shapes, a malformed CIDR and a non-alphanumeric Sid; they
fail on the previous build and pass on this one.
@navareamol

Copy link
Copy Markdown
Collaborator

Verdict: Approve with nits

The engine is correct and the fail-closed posture is genuine where it's engaged. Nothing blocking, but a few things the author/reviewers should act on.

What holds up (the security-critical parts)

  • Explicit Deny precedence is intact. The 5-phase IAM order is preserved, and the new three-valued ConditionMatch gets the asymmetry right: a Deny applies on Match or Unresolvable; an Allow grants only on Match. The same logic backs both the
    DynamoDB request path and the AssumeRole trust path, so they can't drift.
  • The console gap the PR set out to close is actually closed. Both role_pages.rs and policy_pages.rs now call the same validate_trust_policy / PolicyDocument::from_json_for_write as the management API. A Principal:"*" / wildcard-ARN /
    Resource-bearing trust policy is rejected at write through the console now, not just the API.
  • Case-folding introduces no bypass. DynamoDB path uses Unicode full case-folding (generated table + ASCII fast path, both test-guarded); the trust path is deliberately ASCII-case-only (measured against live STS, and strictly narrower, i.e. the
    safe direction). Tag lookup is deterministic and never crosses principals, so a fold collision can't resolve to another principal's key.
  • CIDR parsing has no permissive-parse bug. ip_in_cidr returns Option; a malformed or cross-family CIDR makes both IpAddress and NotIpAddress false — it never degrades to match-all.
  • Cache is sound. Write-through invalidation at every mutation site; delete cascades; re-assume drops the stale session entry. A revoked key/policy can't be served stale on the local instance; the only window is off-instance, bounded by TTL and
    already documented under one-instance-per-catalog.

Findings worth acting on (none blocking)

  • Medium — FAIL_CLOSED_GLOBAL_KEYS is empty. The whole three-valued "fail closed on unresolvable keys" machinery is architecturally complete but currently inert: no key is ever classified Unresolvable, so every unresolved key falls through to
    absent-key semantics. That's the correct choice for a standalone account, but the safety net catches nothing today — confused-deputy protection currently rests entirely on the "negated operator on absent key" rule. Recommend a regression test
    proving that a key added to the set actually fails closed end-to-end, so the mechanism is proven before an org/VPC deployment ever needs it.
  • Medium — BR-7085 upgrade hazard. A bare operator on dynamodb:Attributes/LeadingKeys is now a no-op (matches AWS). That silently turns a previously-effective bare-operator Deny denylist into a no-op on upgrade. It's documented in the upgrade
    manual, but the "audit your Deny statements" step should be prominent in the release notes.
  • Low — BinaryEquals write/eval asymmetry. Accepted by validate_trust_policy at write but not implemented in parse_base_operator, so a trust Allow using it silently never grants (fail-closed, but a footgun). Either reject it at write or
    implement it.
  • Low — policy-variable ${...} keys bypass the Unresolvable check. Fail-closed for an Allow, but inconsistent with how condition keys are handled.
  • Informational — {"AWS":"*"} trust principal is accepted and assumable by any asserted caller ARN. Matches IAM; worth a one-line doc callout.

@navareamol navareamol left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review comments have been left on the PR.
Approving as there are minor suggestions which can be fast follow.

@jcshepherd jcshepherd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@LeeroyHannigan
LeeroyHannigan added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit ba53851 Oct 22, 2026
34 checks passed
@LeeroyHannigan
LeeroyHannigan deleted the fix/trust-policy-deny branch October 22, 2026 08:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants