Skip to content

fix(server): refuse stream ARNs from another account or region - #466

Open
LeeroyHannigan wants to merge 14 commits into
mainfrom
fix/stream-arn-account-check
Open

LeeroyHannigan wants to merge 14 commits into
mainfrom
fix/stream-arn-account-check

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

DescribeStream and GetShardIterator served the caller's own stream for a StreamArn that named another account (#450), or another partition, another region, an upper-cased region, or the service spelled DYNAMODB (#451). Authorization rebuilt every stream ARN as arn:aws:dynamodb:<server region>:<caller account>:table/<name>/stream/<label> and the stream engine resolved the stream by table name and label, so the fields a client might get wrong were ignored on both sides. The tag operations answered the same respelled stream ARN with "Invalid TableArn: Invalid ResourceArn provided as input ".

A stream ARN is now checked before the IAM decision: on StreamArn for its account, partition, service and region; on a tag operation's ResourceArn for its account; inside a GetRecords shard iterator for all four fields. A tag operation's stream ARN is authorized as the caller spelled it, and the engine refuses another partition, service or region after authorization with "Invalid StreamArn". Each answer matches DynamoDB as measured on 2026-10-23, apart from two ResourceArn cells noted under Measurement.

These checks run before the dev-mode return in authorize_request, so a dev-mode build (the extenddb-dev image and the npm launcher) refuses the same stream ARNs; dev mode skips only the IAM decision. On StreamArn the checks run in the service's order: the ARN's shape first (an empty partition included), then the account, then the partition, service and region values.

Measurement

Live DynamoDB, scratch account, us-east-1, 2026-10-23 12:22 to 12:27 UTC. Stream arn:aws:dynamodb:us-east-1:<account>:table/bb-arn-main/stream/2026-10-20T16:17:40.188. Four principals: admin (Allow dynamodb:* on *), noperm (no stream permissions), exact (Allow dynamodb:* on the canonical stream ARN only), deny (Allow * plus Deny dynamodb:* on the canonical stream ARN).

StreamArn on DescribeStream and GetShardIterator, the same answer for all four principals:

StreamArn spelling Answer
canonical 200 (admin, exact); IAM denial (noperm, deny)
account 111111111111, account abc, another account with aws-cn, us-west-2 or an unknown label AccessDeniedException "Access is denied"
empty account, aws-cn, aws-us-gov, DynamoDB, DYNAMODB, us-west-2, US-EAST-1, extra field, missing region field, trailing slash ValidationException "Invalid StreamArn"
unknown label IAM denial (noperm, exact); DescribeStream "Requested resource not found: Stream: not found", GetShardIterator "Requested resource not found: Stream does not exist" (admin, deny)

ResourceArn on ListTagsOfResource, TagResource and UntagResource:

ResourceArn spelling admin, deny noperm, exact
account 111111111111, abc, empty account, missing region field AccessDeniedException "Access is denied" same
extra field, trailing slash ValidationException "One or more parameter values were invalid: Provided Arn is not a DynamoDB resource arn: " same
aws-cn, aws-us-gov, DynamoDB, DYNAMODB, us-west-2, US-EAST-1 ValidationException "Invalid StreamArn" AccessDeniedException "User: is not authorized to perform: dynamodb: on resource: because no identity-based policy allows the dynamodb: action"
unknown label ResourceNotFoundException "Requested resource not found: ResourceArn: not found" IAM denial

So on a tag operation the account is checked before the IAM decision, the IAM decision runs on the ARN as sent, and the partition, service and region are checked after it.

