From b026026a77f75eaa2242a2c733b62bfadebbe259 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 16:43:40 +0000 Subject: [PATCH 01/38] fix(auth): authorize resource-ARN operations against the real resource authorize_request derived the authorization resource only from a top-level TableName. Operations that carry their resource in another field authorized against arn:aws:dynamodb:::table/*, so an explicit Deny on a specific table, index, stream, or backup ARN never matched. In ABAC deployments this let a caller with an explicit Deny read, add, and remove the tags that drive access decisions (TagResource, UntagResource, ListTagsOfResource), and the same gap covered stream, backup, export, import and index operations. Live DynamoDB evaluates these resources (measured 2026-10-20): the tag operations against ResourceArn (a table or stream ARN); DescribeStream and GetShardIterator against StreamArn; ListStreams against table//stream/* or table/*/stream/*; DescribeBackup and DeleteBackup against BackupArn; CreateBackup and the continuous-backup operations against the table ARN; Query and Scan with IndexName against the index ARN; RestoreTableFromBackup against the backup ARN; ExportTableToPointInTime and ImportTable against the table ARN; ListTables against table/*; and a table ARN supplied in TableName against that table. The new authz_resource module maps each operation to the (action, resource ARN) pairs the service authorizes, and canonicalizes every resource to this deployment's partition, service, region and account, taking only the resource identity from the request. ExtendDB's stream and backup resolvers ignore the partition, service, region and account of a supplied ARN and act within this deployment's account, so a respelled ARN would otherwise evade a Deny on the canonical ARN. A missing or unparseable resource field no longer falls back to table/*. GetRecords carries only an opaque shard iterator with no stream identity, so it is authorized against the account stream space; a Deny on a specific stream ARN for GetRecords is not enforced. See the report. --- crates/server/src/authz_resource.rs | 543 +++++++++++++++++++++++++++ crates/server/src/lib.rs | 1 + crates/server/src/request_helpers.rs | 104 ++--- 3 files changed, 605 insertions(+), 43 deletions(-) create mode 100644 crates/server/src/authz_resource.rs diff --git a/crates/server/src/authz_resource.rs b/crates/server/src/authz_resource.rs new file mode 100644 index 000000000..0eced7950 --- /dev/null +++ b/crates/server/src/authz_resource.rs @@ -0,0 +1,543 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Derivation of the (IAM action, resource ARN) pairs that a `DynamoDB` request +//! is authorized against, matching live `DynamoDB`. +//! +//! The defect this fixes: `authorize_request` derived the +//! authorization resource only from a top-level `TableName`. Operations that +//! carry their resource in another field authorized against +//! `arn:aws:dynamodb:::table/*`, so an explicit Deny on a +//! specific table, index, stream, or backup ARN never matched. In ABAC +//! deployments this let a caller change the tags that drive access decisions. +//! +//! The service evaluates these resources (measured live, 2026-10-20, account +//! 964157134968): +//! +//! | Operation | IAM action | resource | +//! |---|---|---| +//! | `TagResource`/`UntagResource`/`ListTagsOfResource` | same | the `ResourceArn` (a table or stream ARN) | +//! | `DescribeStream`/`GetShardIterator` | same | the `StreamArn` | +//! | `ListStreams` (with `TableName`) | `ListStreams` | `table//stream/*` | +//! | `ListStreams` (no `TableName`) | `ListStreams` | `table/*/stream/*` | +//! | `DescribeBackup`/`DeleteBackup` | same | the `BackupArn` | +//! | `CreateBackup`/`DescribeContinuousBackups`/`UpdateContinuousBackups` | same | the table ARN | +//! | `ExportTableToPointInTime` | same | the `TableArn` | +//! | `ImportTable` | same | the new table ARN | +//! | `Query`/`Scan` (with `IndexName`) | same | `table//index/` | +//! | `ListTables` | `ListTables` | `table/*` | +//! | item/table ops | same | the table ARN (from `TableName`, name or ARN) | +//! +//! Every resource ARN produced here is CANONICAL for this deployment: the +//! partition, service, region, and account are fixed to this server's, and only +//! the resource identity (table name and optional sub-resource) is taken from +//! the request. ExtendDB's stream and backup resolvers ignore the partition, +//! service, region, and account of a supplied ARN and act within this +//! deployment's account, so a caller who respells those fields would otherwise +//! evade a Deny written against the canonical ARN. Canonicalizing here closes +//! that bypass: a Deny on the real resource always matches. +//! +//! Fail-closed: a missing or unparseable resource field never falls back to +//! `table/*`. It yields the field value verbatim (often empty), which an +//! `Allow` on `*` still reaches so the engine returns its normal validation +//! error, while no Deny on a specific resource is silently skipped. + +use serde_json::Value; + +/// Partition and service are fixed to this deployment's; see module docs. +const PARTITION: &str = "aws"; +const SERVICE: &str = "dynamodb"; + +/// A `DynamoDB` resource identity: a table and an optional sub-resource. +#[derive(Debug, PartialEq, Eq)] +enum Sub { + Index(String), + Stream(String), + Backup(String), +} + +#[derive(Debug, PartialEq, Eq)] +struct ResourceId { + table: String, + sub: Option, +} + +/// Extract the resource identity (table name and optional sub-resource) from a +/// `DynamoDB` ARN. Returns `None` for anything that is not a `table/...` +/// resource. The partition, service, region, and account are intentionally +/// ignored: only the resource identity is trusted from the input. +/// +/// Stream labels contain `:` (e.g. `.../stream/2026-10-20T16:17:40.188`), so the +/// resource portion is taken as everything after the fifth `:`. +fn parse_resource_identity(arn: &str) -> Option { + let rest = arn.strip_prefix("arn:")?; + // partition : service : region : account : resource(may contain ':') + let mut it = rest.splitn(5, ':'); + let _partition = it.next()?; + let _service = it.next()?; + let _region = it.next()?; + let _account = it.next()?; + let resource = it.next()?; + + let tail = resource.strip_prefix("table/")?; + let (table, after) = match tail.split_once('/') { + Some((t, a)) => (t, Some(a)), + None => (tail, None), + }; + if table.is_empty() { + return None; + } + let sub = match after { + None => None, + Some(a) => { + if let Some(i) = a.strip_prefix("index/") { + if i.is_empty() { + return None; + } + Some(Sub::Index(i.to_owned())) + } else if let Some(s) = a.strip_prefix("stream/") { + if s.is_empty() { + return None; + } + Some(Sub::Stream(s.to_owned())) + } else if let Some(b) = a.strip_prefix("backup/") { + if b.is_empty() { + return None; + } + Some(Sub::Backup(b.to_owned())) + } else { + // An unrecognized sub-resource shape is not a nameable resource. + return None; + } + } + }; + Some(ResourceId { + table: table.to_owned(), + sub, + }) +} + +/// Build a canonical ARN for this deployment from a resource identity. +fn canonical(region: &str, account_id: &str, rid: &ResourceId) -> String { + let base = format!( + "arn:{PARTITION}:{SERVICE}:{region}:{account_id}:table/{}", + rid.table + ); + match &rid.sub { + None => base, + Some(Sub::Index(i)) => format!("{base}/index/{i}"), + Some(Sub::Stream(s)) => format!("{base}/stream/{s}"), + Some(Sub::Backup(b)) => format!("{base}/backup/{b}"), + } +} + +/// The canonical ARN for the resource a client-supplied ARN names, or the raw +/// string when it does not name a `DynamoDB` `table/...` resource. The raw +/// string lets an `Allow` on `*` reach the engine's own validation while never +/// producing a `table/*` fallback that a specific Deny would miss. +fn canonical_or_raw(arn: &str, region: &str, account_id: &str) -> String { + match parse_resource_identity(arn) { + Some(rid) => canonical(region, account_id, &rid), + None => arn.to_owned(), + } +} + +/// The canonical table ARN for a `TableName` field that may be a bare name or a +/// table ARN. Any sub-resource on a supplied ARN is dropped: a `TableName` names +/// a table. Callers that need an index ARN append `/index/` themselves. +pub(crate) fn table_resource_arn(value: &str, region: &str, account_id: &str) -> String { + if value.starts_with("arn:") { + if let Some(rid) = parse_resource_identity(value) { + return canonical( + region, + account_id, + &ResourceId { + table: rid.table, + sub: None, + }, + ); + } + return value.to_owned(); + } + format!("arn:{PARTITION}:{SERVICE}:{region}:{account_id}:table/{value}") +} + +/// Account-wide table wildcard, for operations with no specific resource. +fn table_wildcard(region: &str, account_id: &str) -> String { + format!("arn:{PARTITION}:{SERVICE}:{region}:{account_id}:table/*") +} + +fn stream_wildcard(region: &str, account_id: &str) -> String { + format!("arn:{PARTITION}:{SERVICE}:{region}:{account_id}:table/*/stream/*") +} + +fn str_field(input: &Value, key: &str) -> Option { + input + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +/// Derive the `(action, resource ARN)` pairs to authorize for a non-batch, +/// non-transact `DynamoDB` operation. `action` is the bare operation form +/// (`check_authorization` prepends `dynamodb:`). Most operations map to a +/// single pair; multi-permission operations return more than one. +/// +/// This function never returns an empty list: the generic single-table path is +/// the fallback for operations not named explicitly. +pub(crate) fn authz_targets( + operation: &str, + input: &Value, + region: &str, + account_id: &str, +) -> Vec<(String, String)> { + let one = |resource: String| vec![(operation.to_owned(), resource)]; + + match operation { + // Resource lives in ResourceArn (a table or stream ARN). + "TagResource" | "UntagResource" | "ListTagsOfResource" => one(canonical_or_raw( + &str_field(input, "ResourceArn").unwrap_or_default(), + region, + account_id, + )), + // Resource lives in StreamArn. + "DescribeStream" | "GetShardIterator" => one(canonical_or_raw( + &str_field(input, "StreamArn").unwrap_or_default(), + region, + account_id, + )), + // GetRecords carries only an opaque shard iterator; the stream identity + // is not in the request body (see report, Divergences kept). Authorize + // against the account's stream space so an account-wide or action-wide + // Deny is enforced. + "GetRecords" => one(stream_wildcard(region, account_id)), + "ListStreams" => { + let res = match str_field(input, "TableName") { + Some(t) => { + // TableName here is a bare name; build table//stream/*. + let rid = ResourceId { + table: table_name_only(&t), + sub: Some(Sub::Stream("*".to_owned())), + }; + canonical(region, account_id, &rid) + } + None => stream_wildcard(region, account_id), + }; + one(res) + } + // Resource lives in BackupArn. + "DescribeBackup" | "DeleteBackup" => one(canonical_or_raw( + &str_field(input, "BackupArn").unwrap_or_default(), + region, + account_id, + )), + // Resource is the table named by TableName. + "CreateBackup" | "DescribeContinuousBackups" | "UpdateContinuousBackups" => { + one(table_resource_arn( + &str_field(input, "TableName").unwrap_or_default(), + region, + account_id, + )) + } + // ListBackups is scoped to a table when TableName is given, else account. + "ListBackups" => match str_field(input, "TableName") { + Some(t) => one(table_resource_arn(&t, region, account_id)), + None => one(table_wildcard(region, account_id)), + }, + // Export names the source table via TableArn. + "ExportTableToPointInTime" => one(table_resource_arn( + &str_field(input, "TableArn").unwrap_or_default(), + region, + account_id, + )), + // Import creates a table; the resource is the new table. + "ImportTable" => { + let t = input + .get("TableCreationParameters") + .and_then(|p| p.get("TableName")) + .and_then(Value::as_str) + .unwrap_or_default(); + one(table_resource_arn(t, region, account_id)) + } + // RestoreTableFromBackup's primary action is evaluated on the source + // backup ARN (measured live: the first permission the service requires + // is dynamodb:RestoreTableFromBackup on the BackupArn). So a Deny on the + // backup ARN blocks the restore. + "RestoreTableFromBackup" => one(canonical_or_raw( + &str_field(input, "BackupArn").unwrap_or_default(), + region, + account_id, + )), + // RestoreTableToPointInTime's primary action is evaluated on the source + // table (SourceTableArn or SourceTableName). + "RestoreTableToPointInTime" => { + let source = str_field(input, "SourceTableArn") + .or_else(|| str_field(input, "SourceTableName")) + .unwrap_or_default(); + one(table_resource_arn(&source, region, account_id)) + } + // Query and Scan on an index authorize the index ARN. + "Query" | "Scan" => { + let base = table_resource_arn( + &str_field(input, "TableName").unwrap_or_default(), + region, + account_id, + ); + match str_field(input, "IndexName") { + Some(idx) if !idx.is_empty() => one(format!("{base}/index/{idx}")), + _ => one(base), + } + } + // Account-level operations. ListTables is evaluated against the table + // wildcard; DescribeLimits and DescribeEndpoints have no resource and + // are evaluated against "*" (measured live). + "ListTables" => one(table_wildcard(region, account_id)), + "DescribeLimits" | "DescribeEndpoints" => one("*".to_owned()), + // Generic single-table operations keyed by TableName (name or ARN). + _ => match str_field(input, "TableName") { + Some(t) => one(table_resource_arn(&t, region, account_id)), + None => one(table_wildcard(region, account_id)), + }, + } +} + +/// The bare table name from a value that may be a name or a table ARN. +fn table_name_only(value: &str) -> String { + if value.starts_with("arn:") + && let Some(rid) = parse_resource_identity(value) + { + return rid.table; + } + value.to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const R: &str = "us-east-1"; + const A: &str = "964157134968"; + + fn only(op: &str, input: Value) -> String { + let t = authz_targets(op, &input, R, A); + assert_eq!(t.len(), 1, "expected one target for {op}: {t:?}"); + assert_eq!(t[0].0, op); + t[0].1.clone() + } + + // -- parser -- + + #[test] + fn parses_table() { + assert_eq!( + parse_resource_identity("arn:aws:dynamodb:us-east-1:1:table/T"), + Some(ResourceId { + table: "T".into(), + sub: None + }) + ); + } + + #[test] + fn parses_stream_label_with_colons() { + assert_eq!( + parse_resource_identity( + "arn:aws:dynamodb:us-east-1:1:table/T/stream/2026-10-20T16:17:40.188" + ), + Some(ResourceId { + table: "T".into(), + sub: Some(Sub::Stream("2026-10-20T16:17:40.188".into())), + }) + ); + } + + #[test] + fn parses_index_and_backup() { + assert_eq!( + parse_resource_identity("arn:aws:dynamodb:us-east-1:1:table/T/index/gsi1"), + Some(ResourceId { + table: "T".into(), + sub: Some(Sub::Index("gsi1".into())) + }) + ); + assert_eq!( + parse_resource_identity("arn:aws:dynamodb:us-east-1:1:table/T/backup/01-ab"), + Some(ResourceId { + table: "T".into(), + sub: Some(Sub::Backup("01-ab".into())) + }) + ); + } + + #[test] + fn rejects_non_table_and_malformed() { + assert_eq!(parse_resource_identity("banana"), None); + assert_eq!( + parse_resource_identity("arn:aws:dynamodb:us-east-1:1:banana/x"), + None + ); + assert_eq!( + parse_resource_identity("arn:aws:dynamodb:us-east-1:1:table/"), + None + ); + assert_eq!( + parse_resource_identity("arn:aws:dynamodb:us-east-1:1:table/T/weird/x"), + None + ); + } + + // -- canonicalization closes the respelling bypass -- + + #[test] + fn respelled_partition_service_region_account_canonicalize_to_same_arn() { + let canonical = + "arn:aws:dynamodb:us-east-1:964157134968:table/T/stream/2026-10-20T16:17:40.188"; + for respelled in [ + "arn:aws-cn:dynamodb:us-east-1:964157134968:table/T/stream/2026-10-20T16:17:40.188", + "arn:aws:s3:us-east-1:964157134968:table/T/stream/2026-10-20T16:17:40.188", + "arn:aws:dynamodb:us-west-2:964157134968:table/T/stream/2026-10-20T16:17:40.188", + "arn:aws:dynamodb:us-east-1:111122223333:table/T/stream/2026-10-20T16:17:40.188", + ] { + assert_eq!( + only("DescribeStream", json!({ "StreamArn": respelled })), + canonical, + "respelled {respelled} must canonicalize" + ); + } + } + + // -- per-operation resource derivation (matches live DynamoDB) -- + + #[test] + fn tag_ops_use_resource_arn() { + let table_arn = "arn:aws:dynamodb:us-east-1:964157134968:table/bb"; + for op in ["TagResource", "UntagResource", "ListTagsOfResource"] { + assert_eq!(only(op, json!({ "ResourceArn": table_arn })), table_arn); + } + // a stream ARN as the tag target is preserved + let stream = + "arn:aws:dynamodb:us-east-1:964157134968:table/bb/stream/2026-10-20T16:17:40.188"; + assert_eq!( + only("TagResource", json!({ "ResourceArn": stream })), + stream + ); + } + + #[test] + fn stream_reads_use_stream_arn() { + let s = "arn:aws:dynamodb:us-east-1:964157134968:table/bb/stream/2026-10-20T16:17:40.188"; + assert_eq!(only("DescribeStream", json!({ "StreamArn": s })), s); + assert_eq!(only("GetShardIterator", json!({ "StreamArn": s })), s); + } + + #[test] + fn list_streams_scopes_to_table_then_account() { + assert_eq!( + only("ListStreams", json!({ "TableName": "bb" })), + "arn:aws:dynamodb:us-east-1:964157134968:table/bb/stream/*" + ); + assert_eq!( + only("ListStreams", json!({})), + "arn:aws:dynamodb:us-east-1:964157134968:table/*/stream/*" + ); + } + + #[test] + fn backup_ops_use_backup_arn_or_table() { + let b = "arn:aws:dynamodb:us-east-1:964157134968:table/bb/backup/01-ab"; + assert_eq!(only("DescribeBackup", json!({ "BackupArn": b })), b); + assert_eq!(only("DeleteBackup", json!({ "BackupArn": b })), b); + assert_eq!( + only( + "CreateBackup", + json!({ "TableName": "bb", "BackupName": "x" }) + ), + "arn:aws:dynamodb:us-east-1:964157134968:table/bb" + ); + } + + #[test] + fn query_scan_on_index_use_index_arn() { + assert_eq!( + only("Query", json!({ "TableName": "bb", "IndexName": "gsi1" })), + "arn:aws:dynamodb:us-east-1:964157134968:table/bb/index/gsi1" + ); + assert_eq!( + only("Scan", json!({ "TableName": "bb", "IndexName": "lsi1" })), + "arn:aws:dynamodb:us-east-1:964157134968:table/bb/index/lsi1" + ); + assert_eq!( + only("Query", json!({ "TableName": "bb" })), + "arn:aws:dynamodb:us-east-1:964157134968:table/bb" + ); + } + + #[test] + fn table_name_may_be_an_arn() { + let arn = "arn:aws:dynamodb:us-east-1:964157134968:table/bb"; + assert_eq!(only("GetItem", json!({ "TableName": arn })), arn); + // a respelled table ARN in TableName canonicalizes + assert_eq!( + only( + "GetItem", + json!({ "TableName": "arn:aws-cn:dynamodb:us-west-2:111122223333:table/bb" }) + ), + arn + ); + } + + #[test] + fn account_level_ops_use_table_wildcard() { + assert_eq!( + only("ListTables", json!({})), + "arn:aws:dynamodb:us-east-1:964157134968:table/*" + ); + assert_eq!(only("DescribeLimits", json!({})), "*"); + } + + #[test] + fn restore_export_import_use_measured_resource() { + let backup = "arn:aws:dynamodb:us-east-1:964157134968:table/bb/backup/01-ab"; + // RestoreTableFromBackup: primary action on the backup ARN. + assert_eq!( + only( + "RestoreTableFromBackup", + json!({ "TargetTableName": "restored", "BackupArn": backup }) + ), + backup + ); + // RestoreTableToPointInTime: on the source table. + assert_eq!( + only( + "RestoreTableToPointInTime", + json!({ "SourceTableName": "bb", "TargetTableName": "pitr" }) + ), + "arn:aws:dynamodb:us-east-1:964157134968:table/bb" + ); + // ExportTableToPointInTime: on the source table (TableArn field). + assert_eq!( + only( + "ExportTableToPointInTime", + json!({ "TableArn": "arn:aws:dynamodb:us-east-1:964157134968:table/bb", "S3Bucket": "x" }) + ), + "arn:aws:dynamodb:us-east-1:964157134968:table/bb" + ); + // ImportTable: on the new table. + assert_eq!( + only( + "ImportTable", + json!({ "TableCreationParameters": { "TableName": "imp" } }) + ), + "arn:aws:dynamodb:us-east-1:964157134968:table/imp" + ); + } + + #[test] + fn missing_resource_field_never_falls_back_to_table_wildcard() { + // An absent ResourceArn yields the empty string, not table/*, so no + // specific Deny is silently skipped. + assert_eq!(only("TagResource", json!({})), ""); + assert_eq!(only("DescribeStream", json!({})), ""); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index b36c04ab2..8e00cc441 100755 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -10,6 +10,7 @@ pub(crate) mod authorization; pub mod authz_cache; +pub(crate) mod authz_resource; pub mod console; pub mod dev_credentials; mod handler; diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 923b5e483..dc98b1360 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -9,6 +9,7 @@ use serde_json::Value; use crate::AppState; use crate::authorization; +use crate::authz_resource; /// Extract operation name from X-Amz-Target header. /// Accepts both `DynamoDB_20120810` and `DynamoDBStreams_20120810` wire-format prefixes. @@ -108,7 +109,11 @@ pub(crate) async fn authorize_request( // DynamoDB. Verified against the AWS IAM Service Authorization Reference. if let Some(targets) = batch_transact_authz_targets(operation, input) { for (action_op, table) in targets { - let resource_arn = build_resource_arn(&state.region, account_id, Some(&table)); + // The RequestItems key (or TransactItems TableName) may be a bare + // name or a table ARN; canonicalize either to this deployment's + // table ARN so a respelled ARN cannot evade a per-table Deny. + let resource_arn = + authz_resource::table_resource_arn(&table, &state.region, account_id); authorization::check_authorization( state.authz_cache.as_ref(), identity, @@ -122,38 +127,62 @@ pub(crate) async fn authorize_request( return Ok(None); } - let resource_arn = build_resource_arn(&state.region, account_id, table_name.as_deref()); + // Derive the (action, resource ARN) pairs the service authorizes this + // operation against. The resource comes from the field the operation + // actually carries it in (ResourceArn, StreamArn, BackupArn, TableArn, + // IndexName, or a table ARN in TableName), canonicalized to this + // deployment, never a `table/*` fallback. See `authz_resource`. + let targets = authz_resource::authz_targets(operation, input, &state.region, account_id); + let is_scan = operation == "Scan"; - let pk_attr = key_info - .as_ref() - .map(|ki| ki.key_schema[0].attribute_name.clone()); - - let params = extenddb_auth::policy::context::RequestParams { - leading_keys: extract_leading_keys(input, operation, pk_attr.as_deref()), - attributes: extract_attributes(input), - select: input - .get("Select") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned), - return_values: input - .get("ReturnValues") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned), - return_consumed_capacity: input - .get("ReturnConsumedCapacity") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned), - ..Default::default() - }; - authorization::check_authorization( - state.authz_cache.as_ref(), - identity, - operation, - &resource_arn, - operation == "Scan", - params, - ) - .await?; + if targets.len() == 1 { + // Single-resource operations carry the request params the policy engine + // reads for item-level condition keys; build them exactly as before. + let pk_attr = key_info + .as_ref() + .map(|ki| ki.key_schema[0].attribute_name.clone()); + let params = extenddb_auth::policy::context::RequestParams { + leading_keys: extract_leading_keys(input, operation, pk_attr.as_deref()), + attributes: extract_attributes(input), + select: input + .get("Select") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + return_values: input + .get("ReturnValues") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + return_consumed_capacity: input + .get("ReturnConsumedCapacity") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + ..Default::default() + }; + let (action, resource_arn) = &targets[0]; + authorization::check_authorization( + state.authz_cache.as_ref(), + identity, + action, + resource_arn, + is_scan, + params, + ) + .await?; + } else { + // Multi-permission operations (restore). Each pair is authorized with + // default params; a Deny on any required resource fails the request. + for (action, resource_arn) in &targets { + authorization::check_authorization( + state.authz_cache.as_ref(), + identity, + action, + resource_arn, + false, + extenddb_auth::policy::context::RequestParams::default(), + ) + .await?; + } + } Ok(key_info) } @@ -282,17 +311,6 @@ fn batch_transact_authz_targets(operation: &str, input: &Value) -> Option) -> String { - match table_name { - Some(name) => format!("arn:aws:dynamodb:{region}:{account_id}:table/{name}"), - None => format!("arn:aws:dynamodb:{region}:{account_id}:table/*"), - } -} - #[cfg(test)] mod tests { use super::*; From 6ef01c49260b3693d3776b592a7a0b07c657d91b Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 16:49:18 +0000 Subject: [PATCH 02/38] test(auth): wire tests for explicit Deny on resource-ARN operations Dual-target pytest in the auth suite. An identity allowing dynamodb:* on * with an explicit Deny on one specific table, stream, backup, or index ARN must be blocked with AccessDeniedException on the tag operations, DescribeStream, DeleteBackup, and Query on an index, and a respelled ARN must not bypass a Deny on the canonical ARN. Expected behavior measured on live DynamoDB. --- tests/test_authz_resource_arn.py | 238 +++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 tests/test_authz_resource_arn.py diff --git a/tests/test_authz_resource_arn.py b/tests/test_authz_resource_arn.py new file mode 100644 index 000000000..da6baedbe --- /dev/null +++ b/tests/test_authz_resource_arn.py @@ -0,0 +1,238 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Explicit-Deny enforcement on resource-ARN operations. + +Operations that carry their resource in a field other than a top-level +TableName (ResourceArn on the tag operations, StreamArn on stream operations, +BackupArn on backup operations, IndexName on Query and Scan, and a table ARN in +TableName) must authorize against that specific resource. A policy that allows +dynamodb:* on * but explicitly Denies one action on one specific ARN must block +that action, matching live DynamoDB (measured 2026-10-20, account +964157134968: every case below returns AccessDeniedException while an allowed +control on the same principal succeeds). + +These tests provision builtin-IAM principals through the management API, so they +run against ExtendDB on every storage backend in CI. The equivalent behavior on +live DynamoDB was measured with real IAM principals and recorded in the builder +report's Service measurements section. + +Prerequisites: + - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD + +REQ-TEST-001, REQ-AUTH-002 +""" + +from __future__ import annotations + +import os +import time +import uuid +from typing import Any + +import boto3 +import pytest +from botocore.config import Config as BotoConfig +from botocore.exceptions import ClientError + +from conftest import wait_for_active +from management_helpers import ManagementClient + + +def _require_auth_env() -> tuple[str, str, str]: + endpoint = os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip() + admin_user = os.environ.get("EXTENDDB_ADMIN_USER", "").strip() + admin_pass = os.environ.get("EXTENDDB_ADMIN_PASSWORD", "").strip() + if not endpoint or not admin_user or not admin_pass: + pytest.fail( + "MISCONFIGURED: resource-ARN authz tests require EXTENDDB_TEST_ENDPOINT, " + "EXTENDDB_ADMIN_USER, and EXTENDDB_ADMIN_PASSWORD. " + "These must be set by devtools/run-tests before test execution." + ) + return endpoint, admin_user, admin_pass + + +def _client(endpoint: str, ak: str, sk: str, region: str, service: str = "dynamodb") -> Any: + kwargs: dict = dict( + service_name=service, + endpoint_url=endpoint, + aws_access_key_id=ak, + aws_secret_access_key=sk, + region_name=region, + config=BotoConfig(retries={"max_attempts": 0}), + ) + if endpoint.startswith("https://"): + kwargs["verify"] = False + return boto3.client(**kwargs) + + +ALLOW_ALL = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}], +} + + +def _allow_all_deny(action: str, resource: str) -> dict: + return { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}, + {"Effect": "Deny", "Action": f"dynamodb:{action}", "Resource": resource}, + ], + } + + +@pytest.fixture(scope="module") +def auth_env(): + return _require_auth_env() + + +@pytest.fixture(scope="module") +def mgmt(auth_env) -> ManagementClient: + endpoint, admin_user, admin_pass = auth_env + return ManagementClient(endpoint, admin_user, admin_pass) + + +@pytest.fixture(scope="module") +def region() -> str: + return os.environ.get("AWS_DEFAULT_REGION", "us-east-1") + + +@pytest.fixture(scope="module") +def account_id(mgmt) -> str: + acct = f"{uuid.uuid4().int % 10**12:012d}" + resp = mgmt.create_account(acct, f"arnauthz-{acct}") + assert resp.status_code == 201, resp.text + yield acct + mgmt.delete_account(acct) + + +def _user_with_policy(mgmt: ManagementClient, account_id: str, policy: dict) -> tuple[str, str]: + """Create a fresh IAM user with the given policy and return (ak, sk).""" + user = f"u{uuid.uuid4().hex[:10]}" + assert mgmt.create_user(account_id, user, "TestPass123!").status_code == 201 + assert mgmt.put_user_policy(account_id, user, "p", policy).status_code == 204 + resp = mgmt.create_access_key(account_id, user) + assert resp.status_code == 201, resp.text + creds = resp.json() + return creds["access_key_id"], creds["secret_access_key"] + + +@pytest.fixture(scope="module") +def table(auth_env, mgmt, account_id, region): + """A table with a GSI, a stream, one item, and a backup. Returns the + canonical table, index, stream, and backup ARNs.""" + endpoint = auth_env[0] + ak, sk = _user_with_policy(mgmt, account_id, ALLOW_ALL) + admin = _client(endpoint, ak, sk, region) + name = f"t{uuid.uuid4().hex[:8]}" + admin.create_table( + TableName=name, + AttributeDefinitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "g1pk", "AttributeType": "S"}, + ], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + GlobalSecondaryIndexes=[{ + "IndexName": "gsi1", + "KeySchema": [{"AttributeName": "g1pk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + ) + wait_for_active(admin, name) + admin.put_item(TableName=name, Item={"pk": {"S": "p1"}, "g1pk": {"S": "g1"}}) + desc = admin.describe_table(TableName=name)["Table"] + table_arn = desc["TableArn"] + backup_arn = admin.create_backup(TableName=name, BackupName="arnbk")["BackupDetails"]["BackupArn"] + return { + "name": name, + "table_arn": table_arn, + "index_arn": table_arn + "/index/gsi1", + "stream_arn": desc.get("LatestStreamArn"), + "backup_arn": backup_arn, + } + + +class TestExplicitDenyOnResourceArn: + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.endpoint = auth_env[0] + self.mgmt = mgmt + self.account_id = account_id + self.region = region + + def _ddb(self, policy: dict, service: str = "dynamodb"): + ak, sk = _user_with_policy(self.mgmt, self.account_id, policy) + return _client(self.endpoint, ak, sk, self.region, service) + + def _assert_denied(self, fn): + with pytest.raises(ClientError) as exc: + fn() + assert exc.value.response["Error"]["Code"] == "AccessDeniedException", ( + exc.value.response["Error"] + ) + + # -- tag operations: the ticket's core defect -- + + def test_tag_resource_deny_blocks(self, table): + d = self._ddb(_allow_all_deny("TagResource", table["table_arn"])) + self._assert_denied(lambda: d.tag_resource( + ResourceArn=table["table_arn"], Tags=[{"Key": "k", "Value": "v"}])) + + def test_untag_resource_deny_blocks(self, table): + d = self._ddb(_allow_all_deny("UntagResource", table["table_arn"])) + self._assert_denied(lambda: d.untag_resource( + ResourceArn=table["table_arn"], TagKeys=["k"])) + + def test_list_tags_deny_blocks(self, table): + d = self._ddb(_allow_all_deny("ListTagsOfResource", table["table_arn"])) + self._assert_denied(lambda: d.list_tags_of_resource(ResourceArn=table["table_arn"])) + + # -- stream, backup, index operations -- + + def test_describe_stream_deny_blocks(self, table): + s = self._ddb(_allow_all_deny("DescribeStream", table["stream_arn"]), "dynamodbstreams") + self._assert_denied(lambda: s.describe_stream(StreamArn=table["stream_arn"])) + + def test_delete_backup_deny_blocks(self, table): + d = self._ddb(_allow_all_deny("DeleteBackup", table["backup_arn"])) + self._assert_denied(lambda: d.delete_backup(BackupArn=table["backup_arn"])) + + def test_query_on_index_deny_blocks(self, table): + d = self._ddb(_allow_all_deny("Query", table["index_arn"])) + self._assert_denied(lambda: d.query( + TableName=table["name"], IndexName="gsi1", + KeyConditionExpression="g1pk = :p", + ExpressionAttributeValues={":p": {"S": "g1"}})) + + # -- fail closed: a respelled ARN cannot evade a Deny on the canonical ARN -- + + def test_respelled_stream_arn_cannot_bypass_deny(self, table): + """A Deny on the canonical stream ARN must also block a request that + respells the partition. ExtendDB resolves the stream in this account + regardless of the ARN's partition, so authorizing the respelled string + verbatim would bypass the Deny.""" + respelled = table["stream_arn"].replace("arn:aws:", "arn:aws-cn:") + s = self._ddb(_allow_all_deny("DescribeStream", table["stream_arn"]), "dynamodbstreams") + self._assert_denied(lambda: s.describe_stream(StreamArn=respelled)) + + def test_table_arn_in_tablename_deny_blocks(self, table): + """A Deny on the table ARN blocks GetItem whether TableName is the bare + name or the table ARN.""" + d = self._ddb(_allow_all_deny("GetItem", table["table_arn"])) + self._assert_denied(lambda: d.get_item( + TableName=table["name"], Key={"pk": {"S": "p1"}})) + d2 = self._ddb(_allow_all_deny("GetItem", table["table_arn"])) + self._assert_denied(lambda: d2.get_item( + TableName=table["table_arn"], Key={"pk": {"S": "p1"}})) + + # -- positive control: without the Deny, the operation is allowed -- + + def test_allow_without_deny_permits_tagging(self, table): + d = self._ddb(ALLOW_ALL) + # Must not raise. + d.tag_resource(ResourceArn=table["table_arn"], Tags=[{"Key": "ctrl", "Value": "1"}]) + time.sleep(0) From ccd4850183a1dbd93f3e7c5e893059866346c14e Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 16:56:42 +0000 Subject: [PATCH 03/38] fix(auth): parse ARN sub-resource with split_once No behavior change. Rewrites the index/stream/backup sub-resource parse in parse_resource_identity to satisfy clippy::question_mark under -D warnings. --- crates/server/src/authz_resource.rs | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/crates/server/src/authz_resource.rs b/crates/server/src/authz_resource.rs index 0eced7950..b453cf5aa 100644 --- a/crates/server/src/authz_resource.rs +++ b/crates/server/src/authz_resource.rs @@ -90,25 +90,19 @@ fn parse_resource_identity(arn: &str) -> Option { let sub = match after { None => None, Some(a) => { - if let Some(i) = a.strip_prefix("index/") { - if i.is_empty() { - return None; - } - Some(Sub::Index(i.to_owned())) - } else if let Some(s) = a.strip_prefix("stream/") { - if s.is_empty() { - return None; - } - Some(Sub::Stream(s.to_owned())) - } else if let Some(b) = a.strip_prefix("backup/") { - if b.is_empty() { - return None; - } - Some(Sub::Backup(b.to_owned())) - } else { - // An unrecognized sub-resource shape is not a nameable resource. + // `a` is `/`, e.g. `index/gsi1`, `stream/