diff --git a/crates/core/src/arn.rs b/crates/core/src/arn.rs index 408d368cc..cdcbf0f2c 100644 --- a/crates/core/src/arn.rs +++ b/crates/core/src/arn.rs @@ -14,8 +14,8 @@ //! //! [`parse`] requires the literal `arn:` prefix and five colon-separated fields. //! The partition, service, region, and account are captured but not constrained -//! to any value: a caller that pins them to its deployment does so itself, and a -//! caller that ignores them (authorization canonicalizes every supplied ARN to +//! to any value: a caller that pins them to its deployment does so itself +//! ([`ArnFields::in_deployment`]), and a caller that ignores them (authorization canonicalizes every supplied ARN to //! its own deployment) reads only the table and sub-resource. /// A table sub-resource: a secondary index, a stream, or a backup. @@ -49,6 +49,18 @@ pub struct TableResource { pub sub: Option, } +impl ArnFields<'_> { + /// True when the partition is `aws`, the service `dynamodb` and the region + /// `region`, each compared exactly. Every ARN the server issues carries + /// these three values. The server's `StreamArn` scope check and the engine's + /// tag `ResourceArn` check both refuse an ARN for which this is false; the + /// account and resource are not compared. + #[must_use] + pub fn in_deployment(&self, region: &str) -> bool { + self.partition == "aws" && self.service == "dynamodb" && self.region == region + } +} + /// Split a string into the five ARN fields after the `arn:` prefix. /// /// The resource field keeps every `:` and `/` it contains, so a stream ARN's @@ -148,6 +160,34 @@ mod tests { assert_eq!(f.resource, "table/T"); } + #[test] + fn in_deployment_compares_partition_service_and_region_exactly() { + let arn = "arn:aws:dynamodb:us-east-1:1:table/T/stream/2026-10-20T16:17:40.188"; + assert!(fields(arn).unwrap().in_deployment("us-east-1")); + // The account and the resource are not compared. + assert!( + fields("arn:aws:dynamodb:us-east-1:2:table/U") + .unwrap() + .in_deployment("us-east-1") + ); + for other in [ + arn.replace("arn:aws:", "arn:aws-cn:"), + arn.replace("arn:aws:", "arn:aws-us-gov:"), + arn.replace("arn:aws:", "arn::"), + arn.replace(":dynamodb:", ":DynamoDB:"), + arn.replace(":dynamodb:", ":DYNAMODB:"), + arn.replace(":dynamodb:", ":s3:"), + arn.replace("us-east-1", "us-west-2"), + arn.replace("us-east-1", "US-EAST-1"), + arn.replace("us-east-1", ""), + ] { + assert!( + !fields(&other).unwrap().in_deployment("us-east-1"), + "{other}" + ); + } + } + #[test] fn parses_table_index_stream_backup() { assert_eq!( diff --git a/crates/engine/src/streams.rs b/crates/engine/src/streams.rs index c8ba419bc..e57328de0 100755 --- a/crates/engine/src/streams.rs +++ b/crates/engine/src/streams.rs @@ -314,8 +314,9 @@ pub async fn handle_get_records( // Cassandra after reading the table. A forged token that pairs an // authorized stream ARN with another stream's shard id reads nothing and // gets the "stream does not exist" answer. A token with no stream ARN gets - // the answer the authorization layer gives it (`classify_shard_iterator`); - // the layer is skipped in developer mode, so the engine answers the same. + // the answer the authorization layer gives it (`classify_shard_iterator`) + // in every build, developer mode included; the engine answers the same for + // a caller that reaches it without the layer. let Some(stream_arn) = it.stream_arn.as_deref() else { return Err(no_stream_arn_error(&it)); }; diff --git a/crates/engine/src/tagging.rs b/crates/engine/src/tagging.rs index 432d75dd4..d9b674386 100755 --- a/crates/engine/src/tagging.rs +++ b/crates/engine/src/tagging.rs @@ -34,6 +34,43 @@ fn tag_subresource_not_table_or_stream(resource: &str) -> bool { !matches!(segs.next(), None | Some("") | Some("stream")) } +/// True for a `table//stream/