Two of these cells are answered differently by this server, and test_stream_arn_scope.py does not assert them. A missing region field gets "One or more parameter values were invalid: Provided Arn is not a DynamoDB resource arn: ", because the shape check reads the shifted fields first. An unknown label gets 200 on all three operations (#452).

GetRecords with a token the service did not issue (canonical, another account, aws-cn, us-west-2, random bytes): ValidationException "Invalid ShardIterator" for admin and noperm.

UntagResource, measured 14:03 to 14:09 UTC for the same four principals and spellings, matches the ListTagsOfResource and TagResource rows in every cell.

The service checks a StreamArn's shape first (an empty partition or service field), then the account, then the region. Measured on 2026-10-28 in us-east-1 as the admin principal, two samples per row: DescribeStream from 20:47 to 20:48 UTC and GetShardIterator at 21:51 UTC, except the empty-service row, measured on both operations at 21:51 UTC.

StreamArn DescribeStream GetShardIterator
empty partition, account 111111111111 or the caller's ValidationException "Invalid StreamArn" the same
empty service, account 111111111111 or the caller's ValidationException "Invalid StreamArn" the same
account 111111111111 with an empty region, with us-west-2, or with every other field the caller's AccessDeniedException "Access is denied" the same
the caller's account with an empty region or us-west-2 ValidationException "Invalid StreamArn" the same

What changed

crates/server/src/authz_resource/stream_scope.rs (new)

  • check_stream_arn_account: the account must be the caller's, AccessDeniedException "Access is denied".
  • check_stream_arn_scope: the whole StreamArn check, in the measured order. A value that is not a stream ARN, or has an empty partition, service or account, is ValidationException "Invalid StreamArn"; then another account is AccessDeniedException "Access is denied"; then a partition other than aws, a service other than dynamodb or a region other than the server's, an empty region included, is "Invalid StreamArn".

crates/server/src/authz_resource.rs

  • validate_operation_arns applies check_stream_arn_scope to StreamArn on DescribeStream and GetShardIterator. The kind check it replaces for StreamArn refused an empty region before the account check; it stays for BackupArn as arn_is_backup.
  • validate_tag_resource_arn_shape applies check_stream_arn_account to a stream ARN in ResourceArn.
  • tag_resource_arn passes a stream ARN to authorization as sent instead of canonicalizing it. A table ARN is still canonicalized.
  • getrecords_resource refuses an iterator whose stream ARN is outside scope as Invalid. GetShardIterator now refuses such a StreamArn, so it issues no such token.
  • The unit test that asserted an aws-cn, s3, us-west-2 or other-account stream ARN canonicalizes to the deployment's ARN is replaced by tests for each measured answer.

crates/server/src/request_helpers.rs: passes the region and the caller's account to validate_operation_arns, and runs the tag ResourceArn shape check, validate_operation_arns and the GetRecords iterator classification before the dev-mode return. The GetRecords IAM check stays after it.

crates/engine/src/tagging.rs: a stream ARN with another partition, service or region answers "Invalid StreamArn" instead of "Invalid TableArn: Invalid ResourceArn provided as input ". Table ARNs are unchanged.

crates/core/src/arn.rs: ArnFields::in_deployment, the partition, service and region rule. check_stream_arn_scope and the engine's tag check both call it. The tag check's refusal moves into deployment_scope_error, which reads no storage, so a unit test can reach it.

docs/manuals/07-upgrade-manual.md: a client-built arn:aws-cn: or arn:aws-us-gov: StreamArn is now refused.

Tests

  • tests/test_stream_arn_scope.py (new): the two tables above on the wire for the four principals, five operations (DescribeStream, GetShardIterator, ListTagsOfResource, TagResource, UntagResource) and seventeen spellings, with message text; a Deny on the canonical ARN not evaded by any spelling; forged GetRecords tokens; the unknown-label messages of DescribeStream and GetShardIterator. The file has ten tests, and seven of them fail against main at 1d0d8f4 (the canonical ARN, malformed ARN and unknown label tests pass there).
  • tests/test_stream_arn_scope.py::test_partition_then_account_then_region: the nine 2026-10-28 cells of the Measurement table, on DescribeStream and GetShardIterator. The test fails if the kind check runs ahead of the account check (the row for another account with an empty region answers "Invalid StreamArn") or if the empty-service check is removed (the row for another account with an empty service answers "Access is denied").
  • tests/test_dev_mode_authorization.py::TestDevModeKeepsStreamArnScope (run by run-integration-dev-mode): DescribeStream and GetShardIterator with an aws-cn StreamArn ("Invalid StreamArn") and with account 111111111111 ("Access is denied"); TagResource and ListTagsOfResource with the issued stream ARN plus a trailing slash ("Provided Arn is not a DynamoDB resource arn: /"); GetRecords with a hand-built token naming account 111111111111 ("Invalid ShardIterator") and a token in the pre-upgrade shape ("Shard iterator has expired"). With the checks after the dev-mode return, a dev-mode server answers 200 to all eight requests, and TagResource writes a tag under the trailing-slash ARN.
  • tests/test_authz_resource_arn.py::test_respelled_stream_arn_cannot_bypass_deny: expects "Invalid StreamArn" instead of AccessDeniedException; fails on main.
  • tests/test_cross_account_isolation.py::TestStreamAccountScoping: a current-format token naming account A's stream, sent by account B, is "Invalid ShardIterator" instead of ResourceNotFoundException; fails on main.
  • Unit: in_deployment_compares_partition_service_and_region_exactly in core, and stream_arn_scope_uses_the_shared_predicate and deployment_scope_uses_the_shared_predicate, which compare each caller's answer with the predicate for the aws-cn, aws-us-gov, DynamoDB, DYNAMODB, us-west-2, US-EAST-1 and empty-region spellings.
  • The commands and counts run on the rebased head are under "Rebase on fix(streams): resolve the shard against its stream inside the GetRecords read #543".

The order cells were measured as the admin principal only, and no service measurement covers "Shard iterator has expired" for a pre-upgrade token, which is this server's answer to an iterator issued before the upgrade.

Behaviour an upgrader will notice

  • DescribeStream and GetShardIterator with a StreamArn whose account is not the caller's return AccessDeniedException "Access is denied" instead of the caller's stream. With another partition, region or service spelling they return ValidationException "Invalid StreamArn". An empty partition, service or account field, or a value that is not a stream ARN, is "Invalid StreamArn" whatever the account; another account with an empty region field is "Access is denied".
  • TagResource, UntagResource and ListTagsOfResource with a stream ARN in another account return AccessDeniedException "Access is denied" for every principal (before, a no-permissions principal got the IAM denial). With another partition, region or service spelling an authorized principal gets "Invalid StreamArn" (was "Invalid TableArn: Invalid ResourceArn provided as input "); a principal whose policy names only the canonical stream ARN is now denied that spelling, because the ARN is authorized as sent.
  • A GetRecords shard iterator whose stream ARN is outside the deployment's scope returns ValidationException "Invalid ShardIterator" (was: canonicalized and served within the caller's account, or ResourceNotFoundException across accounts). Iterators issued from the canonical stream ARN are unaffected. An iterator the previous build issued from a respelled StreamArn (aws-cn, US-EAST-1, DYNAMODB), and every iterator chained from it, gets that answer after the upgrade rather than ExpiredIteratorException, as does every iterator issued before a change of server.region. The consumer has to call GetShardIterator again.
  • The StreamArn refusal also applies on a deployment whose server.region is a China or GovCloud region: a client there that builds an arn:aws-cn: or arn:aws-us-gov: StreamArn itself was served before and is refused now. The server issues every stream ARN as arn:aws:, so ARNs taken from DescribeTable or ListStreams are not affected.
  • A dev-mode build refuses the same StreamArn, ResourceArn and GetRecords iterator values as any other build, with the same answers. It also applies the existing StreamArn, BackupArn and export TableArn kind checks, which it skipped before: a table ARN or an empty-partition ARN as StreamArn is ValidationException "Invalid StreamArn" (was "Invalid stream ARN format: " for a table ARN, and 200 for an empty partition).

Rebase on #543

Rebased onto main at 1d0d8f4. #543 moved the stream not-found messages into extenddb_storage::util::arn and dropped the trailing period from DescribeStream's "Stream: not found" (the message for a shard that is not one of the stream's shards keeps it), so all four stream engines take main's side and this PR no longer changes them. In request_helpers.rs the GetRecords branch keeps if let Some(resource_arn) = getrecords_stream, with #522's base_params(request_tags, request_tag_keys) and #543's sentence about the storage read. In authz_resource.rs the backup kind check trims the ARN with extenddb_core::backup_format::trim_backup_arn, as #394 does, and ArnKind is removed. test_unknown_label also pins GetShardIterator's "Requested resource not found: Stream does not exist", which main answers since #543.

Also on this head:

  • extenddb_core::arn::ArnFields::in_deployment holds the partition, service and region rule. check_stream_arn_scope and the engine's tag check both call it, and the tag check's refusal moves into deployment_scope_error, which reads no storage, so a unit test can call it. A unit test in each caller compares its answer with the predicate; with a case-folded region compare in either caller, its test fails.
  • The comment above validate_operation_arns lists only the fields it checks. The getrecords_resource docs say which iterators get Invalid ShardIterator after the upgrade.
  • docs/manuals/07-upgrade-manual.md notes that a client-built arn:aws-cn: or arn:aws-us-gov: StreamArn is refused.

Checked on the rebased head, debug builds:

  • cargo +1.97.0 fmt --all --check; cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings; cargo +1.97.0 check --locked --workspace --all-targets; cargo +1.88.0 check --locked --workspace.
  • cargo +1.97.0 test --locked --lib: extenddb-core 698 passed (1 ignored), extenddb-engine 184, extenddb-server 240.
  • Wire, SQLite and PostgreSQL, the same counts on both: test_stream_arn_scope.py 10/10, test_authz_resource_arn.py 43/43, test_cross_account_isolation.py 11/11, tests/python/test_streams.py 16/16, test_stream_shard_ownership.py 3/3, test_tagging.py 7/7, and test_abac, test_auth_permissions, test_misc_operations, test_stream_table_reuse, test_ttl, test_put_item_create_race and the five test_tag_request_* files, 262/262.
  • tests/test_dev_mode_authorization.py on a dev-mode build: 8/8.
  • test_stream_arn_scope.py against a SQLite server built from main: 7 of 10 fail. The canonical ARN, malformed ARN and unknown label tests pass there.
  • CI passed all 35 checks on 5ebcfb4.

Fixes #450
Fixes #451

robinnsc
robinnsc previously approved these changes Oct 31, 2026

@robinnsc robinnsc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good to me. On local SQLite servers with two accounts and about a dozen principals, we found no cross-account or cross-table read on head. The principals covered Allow and Deny on the table ARN, the stream ARN and stream/*, wildcard account and partition, and aws:ResourceTag conditions. The shapes we threw at it: respelled ARNs, forged and foreign-minted GetRecords tokens, ExclusiveStartStreamArn and ExclusiveStartShardId from other streams, sequence numbers from other accounts' streams, and in-scope ARNs paired with denied shards. #450 reproduces on main, where B's admin naming A's account read B's records, and head refuses it. The canonical ARN gets the same answers as main for every principal, so we saw no false denies. test_stream_arn_scope.py is 10/10 here and 8/10 fail on main, and the dev-mode mutant (return moved back above the checks) fails TestDevModeKeepsStreamArnScope. Latency is unchanged.

Merge notes, since it conflicts with main in six files:

  • Stream engines: take main's side in all four. #543 already moved these messages into extenddb_storage::util::arn, and shard_not_in_stream keeps its period on purpose, so this branch's third hunk per engine would undo that. After the merge the PR changes nothing there, so the description's lines about the trailing period can go.
  • authz_resource.rs and request_helpers.rs: inline below. With those, the resolved tree passed the server lib tests (239), the dev-mode file (8/8), and main's test_streams.py plus test_stream_shard_ownership.py (19/19).
  • Main's engine/src/streams.rs:316 comment that the authorization layer "is skipped in developer mode" won't be true for GetRecords after this.

Small notes:

  • A cn-* or us-gov deployment now refuses a client-built arn:aws-cn: / arn:aws-us-gov: StreamArn, which main served. ARNs the server issues are always arn:aws:, so only hand-built ones are affected. Maybe a line in the upgrade notes.
  • Pre-existing: tag operations on a stream ARN only check that the table exists, so an unknown label (or one with trailing whitespace) stores tag rows under that string. Those rows are never deleted with the table. They don't touch authorization, as far as I can tell.
  • The description says seven of nine fail on main. The file has 10 tests, and 8 fail on the base (7 on current main, since #543 fixed the period).
  • The comment at request_helpers.rs:162 lists SourceTableArn among the ARNs checked before IAM, but validate_operation_arns doesn't check it.

// as the service does. The engine separately confirms the shard belongs to
// that stream before reading, so a token that names an allowed stream but a
// different stream's shard reads nothing.
if let Some(resource_arn) = getrecords_stream {

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.

For the merge: keep this if let Some(resource_arn) = getrecords_stream, and the body needs main's base_params(request_tags, request_tag_keys) from #522. tag_condition_keys can stay after the dev-mode return. The sentence above about the engine separately confirming the shard is the one #543 replaced ("The storage read resolves the shard against that stream as part of the read"), so a take-ours resolution would bring the old wording back.

Comment thread crates/server/src/authz_resource.rs Outdated
arn.trim_matches(|c: char| c.is_ascii_whitespace()),
ArnKind::Backup,
)
&& !arn_is_backup(arn.trim_matches(|c: char| c.is_ascii_whitespace()))

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.

For the merge: main (#394) uses extenddb_core::backup_format::trim_backup_arn(arn) here, the same trim canonical_resource and the typed backup resolver use. So this probably wants to become !arn_is_backup(extenddb_core::backup_format::trim_backup_arn(arn)), otherwise the kind check and the authorized resource trim differently. ArnKind has no users after that.

Comment thread crates/server/src/authz_resource.rs Outdated
/// not parse to a stream is refused as Invalid rather than authorized verbatim,
/// so no respelled or forged stream ARN is served.
/// must parse to a stream and name this deployment's partition, service and
/// region and the caller's account: the server issues no other spelling, so a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"the server issues no other spelling" doesn't quite hold across the upgrade. Main's GetShardIterator encodes StreamArn as sent and accepts respelled ones. Iterators main issued for aws-cn, US-EAST-1 and DYNAMODB spellings, and every NextShardIterator chained from them, got the non-retryable Invalid ShardIterator once head read them from the same database, rather than ExpiredIteratorException. A change of server.region does the same even to canonical iterators. It's a narrow window and the behaviour seems fine. It might just be worth saying "iterators issued from the canonical stream ARN" here and in the description's upgrade notes.

return invalid();
}
check_stream_arn_account(arn, account_id)?;
if f.partition != PARTITION || f.service != SERVICE || f.region != region {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The same partition/service/region rule also lives in engine/src/tagging.rs:138, and the tag path depends on that copy. Since a tag op's stream ARN is authorized as sent, a Deny on the canonical stream ARN doesn't match an aws-cn or DYNAMODB spelling, and only the engine check stops it before a row is written. Today only the tests keep the two in step. One shared predicate in extenddb_core::arn would remove the second copy.

Comment thread tests/test_stream_arn_scope.py Outdated
Deny principal (whose Deny names another label). DescribeStream:
"Requested resource not found: Stream: <arn> not found" with no trailing
period (#451). GetShardIterator: the service says "Requested resource
not found: Stream does not exist"; ExtendDB answers the DescribeStream

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.

nit: after merging main, GetShardIterator answers Stream does not exist for an unknown label, so this "pre-existing difference" goes away. The assertion at :545 could then pin that message too.

…egion

DescribeStream and GetShardIterator served the caller's own stream for a
StreamArn that named another account, another partition, another region,
an upper-cased region or the service spelled DYNAMODB. Authorization
rebuilt every stream ARN with the server's partition, service and region
and the caller's account, and the stream engine resolved the stream by
table name and label, so the fields a client might get wrong were ignored
on both sides. The tag operations answered the same stream ARN with
"Invalid TableArn: Invalid ResourceArn provided as input <arn>".

Measured on DynamoDB on 2026-10-23 for an admin, a no-permissions user, a
user allowed the exact stream ARN and a user with a Deny on it. On
StreamArn, every principal gets AccessDeniedException "Access is denied"
for another account and ValidationException "Invalid StreamArn" for
aws-cn, aws-us-gov, DynamoDB, DYNAMODB, us-west-2 or US-EAST-1, so both
checks run before the IAM decision, account first. On a tag operation's
ResourceArn, another account is "Access is denied" for every principal;
the other spellings are authorized as sent (a no-permissions principal is
denied naming the ARN it sent, a principal allowed only the canonical ARN
is denied the respelled one) and then refused with "Invalid StreamArn". A
GetRecords token the service did not issue is "Invalid ShardIterator".

A stream ARN is now checked in the server before the IAM decision: on
StreamArn for its account, partition, service and region
(check_stream_arn_scope); on a tag operation's ResourceArn for its
account (check_stream_arn_account), with the ARN then authorized as sent
and the engine refusing another partition, service or region with
"Invalid StreamArn" after authorization; inside a GetRecords iterator for
all four fields, refusing the token as Invalid.

Wire tests in tests/test_stream_arn_scope.py cover the four principals,
five operations and seventeen spellings with the measured message text;
test_authz_resource_arn.py and test_cross_account_isolation.py are
updated to the measured answers.

Fixes #450
Fixes #451
The control in test_getrecords_iterator_with_respelled_stream_arn_is_invalid
reads the stream with a hand-built token carrying the issued ARN. The service
refuses every token it did not issue, so the comment now states that the
control passes only because ExtendDB iterators carry no signature, a gap
that exists on main.
…account

tests/python/test_streams.py::test_describe_stream_invalid_arn sent a
StreamArn in account 000000000000 and accepted ResourceNotFoundException or
ValidationException. With this change the server refuses another account
before the IAM decision with AccessDeniedException "Access is denied", the
service's answer measured on 2026-10-23, so the test now asserts that code
and message.
…essages

Three test helpers printed the error returned by the stream ARN checks in
their panic messages, and that text carries the account id from the ARN.
They now name the error variant only.
authorize_request returned for a dev-mode server before the tag
ResourceArn shape check, validate_operation_arns and the GetRecords
iterator classification. A dev-mode build therefore served a StreamArn
naming another account or partition, and a GetRecords token naming
another account, as the caller's own stream.

The service applies these checks to every principal before the IAM
decision. They now run ahead of the dev-mode return, and dev mode skips
only check_authorization. Each check reads the request body, the
configured region and the caller's account, with no lock, await or
storage access.

tests/test_dev_mode_authorization.py, which run-integration-dev-mode
runs, sends DescribeStream and GetShardIterator with an aws-cn StreamArn
(Invalid StreamArn) and with account 111111111111 (Access is denied),
and GetRecords with a hand-built token naming account 111111111111
(Invalid ShardIterator) and with a token in the pre-upgrade shape (Shard
iterator has expired). A dev-mode build of the previous head answered
200 to all six.
The service checks a StreamArn's partition shape, then the account, then
the region (measured on DescribeStream on 2026-10-28). With the region
field empty and another account it answers AccessDeniedException "Access
is denied". ExtendDB answered "Invalid StreamArn" there, because
arn_is_kind required every field to be non-empty before
check_stream_arn_scope compared the account.

check_stream_arn_scope now holds the whole StreamArn check: a stream
resource with a non-empty partition, service and account, then the
caller's account, then the partition, service and region values, where
an empty region is a region other than the server's. arn_is_kind had
only the backup caller left and becomes arn_is_backup.

The seven measured cells are a unit-test table beside respellings() in
stream_scope.rs and wire rows in tests/test_stream_arn_scope.py, whose
module docstring records the measurement. With the previous order the
row for another account with an empty region fails.
The authz_resource module docs said a GetRecords iterator naming another
account answers "Access is denied" and one naming another partition,
service or region answers "Invalid StreamArn". The code answers both
with ValidationException "Invalid ShardIterator", as the tests expect.
The docs also said every resource ARN is canonicalized, while a tag
operation's stream ARN is authorized as sent and refused by the engine's
validate_resource_arn unless its partition, service, region and account
are this deployment's.

The comments cited measurement files and probe names that are not in
the repository. They now cite the measured tables in the module
docstring of tests/test_stream_arn_scope.py and their dates, except the
GetRecords token answer, which keeps its 2026-10-22 citation as main's
iterator comments do.
The variant() doc in stream_scope.rs and a comment in
validate_operation_arns_matches_measured_live_behaviour said the error
text is left out of failure messages because it carries the account id
from the checked ARN. The scope checks answer "Access is denied" and
"Invalid StreamArn", which carry no account id, and the same failure
messages print the ARN. The reason is removed; the helpers are
unchanged.
check_stream_arn_scope refuses a StreamArn with an empty service field
with ValidationException "Invalid StreamArn" before it compares the
account, so that answer holds in another account too. The service gives
the same answer on DescribeStream and GetShardIterator, in another
account and in the caller's (measured 2026-10-28 at 21:51 UTC, two
samples each).

No test sent an empty-service StreamArn, so removing the empty-service
check left every test passing. measured_order() and
test_partition_then_account_then_region now assert both cells on both
operations, and the order table in tests/test_stream_arn_scope.py lists
them with the time they were measured.
authorize_request runs validate_tag_resource_arn_shape before the
dev-mode return, so a dev-mode server refuses a malformed tag
ResourceArn with the same answer as every other build. No dev-mode case
sent a tag operation, so moving that check back below the return left
every test passing, and a dev-mode server then answered 200 to
TagResource with the issued stream ARN plus a trailing slash and wrote
a tag under that ARN.

The new case in TestDevModeKeepsStreamArnScope sends TagResource and
ListTagsOfResource with that ARN and expects ValidationException "One
or more parameter values were invalid: Provided Arn is not a DynamoDB
resource arn: <arn>", the service's answer for every principal
(measured 2026-10-23).
The StreamArn order cells were measured on GetShardIterator on
2026-10-28 at 21:51 UTC, and an empty service field in another account
and in the caller's on both operations at the same time, with the
answers the code gives. The comments still said those cells were
measured on DescribeStream only, or not measured.

The order table header in tests/test_stream_arn_scope.py, the doc of
check_stream_arn_scope, the doc of
stream_arn_checks_follow_the_measured_order and the docstring of
test_partition_then_account_then_region now name both operations and
the 21:51 UTC set. Comments only.
The authz_resource.rs module docs said every resource ARN produced
there is canonical and that a Deny on the real resource always matches,
while a tag operation's stream ARN is authorized as sent. They now name
that exception, and say that on StreamArn the shape step runs before
the account check, so an empty partition, service or account field is
Invalid StreamArn in any account.

The module docstring of tests/test_stream_arn_scope.py said every
answer below it was measured for four principals; the 2026-10-28 order
table was measured as the admin only, and the docstring now says so.
The TestDevModeKeepsStreamArnScope docstring now says that "Shard
iterator has expired" for a pre-upgrade token is this server's own
upgrade answer, which no service measurement covers. Comments only.
check_stream_arn_scope in the server and validate_resource_arn in the
engine tag handler each compared an ARN's partition, service and region
with this deployment's. The tag path depends on the engine copy: a tag
operation's stream ARN is authorized as sent, so a Deny on the canonical
stream ARN does not match an aws-cn or DYNAMODB spelling, and only the
engine check refuses it before a tag row is written. Only the tests kept
the two copies in step.

Both now call ArnFields::in_deployment in extenddb_core::arn. The tag
handler's refusal moves into deployment_scope_error, a function with no
storage access, so a unit test can reach it. Unit tests in core, in
stream_scope.rs and in tagging.rs check each caller's answer against the
predicate for the aws-cn, aws-us-gov, DynamoDB, DYNAMODB, us-west-2,
US-EAST-1 and empty-region spellings. No answer changes.
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

Thanks for the merge notes. I rebased onto main at 1d0d8f4.

  1. Stream engines take main's side in all four, so the PR no longer touches them, and the trailing-period lines are gone from the description and from 9119795's message.
  2. request_helpers.rs keeps if let Some(resource_arn) = getrecords_stream with main's base_params(request_tags, request_tag_keys) in its body, tag_condition_keys stays after the dev-mode return, and main's GetRecords sentence is back (30f3c19).
  3. The backup kind check is !arn_is_backup(extenddb_core::backup_format::trim_backup_arn(arn)), and ArnKind is gone (c35a772).
  4. The engine/src/streams.rs comment says the authorization layer answers a token with no stream ARN in every build, developer mode included (30f3c19).
  5. The comment above validate_operation_arns now lists only the fields it checks, and says a restore's source ARN is refused by the engine after IAM (30f3c19).
  6. The getrecords_resource and stream_arn_in_scope docs now say iterators issued from the canonical stream ARN pass, and that ones issued from a respelled ARN, or before a server.region change, get Invalid ShardIterator (9119795, 4ab378e). The upgrade notes in the description say the same. Three test comments still claimed an out-of-scope iterator was not issued by this server; they now say GetShardIterator issues no such iterator (9119795, 30f3c19).
  7. test_unknown_label pins Stream does not exist for GetShardIterator (9119795).
  8. The upgrade manual has a line on client-built arn:aws-cn: and arn:aws-us-gov: StreamArns (9119795).
  9. The description says the file has 10 tests and 7 fail against main at 1d0d8f4.

The partition, service and region rule is now ArnFields::in_deployment in extenddb_core::arn. check_stream_arn_scope and the engine's tag check both call it, and a unit test on each side compares its answer with the predicate (5ebcfb4).

On the tag rows note: #452 covers the unknown label, and I've added the trailing-space case and the rows that outlive the table there.

The ruleset needs an approval on the last push, so could you take another look at the new head?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants