diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index 8d4643ad1..a45dbda40 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -25,18 +25,24 @@ use extenddb_core::error::DynamoDbError; use extenddb_storage::management_store::OpError; use crate::authz_cache::{CachedAuthzStore, PolicyList, TagMap}; +use crate::authz_resource::AuthzTarget; /// Evaluate whether the authenticated identity is authorized for the given /// `DynamoDB` operation on the given resource. /// +/// Policies are matched against `target.policy_resource()`: the canonical ARN +/// for this deployment, or the ARN as written on the tag operations. Resource +/// tags are read under the canonical ARN, and a denial names +/// `target.supplied`, the resource as the caller wrote it, as the service +/// does. +/// /// For `AuthIdentity::User` and `AuthIdentity::RoleSession`, the full IAM /// evaluation algorithm runs: explicit deny → permissions boundary → session /// policy → identity allow → implicit deny. pub async fn check_authorization( cache: &CachedAuthzStore, identity: &AuthIdentity, - operation: &str, - resource_arn: &str, + target: &AuthzTarget, is_scan: bool, params: RequestParams, ) -> Result<(), DynamoDbError> { @@ -44,18 +50,7 @@ pub async fn check_authorization( AuthIdentity::User { account_id, user_name, - } => { - check_user_authorization( - cache, - account_id, - user_name, - operation, - resource_arn, - is_scan, - params, - ) - .await - } + } => check_user_authorization(cache, account_id, user_name, target, is_scan, params).await, AuthIdentity::RoleSession { account_id, role_name, @@ -66,8 +61,7 @@ pub async fn check_authorization( account_id, role_name, session_name, - operation, - resource_arn, + target, is_scan, params, ) @@ -80,18 +74,19 @@ async fn check_user_authorization( cache: &CachedAuthzStore, account_id: &str, user_name: &str, - operation: &str, - resource_arn: &str, + target: &AuthzTarget, is_scan: bool, params: RequestParams, ) -> Result<(), DynamoDbError> { - let action = format!("dynamodb:{operation}"); + let action = format!("dynamodb:{}", target.action); + let resource_arn = target.policy_resource(); // Resource tags apply only to a table, and to an index through its base // table (indexes inherit the table's tags). A stream, backup or any other // resource has none, so `aws:ResourceTag/*` is absent there, as on the - // service (see `resource_tag_lookup_arn`). - let tag_arn = crate::authz_resource::resource_tag_lookup_arn(resource_arn); + // service (see `resource_tag_lookup_arn`). They are read under the + // canonical resource, also when the policies match the ARN as written. + let tag_arn = crate::authz_resource::resource_tag_lookup_arn(&target.resource); // Fetch all authz inputs concurrently; they are independent cache // lookups (or DB queries, on cold start). All come back pre-parsed. @@ -170,27 +165,27 @@ async fn check_user_authorization( Err(DynamoDbError::AccessDeniedException(deny_message( &principal, &action, - resource_arn, + &target.supplied, explicit, ))) } } -#[allow(clippy::too_many_arguments)] async fn check_role_authorization( cache: &CachedAuthzStore, account_id: &str, role_name: &str, session_name: &str, - operation: &str, - resource_arn: &str, + target: &AuthzTarget, is_scan: bool, params: RequestParams, ) -> Result<(), DynamoDbError> { - let action = format!("dynamodb:{operation}"); + let action = format!("dynamodb:{}", target.action); + let resource_arn = target.policy_resource(); - // Resource tags for a table or index only (see the user path). - let tag_arn = crate::authz_resource::resource_tag_lookup_arn(resource_arn); + // Resource tags for a table or index only, read under the canonical + // resource (see the user path). + let tag_arn = crate::authz_resource::resource_tag_lookup_arn(&target.resource); let ( identity_policies, @@ -268,7 +263,7 @@ async fn check_role_authorization( Err(DynamoDbError::AccessDeniedException(deny_message( &principal, &action, - resource_arn, + &target.supplied, explicit, ))) } @@ -280,7 +275,8 @@ async fn check_role_authorization( /// Build the AccessDeniedException message in the form the service uses, which /// depends on why the request was denied. `action` already carries the -/// `dynamodb:` prefix. An explicit Deny statement yields "with an explicit deny +/// `dynamodb:` prefix; `resource_arn` is the resource as the caller supplied +/// it, partition, service and region included. An explicit Deny statement yields "with an explicit deny /// in an identity-based policy"; any other denial yields "because no /// identity-based policy allows the action", measured against live /// DynamoDB. @@ -473,6 +469,7 @@ mod resource_tag_tests { use super::{check_role_authorization, check_user_authorization, fetch_authz_resource_tags}; use crate::authz_cache::CachedAuthzStore; use crate::authz_cache::tests::{CountingAuthzStore, fast_authz_cfg}; + use crate::authz_resource::AuthzTarget; use extenddb_auth::policy::context::RequestParams; use extenddb_core::error::DynamoDbError; use std::sync::Arc; @@ -516,14 +513,70 @@ mod resource_tag_tests { cache, ACCOUNT, "u", - op, - arn, + &AuthzTarget::new(op, arn.to_owned()), false, RequestParams::default(), ) .await } + /// The policy is matched against the canonical resource and the denial + /// names the resource as the caller supplied it (issue #447): a respelled + /// region is not allowed by the tag condition on the canonical table (the + /// stream carries no tags), and the message carries the respelled ARN. + #[tokio::test] + async fn denial_names_the_supplied_resource() { + let inner = store(); + let cache = CachedAuthzStore::new(inner.clone(), fast_authz_cfg()); + let supplied = + "arn:aws-cn:dynamodb:us-east-1:123456789012:table/t/stream/2026-10-20T16:17:40.188"; + let target = AuthzTarget { + action: "DescribeStream".to_owned(), + resource: STREAM.to_owned(), + supplied: supplied.to_owned(), + match_supplied: false, + }; + let err = check_user_authorization( + &cache, + ACCOUNT, + "u", + &target, + false, + RequestParams::default(), + ) + .await + .unwrap_err(); + let DynamoDbError::AccessDeniedException(msg) = err else { + panic!("expected AccessDeniedException, got {err:?}"); + }; + assert_eq!( + msg, + format!( + "User: arn:aws:iam::{ACCOUNT}:user/u is not authorized to perform: \ + dynamodb:DescribeStream on resource: {supplied} because no \ + identity-based policy allows the dynamodb:DescribeStream action" + ) + ); + let err = check_role_authorization( + &cache, + ACCOUNT, + "r", + "s", + &target, + false, + RequestParams::default(), + ) + .await + .unwrap_err(); + let DynamoDbError::AccessDeniedException(msg) = err else { + panic!("expected AccessDeniedException, got {err:?}"); + }; + assert!( + msg.contains(&format!("on resource: {supplied} because")), + "{msg}" + ); + } + #[tokio::test] async fn stream_and_backup_operations_read_no_resource_tags() { let inner = store(); @@ -549,17 +602,17 @@ mod resource_tag_tests { async fn role_session_stream_and_backup_operations_read_no_resource_tags() { let inner = store(); let cache = CachedAuthzStore::new(inner.clone(), fast_authz_cfg()); - let role = |op: &'static str, arn: &'static str| { + let role = |op: &'static str, arn: &'static str| async { check_role_authorization( &cache, ACCOUNT, "r", "s", - op, - arn, + &AuthzTarget::new(op, arn.to_owned()), false, RequestParams::default(), ) + .await }; assert!(role("DescribeTable", TABLE).await.is_ok()); let fetched = inner.resource_tags_calls.load(Ordering::SeqCst); diff --git a/crates/server/src/authz_resource.rs b/crates/server/src/authz_resource.rs index 8a5f84156..f7e0924ac 100644 --- a/crates/server/src/authz_resource.rs +++ b/crates/server/src/authz_resource.rs @@ -36,7 +36,12 @@ //! 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. +//! that bypass: a Deny on the real resource always matches. The tag operations +//! are the one exception: their policies are matched against the `ResourceArn` +//! as written, as the service matches them, because the engine tag handler +//! refuses another partition, service or region after the IAM decision (see +//! [`AuthzTarget`]). A resource in another account is refused before the IAM +//! decision for every operation ([`cross_account`]). //! //! Fail-closed: a missing or unparseable resource field never falls back to //! `table/*`. It yields the field value verbatim (often empty), which an @@ -46,6 +51,8 @@ use extenddb_core::error::DynamoDbError; use serde_json::Value; +pub(crate) mod cross_account; + /// Partition and service are fixed to this deployment's; see module docs. const PARTITION: &str = "aws"; const SERVICE: &str = "dynamodb"; @@ -63,6 +70,78 @@ const RESTORE_TARGET_ACTIONS: &[&str] = &[ "BatchWriteItem", ]; +/// One (IAM action, resource) pair a request is authorized against. +/// +/// `resource` is the canonical ARN for this deployment. Resource tags are +/// read under it, and the policies are matched against it unless +/// `match_supplied` is set, so a Deny on the real resource matches however +/// the caller spelled the partition, service or region. `supplied` is the +/// resource as the caller wrote it, and is what a denial message names: the +/// service echoes the caller's ARN with its partition, service and region +/// intact (measured 2026-10-20 with TagResource on an `aws-cn` partition, an +/// `s3` service and a `us-west-2` region). It equals `resource` when the +/// request named the resource by a bare name or by its canonical ARN. +/// +/// `match_supplied` is set on the tag operations only. The service matches +/// their policies against the `ResourceArn` as written: a caller allowed +/// `dynamodb:*` on the canonical table ARN alone is denied ListTagsOfResource +/// on the `aws-cn`, `s3` and `us-west-2` spellings of it (measured +/// 2026-11-03). The engine tag handler refuses those spellings after the IAM +/// decision, so no tag is read or written for a resource other than the one +/// the policies matched. The other ARN fields keep the canonical match: the +/// engine resolves a `StreamArn`, an export `TableArn` or a restore +/// `SourceTableArn` with another partition, service or region, and a +/// `BackupArn` or `CreateBackup` table ARN in another region, to this +/// deployment's resource, so a Deny on that resource has to match the +/// respelled ARN. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AuthzTarget { + /// The bare operation form; `check_authorization` prepends `dynamodb:`. + pub action: String, + pub resource: String, + pub supplied: String, + pub match_supplied: bool, +} + +impl AuthzTarget { + /// A target named by a bare name or built by the server: the denial names + /// the canonical ARN. + pub(crate) fn new(action: &str, resource: String) -> Self { + Self { + action: action.to_owned(), + supplied: resource.clone(), + resource, + match_supplied: false, + } + } + + /// A target derived from request field `field`: when the field parses as + /// a `DynamoDB` resource ARN, the denial names it as written; otherwise + /// it names `resource`. + fn from_field(action: &str, resource: String, field: &str) -> Self { + let supplied = if extenddb_core::arn::parse(field).is_some() { + field.to_owned() + } else { + resource.clone() + }; + Self { + action: action.to_owned(), + resource, + supplied, + match_supplied: false, + } + } + + /// The ARN the policies are matched against. + pub(crate) fn policy_resource(&self) -> &str { + if self.match_supplied { + &self.supplied + } else { + &self.resource + } + } +} + /// A `DynamoDB` resource identity: a table and an optional sub-resource. #[derive(Debug, PartialEq, Eq)] enum Sub { @@ -384,6 +463,18 @@ pub(crate) fn table_resource_arn(value: &str, region: &str, account_id: &str) -> format!("arn:{PARTITION}:{SERVICE}:{region}:{account_id}:table/{value}") } +/// The target for `action` on a table named by `value`, a bare name or a table +/// ARN (a `RequestItems` key, a `TransactItems` table): the canonical table +/// ARN for the policy match, the value as written for the denial. +pub(crate) fn table_target( + action: &str, + value: &str, + region: &str, + account_id: &str, +) -> AuthzTarget { + AuthzTarget::from_field(action, table_resource_arn(value, region, account_id), value) +} + /// Account-wide table wildcard, for operations with no specific resource. pub(crate) fn table_wildcard(region: &str, account_id: &str) -> String { format!("arn:{PARTITION}:{SERVICE}:{region}:{account_id}:table/*") @@ -455,10 +546,9 @@ fn str_field(input: &Value, key: &str) -> Option { .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. +/// Derive the [`AuthzTarget`]s to authorize for a non-batch, non-transact +/// `DynamoDB` operation. Most operations map to a single target; +/// 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. @@ -467,23 +557,36 @@ pub(crate) fn authz_targets( input: &Value, region: &str, account_id: &str, -) -> Vec<(String, String)> { - let one = |resource: String| vec![(operation.to_owned(), resource)]; +) -> Vec { + // A target built by the server (a wildcard, a table named by a bare name + // or a sub-resource of one): the denial names the canonical ARN. + let one = |resource: String| vec![AuthzTarget::new(operation, resource)]; + // A target derived from a request field that may hold an ARN: the denial + // names the field as written. + let from = + |resource: String, field: &str| vec![AuthzTarget::from_field(operation, resource, field)]; + let table_name = str_field(input, "TableName").unwrap_or_default(); match operation { // Resource lives in ResourceArn (a table or stream ARN; a foreign ARN - // passes the shape check and is denied by authorization). - "TagResource" | "UntagResource" | "ListTagsOfResource" => one(tag_resource_arn( - &str_field(input, "ResourceArn").unwrap_or_default(), - region, - account_id, - )), + // passes the shape check and is denied by authorization). The policies + // are matched against the ARN as written and the resource tags are + // read from the canonical table (see `AuthzTarget`). + "TagResource" | "UntagResource" | "ListTagsOfResource" => { + let arn = str_field(input, "ResourceArn").unwrap_or_default(); + let mut target = AuthzTarget::from_field( + operation, + tag_resource_arn(&arn, region, account_id), + &arn, + ); + target.match_supplied = true; + vec![target] + } // Resource lives in StreamArn, validated to a stream before authz. - "DescribeStream" | "GetShardIterator" => one(canonical_resource( - &str_field(input, "StreamArn").unwrap_or_default(), - region, - account_id, - )), + "DescribeStream" | "GetShardIterator" => { + let arn = str_field(input, "StreamArn").unwrap_or_default(); + from(canonical_resource(&arn, region, account_id), &arn) + } // GetRecords carries only an opaque shard iterator, not the stream in a // body field, so it is handled in `authorize_request`: the iterator is // decoded for the stream ARN, and an iterator that names no stream gets @@ -506,19 +609,15 @@ pub(crate) fn authz_targets( one(res) } // Resource lives in BackupArn, validated to a backup before authz. - "DescribeBackup" | "DeleteBackup" => one(canonical_resource( - &backup_arn_field(input), - 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, - )) + "DescribeBackup" | "DeleteBackup" => { + let arn = backup_arn_field(input); + from(canonical_resource(&arn, region, account_id), &arn) } + // Resource is the table named by TableName. + "CreateBackup" | "DescribeContinuousBackups" | "UpdateContinuousBackups" => from( + table_resource_arn(&table_name, region, account_id), + &table_name, + ), // ListBackups is scoped to a table's backups when TableName is given, // else to every table's backups. The service evaluates // table//backup/* and table/*/backup/* (measured live), the same @@ -542,37 +641,33 @@ pub(crate) fn authz_targets( // does the same, measured live), and the export handler then refuses a // non-table ARN. Unparseable TableArns were already refused before // authorization by `validate_operation_arns`. - "ExportTableToPointInTime" => one(canonical_resource( - &str_field(input, "TableArn").unwrap_or_default(), - region, - account_id, - )), - // Import creates a table; the resource is the new table. + "ExportTableToPointInTime" => { + let arn = str_field(input, "TableArn").unwrap_or_default(); + from(canonical_resource(&arn, region, account_id), &arn) + } + // Import creates a table; the resource is the new table. The service + // reads `TableCreationParameters.TableName` as a name even when it is + // an ARN, and authorizes `table/` in the caller's + // account (measured 2026-11-03); the engine creates the table under + // the value as written too. "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)) + one(format!( + "arn:{PARTITION}:{SERVICE}:{region}:{account_id}:table/{t}" + )) } // RestoreTableFromBackup requires dynamodb:RestoreTableFromBackup on the // source backup ARN and the item actions on the target table it writes // (measured live). A Deny on the backup ARN, or on any item action for // the target table, blocks the restore. "RestoreTableFromBackup" => { - let mut v = vec![( - operation.to_owned(), - canonical_resource(&backup_arn_field(input), region, account_id), - )]; - let target = table_resource_arn( - &str_field(input, "TargetTableName").unwrap_or_default(), - region, - account_id, - ); - for a in RESTORE_TARGET_ACTIONS { - v.push(((*a).to_owned(), target.clone())); - } + let arn = backup_arn_field(input); + let mut v = from(canonical_resource(&arn, region, account_id), &arn); + v.extend(restore_target_actions(input, region, account_id)); v } // RestoreTableToPointInTime requires dynamodb:RestoreTableToPointInTime @@ -582,30 +677,35 @@ pub(crate) fn authz_targets( let source = str_field(input, "SourceTableArn") .or_else(|| str_field(input, "SourceTableName")) .unwrap_or_default(); - let mut v = vec![( - operation.to_owned(), - table_resource_arn(&source, region, account_id), - )]; - let target = table_resource_arn( - &str_field(input, "TargetTableName").unwrap_or_default(), - region, - account_id, - ); - for a in RESTORE_TARGET_ACTIONS { - v.push(((*a).to_owned(), target.clone())); - } + let mut v = from(table_resource_arn(&source, region, account_id), &source); + v.extend(restore_target_actions(input, region, account_id)); v } - // Query and Scan on an index authorize the index ARN. + // Query and Scan on an index authorize the index ARN. With a table ARN + // in TableName the denial names that ARN with the index appended; any + // other ARN in TableName (an index ARN, a malformed resource) names + // the canonical index ARN, so the denial never names a resource the + // caller did not write. "Query" | "Scan" => { - let base = table_resource_arn( - &str_field(input, "TableName").unwrap_or_default(), - region, - account_id, - ); + let base = table_resource_arn(&table_name, region, account_id); match str_field(input, "IndexName") { - Some(idx) if !idx.is_empty() => one(format!("{base}/index/{idx}")), - _ => one(base), + Some(idx) if !idx.is_empty() => { + let resource = format!("{base}/index/{idx}"); + let names_a_table = + extenddb_core::arn::parse(&table_name).is_some_and(|r| r.sub.is_none()); + let supplied = if names_a_table { + format!("{table_name}/index/{idx}") + } else { + resource.clone() + }; + vec![AuthzTarget { + action: operation.to_owned(), + resource, + supplied, + match_supplied: false, + }] + } + _ => from(base, &table_name), } } // Account-level operations. ListTables is evaluated against the table @@ -616,12 +716,27 @@ pub(crate) fn authz_targets( "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)), + Some(t) => from(table_resource_arn(&t, region, account_id), &t), None => one(table_wildcard(region, account_id)), }, } } +/// The item actions a restore requires on its `TargetTableName`, one target +/// per action. The target is a new table named by a bare name, so each denial +/// names the canonical ARN. +fn restore_target_actions(input: &Value, region: &str, account_id: &str) -> Vec { + let target = table_resource_arn( + &str_field(input, "TargetTableName").unwrap_or_default(), + region, + account_id, + ); + RESTORE_TARGET_ACTIONS + .iter() + .map(|a| AuthzTarget::new(a, target.clone())) + .collect() +} + /// 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:") @@ -664,8 +779,8 @@ mod tests { 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() + assert_eq!(t[0].action, op); + t[0].resource.clone() } /// The engine trims surrounding ASCII whitespace from a `BackupArn` @@ -694,8 +809,8 @@ mod tests { A, ); assert_eq!( - restore[0], - ("RestoreTableFromBackup".to_owned(), arn.clone()) + (restore[0].action.as_str(), restore[0].resource.as_str()), + ("RestoreTableFromBackup", arn.as_str()) ); } } @@ -904,11 +1019,14 @@ mod tests { R, A, ); - assert!(rfb.contains(&("RestoreTableFromBackup".to_owned(), backup.to_owned()))); + assert!(rfb.contains(&AuthzTarget::new( + "RestoreTableFromBackup", + backup.to_owned() + ))); let target = "arn:aws:dynamodb:us-east-1:444455556666:table/restored".to_owned(); for a in RESTORE_TARGET_ACTIONS { assert!( - rfb.contains(&((*a).to_owned(), target.clone())), + rfb.contains(&AuthzTarget::new(a, target.clone())), "RestoreTableFromBackup missing {a} on target" ); } @@ -922,14 +1040,14 @@ mod tests { R, A, ); - assert!(pitr.contains(&( - "RestoreTableToPointInTime".to_owned(), + assert!(pitr.contains(&AuthzTarget::new( + "RestoreTableToPointInTime", "arn:aws:dynamodb:us-east-1:444455556666:table/bb".to_owned() ))); let ptarget = "arn:aws:dynamodb:us-east-1:444455556666:table/pitr".to_owned(); for a in RESTORE_TARGET_ACTIONS { assert!( - pitr.contains(&((*a).to_owned(), ptarget.clone())), + pitr.contains(&AuthzTarget::new(a, ptarget.clone())), "RestoreTableToPointInTime missing {a} on target" ); } @@ -1355,3 +1473,7 @@ mod key_lookup_name_tests { } } } + +/// The denial names the resource as the caller supplied it (issue #447). +#[cfg(test)] +mod supplied_resource_tests; diff --git a/crates/server/src/authz_resource/cross_account.rs b/crates/server/src/authz_resource/cross_account.rs new file mode 100644 index 000000000..148b0b92e --- /dev/null +++ b/crates/server/src/authz_resource/cross_account.rs @@ -0,0 +1,301 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The refusal of a request that names a resource in another account. +//! +//! ExtendDB has no resource-based policies, so a resource ARN in another +//! account is always refused, before the IAM decision. DynamoDB gives a caller +//! allowed `dynamodb:*` on `*` and a caller with no policy the same answer, +//! and the answer depends on the operation and on whether the account exists +//! (measured 2026-11-03 in us-east-1, with an account that exists and with +//! `999999999999`, which does not): +//! +//! | Operations | Account exists | Account does not exist | +//! |---|---|---| +//! | the tag operations, `DescribeTable`, `UpdateTable`, `BatchWriteItem`, `TransactGetItems`, `TransactWriteItems`, `DescribeStream`, `GetShardIterator` | [`resource_policy_denial`] | "Access is denied" | +//! | `GetItem`, `PutItem`, `UpdateItem`, `Query`, `Scan`, `BatchGetItem` | [`resource_policy_denial`] | "The caller is not authorized to access the specified resource." | +//! | `CreateTable`, `DescribeTimeToLive`, `UpdateTimeToLive`, `DescribeContinuousBackups`, `UpdateContinuousBackups`, `CreateBackup`, `ListBackups`, `DescribeBackup`, `RestoreTableFromBackup`, `ExportTableToPointInTime` | ValidationException [`OWNER_ONLY`] | "Access is denied" | +//! | `RestoreTableToPointInTime` | ValidationException [`OWNER_ONLY`] | the same | +//! +//! `DeleteTable`, `DeleteItem` and `DeleteBackup` were not sent to the +//! service; they take the answers of `UpdateTable`, `UpdateItem` and +//! `DescribeBackup`. `SearchVectors` has no service counterpart and takes the +//! answers of `DescribeTable`. On the service the ARN shape checks sit +//! between the two answers: an index or backup ARN in `ResourceArn`, or a +//! `StreamArn` in another partition or region, is "Access is denied" in an +//! account that does not exist and gets the shape ValidationException in one +//! that does. ExtendDB runs its own shape checks at that point. They do not +//! check a `StreamArn`'s partition or region (#451), so such a `StreamArn` in +//! an account the catalog holds gets the denial. A table ARN in `TableName` +//! with another partition, service or region, or naming an index or backup, +//! gets the answers above, where the service answers a ValidationException +//! (#794). A batch or transaction whose tables are +//! in more than one account, the caller's included, is refused with +//! [`MIXED_ACCOUNTS`] whichever accounts they are. +//! +//! An account exists here when this deployment's catalog holds it. Fields the +//! service reads as a name rather than an ARN (`ListStreams` `TableName`, +//! `RestoreTableToPointInTime` `SourceTableName`, the `ImportTable` table name, +//! a restore's `TargetTableName`) are not checked. + +use std::collections::{BTreeSet, HashMap}; + +use extenddb_auth::AuthIdentity; +use extenddb_core::error::DynamoDbError; +use serde_json::Value; + +use crate::AppState; + +/// The ValidationException message for an operation only the resource owner's +/// account may call, as the service writes it (with U+2019 in "owner's"). +pub(crate) const OWNER_ONLY: &str = + "This action is only supported by accounts that match the resource owner\u{2019}s account."; + +/// The ValidationException message for a batch or transaction whose tables +/// are in more than one account. +pub(crate) const MIXED_ACCOUNTS: &str = + "Invalid request! All tables in the request must be from a single account."; + +const ACCESS_IS_DENIED: &str = "Access is denied"; +const CALLER_NOT_AUTHORIZED: &str = + "The caller is not authorized to access the specified resource."; + +/// Another account's resource named by a request. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum OtherAccount { + /// The first such resource, with the action and resource a denial names. + Resource { + action: String, + resource: String, + account: String, + }, + /// A batch or transaction whose tables are in more than one account. + Mixed, +} + +/// The account of a `DynamoDB` resource ARN (`table/...`), or `None` for a +/// bare name or any other value. +fn arn_account(value: &str) -> Option { + extenddb_core::arn::parse(value).map(|r| r.account) +} + +/// The first resource `input` names by an ARN whose account is not +/// `account_id`, or `Mixed` for a batch or transaction across accounts. +pub(crate) fn find(operation: &str, input: &Value, account_id: &str) -> Option { + let field = |key: &str| input.get(key).and_then(Value::as_str); + let other = |value: &str, resource: String| { + let account = arn_account(value)?; + (account != account_id).then(|| OtherAccount::Resource { + action: operation.to_owned(), + resource, + account, + }) + }; + let named = |key: &str| field(key).and_then(|v| other(v, v.to_owned())); + match operation { + "TagResource" | "UntagResource" | "ListTagsOfResource" => named("ResourceArn"), + "DescribeStream" | "GetShardIterator" => named("StreamArn"), + // The engine trims a BackupArn before it resolves it, so the account + // is read from the trimmed value too. + "DescribeBackup" | "DeleteBackup" | "RestoreTableFromBackup" => field("BackupArn") + .map(extenddb_core::backup_format::trim_backup_arn) + .and_then(|v| other(v, v.to_owned())), + "ExportTableToPointInTime" => named("TableArn"), + "RestoreTableToPointInTime" => named("SourceTableArn"), + "BatchGetItem" | "BatchWriteItem" | "TransactGetItems" | "TransactWriteItems" => { + batch_or_transaction(operation, input, account_id) + } + "ListStreams" | "ImportTable" | "ListTables" | "DescribeLimits" | "DescribeEndpoints" + | "GetRecords" => None, + // An index query names the index ARN, built from the table ARN as + // written (measured: `.../table//index/`). + "Query" | "Scan" => { + let table = field("TableName")?; + let resource = match field("IndexName") { + Some(index) + if !index.is_empty() + && extenddb_core::arn::parse(table).is_some_and(|r| r.sub.is_none()) => + { + format!("{table}/index/{index}") + } + _ => table.to_owned(), + }; + other(table, resource) + } + _ => named("TableName"), + } +} + +/// The tables of a batch or transaction, in request order, with the action +/// each is authorized for: `Mixed` when they are in more than one account +/// (a bare name is in the caller's), else the first in another account. +fn batch_or_transaction(operation: &str, input: &Value, account_id: &str) -> Option { + let targets = crate::leading_keys::batch_transact_targets(operation, input, &HashMap::new())?; + let mut accounts = BTreeSet::new(); + let mut first = None; + for target in targets { + let account = arn_account(&target.table).unwrap_or_else(|| account_id.to_owned()); + if first.is_none() && account != account_id { + first = Some(OtherAccount::Resource { + action: target.action, + resource: target.table, + account: account.clone(), + }); + } + accounts.insert(account); + } + if accounts.len() > 1 { + return Some(OtherAccount::Mixed); + } + first +} + +/// The denial for a resource in an account that exists, which has no +/// resource-based policy granting the caller anything. `action` is the bare +/// operation; `resource` is the ARN as the caller wrote it. +pub(crate) fn resource_policy_denial(principal: &str, action: &str, resource: &str) -> String { + format!( + "User: {principal} is not authorized to perform: dynamodb:{action} on resource: \ + {resource} because no resource-based policy allows the dynamodb:{action} action" + ) +} + +/// The error for `operation` naming `resource` in another account, by whether +/// the catalog holds that account (`known`). See the module docs. +pub(crate) fn refusal( + operation: &str, + action: &str, + resource: &str, + known: bool, + principal: &str, +) -> DynamoDbError { + let access_denied = |m: &str| DynamoDbError::AccessDeniedException(m.to_owned()); + match (operation, known) { + ("RestoreTableToPointInTime", _) + | ( + "CreateTable" + | "DescribeTimeToLive" + | "UpdateTimeToLive" + | "DescribeContinuousBackups" + | "UpdateContinuousBackups" + | "CreateBackup" + | "ListBackups" + | "DescribeBackup" + | "DeleteBackup" + | "RestoreTableFromBackup" + | "ExportTableToPointInTime", + true, + ) => DynamoDbError::ValidationException(OWNER_ONLY.to_owned()), + (_, true) => access_denied(&resource_policy_denial(principal, action, resource)), + ( + "GetItem" | "PutItem" | "UpdateItem" | "DeleteItem" | "Query" | "Scan" | "BatchGetItem", + false, + ) => access_denied(CALLER_NOT_AUTHORIZED), + (_, false) => access_denied(ACCESS_IS_DENIED), + } +} + +/// True when this deployment's catalog holds `account`. A deployment without +/// a catalog store knows no other account. A lookup failure fails the request +/// (it is refused either way) with the authorization layer's internal error. +async fn account_in_catalog(state: &AppState, account: &str) -> Result { + let Some(store) = state.catalog_store.as_ref() else { + return Ok(false); + }; + match store.list_accounts_for(account).await { + Ok(rows) => Ok(rows.iter().any(|(id, _)| id == account)), + Err(e) => { + tracing::error!("Authorization: account lookup failed: {e:?}"); + Err(DynamoDbError::InternalServerError( + "Internal error during authorization".to_owned(), + )) + } + } +} + +/// The principal a denial names, in the form the service writes it. +fn principal_arn(identity: &AuthIdentity) -> String { + match identity { + AuthIdentity::User { + account_id, + user_name, + } => format!("arn:aws:iam::{account_id}:user/{user_name}"), + AuthIdentity::RoleSession { + account_id, + role_name, + session_name, + } => format!("arn:aws:sts::{account_id}:assumed-role/{role_name}/{session_name}"), + } +} + +/// The error for `operation` naming `resource` in another account. For an +/// account that exists ExtendDB's ARN shape checks run first, and one that +/// does not is refused before them, as on the service (measured 2026-11-03 +/// with an index or backup ARN in `ResourceArn`). The service also answers a +/// `StreamArn` in another partition or region with its shape +/// ValidationException there; ExtendDB's `StreamArn` check does not cover +/// those (#451). +fn answer( + operation: &str, + input: &Value, + action: &str, + resource: &str, + known: bool, + principal: &str, +) -> DynamoDbError { + if known { + let shape = if matches!( + operation, + "TagResource" | "UntagResource" | "ListTagsOfResource" + ) { + super::validate_tag_resource_arn_shape(resource) + } else { + super::validate_operation_arns(operation, input) + }; + if let Err(e) = shape { + return e; + } + } + refusal(operation, action, resource, known, principal) +} + +/// Refuse a request that names a resource in another account, before the IAM +/// decision, with the answer in the module docs. `Ok` when every resource the +/// request names by ARN is in `account_id`. +/// +/// A batch or transaction outside its count limits gets the count error +/// first, and the ARN shape checks run as [`answer`] describes. +pub(crate) async fn refuse( + state: &AppState, + identity: &AuthIdentity, + operation: &str, + input: &Value, + account_id: &str, +) -> Result<(), DynamoDbError> { + let Some(other) = find(operation, input, account_id) else { + return Ok(()); + }; + extenddb_engine::check_request_item_counts(operation, input)?; + let OtherAccount::Resource { + action, + resource, + account, + } = other + else { + return Err(DynamoDbError::ValidationException( + MIXED_ACCOUNTS.to_owned(), + )); + }; + let known = account_in_catalog(state, &account).await?; + Err(answer( + operation, + input, + &action, + &resource, + known, + &principal_arn(identity), + )) +} + +#[cfg(test)] +mod tests; diff --git a/crates/server/src/authz_resource/cross_account/tests.rs b/crates/server/src/authz_resource/cross_account/tests.rs new file mode 100644 index 000000000..d9f734ddb --- /dev/null +++ b/crates/server/src/authz_resource/cross_account/tests.rs @@ -0,0 +1,379 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Which resources name another account, and the answer for each. + +use super::{ + MIXED_ACCOUNTS, OWNER_ONLY, OtherAccount, answer, find, principal_arn, refusal, + resource_policy_denial, +}; +use extenddb_auth::AuthIdentity; +use extenddb_core::error::DynamoDbError; +use serde_json::json; + +const A: &str = "444455556666"; +const OTHER: &str = "111122223333"; +const OWN: &str = "arn:aws:dynamodb:us-east-1:444455556666:table/t"; +const FOREIGN: &str = "arn:aws:dynamodb:us-east-1:111122223333:table/t"; +const STREAM: &str = + "arn:aws:dynamodb:us-east-1:111122223333:table/t/stream/2026-10-20T16:17:40.188"; +const BACKUP: &str = + "arn:aws:dynamodb:us-east-1:111122223333:table/t/backup/01791976431438-73d8d5bc"; + +fn other(action: &str, resource: &str) -> Option { + Some(OtherAccount::Resource { + action: action.to_owned(), + resource: resource.to_owned(), + account: OTHER.to_owned(), + }) +} + +#[test] +fn every_resource_arn_field_is_read() { + for op in ["TagResource", "UntagResource", "ListTagsOfResource"] { + assert_eq!( + find(op, &json!({ "ResourceArn": FOREIGN }), A), + other(op, FOREIGN) + ); + assert_eq!( + find(op, &json!({ "ResourceArn": STREAM }), A), + other(op, STREAM) + ); + } + for op in ["DescribeStream", "GetShardIterator"] { + assert_eq!( + find(op, &json!({ "StreamArn": STREAM }), A), + other(op, STREAM) + ); + } + for op in ["DescribeBackup", "DeleteBackup", "RestoreTableFromBackup"] { + let input = json!({ "BackupArn": format!(" {BACKUP}\n"), "TargetTableName": "r" }); + assert_eq!( + find(op, &input, A), + other(op, BACKUP), + "{op}: the trimmed ARN" + ); + } + let input = json!({ "TableArn": FOREIGN, "S3Bucket": "b" }); + assert_eq!( + find("ExportTableToPointInTime", &input, A), + other("ExportTableToPointInTime", FOREIGN) + ); + let input = json!({ "SourceTableArn": FOREIGN, "TargetTableName": "r" }); + assert_eq!( + find("RestoreTableToPointInTime", &input, A), + other("RestoreTableToPointInTime", FOREIGN) + ); + for op in [ + "GetItem", + "PutItem", + "UpdateItem", + "DeleteItem", + "DescribeTable", + "UpdateTable", + "DeleteTable", + "CreateTable", + "DescribeTimeToLive", + "UpdateTimeToLive", + "DescribeContinuousBackups", + "UpdateContinuousBackups", + "CreateBackup", + "ListBackups", + "Query", + "Scan", + ] { + assert_eq!( + find(op, &json!({ "TableName": FOREIGN }), A), + other(op, FOREIGN), + "{op}" + ); + } +} + +/// An index query names the index under the table ARN as written (measured: +/// `.../table//index/` in the denial). +#[test] +fn an_index_query_names_the_index_arn() { + for op in ["Query", "Scan"] { + let input = json!({ "TableName": FOREIGN, "IndexName": "gsi1" }); + assert_eq!( + find(op, &input, A), + other(op, &format!("{FOREIGN}/index/gsi1")) + ); + } +} + +/// The caller's own resources, bare names, ARNs that are not a table resource +/// and fields the service reads as a name are left to authorization. +#[test] +fn own_account_names_and_name_fields_are_not_refused() { + for value in [ + OWN, + "arn:aws-cn:dynamodb:us-west-2:444455556666:table/t", + "t", + "arn:aws:s3:::bucket", + "arn:aws:dynamodb:us-east-1:111122223333:banana/x", + "", + ] { + assert_eq!( + find("GetItem", &json!({ "TableName": value }), A), + None, + "{value}" + ); + assert_eq!( + find("TagResource", &json!({ "ResourceArn": value }), A), + None, + "{value}" + ); + } + assert_eq!( + find("ListStreams", &json!({ "TableName": FOREIGN }), A), + None + ); + let input = json!({ "SourceTableName": FOREIGN, "TargetTableName": "r" }); + assert_eq!(find("RestoreTableToPointInTime", &input, A), None); + let input = json!({ "TableCreationParameters": { "TableName": FOREIGN } }); + assert_eq!(find("ImportTable", &input, A), None); + assert_eq!(find("ListTables", &json!({}), A), None); +} + +/// Tables in more than one account, the caller's bare name included, are +/// `Mixed` in either order (measured 2026-11-03); tables in one other account +/// name the first, with the action each item is authorized for. +#[test] +fn batch_and_transaction_tables_across_accounts_are_mixed() { + let key = json!({ "pk": { "S": "a" } }); + let batch = json!({ "RequestItems": { "t": { "Keys": [key] }, FOREIGN: { "Keys": [key] } } }); + assert_eq!(find("BatchGetItem", &batch, A), Some(OtherAccount::Mixed)); + for items in [ + json!([{ "Get": { "TableName": OWN, "Key": key } }, { "Get": { "TableName": FOREIGN, "Key": key } }]), + json!([{ "Get": { "TableName": FOREIGN, "Key": key } }, { "Get": { "TableName": "t", "Key": key } }]), + ] { + let input = json!({ "TransactItems": items }); + assert_eq!( + find("TransactGetItems", &input, A), + Some(OtherAccount::Mixed) + ); + } + let third = "arn:aws:dynamodb:us-east-1:777788889999:table/t"; + let batch = json!({ "RequestItems": { FOREIGN: [{ "PutRequest": { "Item": key } }], third: [{ "PutRequest": { "Item": key } }] } }); + assert_eq!(find("BatchWriteItem", &batch, A), Some(OtherAccount::Mixed)); + + let batch = json!({ "RequestItems": { FOREIGN: [{ "PutRequest": { "Item": key } }] } }); + assert_eq!( + find("BatchWriteItem", &batch, A), + other("BatchWriteItem", FOREIGN) + ); + let input = json!({ "TransactItems": [ + { "ConditionCheck": { "TableName": FOREIGN, "Key": key, "ConditionExpression": "attribute_exists(pk)" } }, + { "Put": { "TableName": FOREIGN, "Item": key } }, + ] }); + assert_eq!( + find("TransactWriteItems", &input, A), + other("ConditionCheckItem", FOREIGN) + ); + let input = json!({ "TransactItems": [{ "Update": { "TableName": FOREIGN, "Key": key } }] }); + assert_eq!( + find("TransactWriteItems", &input, A), + other("UpdateItem", FOREIGN) + ); + let batch = json!({ "RequestItems": { "t": { "Keys": [key] }, OWN: { "Keys": [key] } } }); + assert_eq!(find("BatchGetItem", &batch, A), None); +} + +fn message(err: &DynamoDbError) -> (&'static str, String) { + match err { + DynamoDbError::AccessDeniedException(m) => ("AccessDeniedException", m.clone()), + DynamoDbError::ValidationException(m) => ("ValidationException", m.clone()), + other => panic!("unexpected {other:?}"), + } +} + +/// The answer for each operation family, for an account the catalog holds +/// and for one it does not (measured 2026-11-03, admin and no-policy caller). +#[test] +fn the_answer_depends_on_the_operation_and_the_account() { + let p = "arn:aws:iam::444455556666:user/u"; + let long = |action: &str, resource: &str| { + ( + "AccessDeniedException", + format!( + "User: {p} is not authorized to perform: dynamodb:{action} on resource: \ + {resource} because no resource-based policy allows the dynamodb:{action} action" + ), + ) + }; + let access = ("AccessDeniedException", "Access is denied".to_owned()); + let caller = ( + "AccessDeniedException", + "The caller is not authorized to access the specified resource.".to_owned(), + ); + let owner = ("ValidationException", OWNER_ONLY.to_owned()); + for op in [ + "TagResource", + "UntagResource", + "ListTagsOfResource", + "DescribeTable", + "UpdateTable", + "DeleteTable", + "BatchWriteItem", + "TransactGetItems", + "TransactWriteItems", + "DescribeStream", + "GetShardIterator", + "SearchVectors", + ] { + assert_eq!( + message(&refusal(op, op, FOREIGN, true, p)), + long(op, FOREIGN), + "{op}" + ); + assert_eq!(message(&refusal(op, op, FOREIGN, false, p)), access, "{op}"); + } + for op in [ + "GetItem", + "PutItem", + "UpdateItem", + "DeleteItem", + "Query", + "Scan", + "BatchGetItem", + ] { + assert_eq!( + message(&refusal(op, op, FOREIGN, true, p)), + long(op, FOREIGN), + "{op}" + ); + assert_eq!(message(&refusal(op, op, FOREIGN, false, p)), caller, "{op}"); + } + for op in [ + "CreateTable", + "DescribeTimeToLive", + "UpdateTimeToLive", + "DescribeContinuousBackups", + "UpdateContinuousBackups", + "CreateBackup", + "ListBackups", + "DescribeBackup", + "DeleteBackup", + "RestoreTableFromBackup", + "ExportTableToPointInTime", + ] { + assert_eq!(message(&refusal(op, op, FOREIGN, true, p)), owner, "{op}"); + assert_eq!(message(&refusal(op, op, FOREIGN, false, p)), access, "{op}"); + } + for known in [true, false] { + let err = refusal( + "RestoreTableToPointInTime", + "RestoreTableToPointInTime", + FOREIGN, + known, + p, + ); + assert_eq!(message(&err), owner); + } + // A transaction item names its own action. + assert_eq!( + message(&refusal( + "TransactWriteItems", + "ConditionCheckItem", + FOREIGN, + true, + p + )), + long("ConditionCheckItem", FOREIGN) + ); +} + +/// An account the catalog holds gets the ARN shape checks before the refusal; +/// one it does not hold is refused first (measured 2026-11-03 with an index +/// ARN in `ResourceArn` and a `StreamArn` in another partition). +#[test] +fn shape_checks_come_first_only_for_a_known_account() { + let p = "arn:aws:iam::444455556666:user/u"; + let access = ("AccessDeniedException", "Access is denied".to_owned()); + // A table ARN where a stream ARN belongs. + let input = json!({ "StreamArn": FOREIGN }); + let unknown = answer( + "DescribeStream", + &input, + "DescribeStream", + FOREIGN, + false, + p, + ); + assert_eq!(message(&unknown), access); + let known = answer("DescribeStream", &input, "DescribeStream", FOREIGN, true, p); + assert_eq!( + message(&known), + ("ValidationException", "Invalid StreamArn".to_owned()) + ); + let index = format!("{FOREIGN}/index/gsi1"); + let input = json!({ "ResourceArn": index }); + let unknown = answer( + "ListTagsOfResource", + &input, + "ListTagsOfResource", + &index, + false, + p, + ); + assert_eq!(message(&unknown), access); + let (code, msg) = message(&answer( + "ListTagsOfResource", + &input, + "ListTagsOfResource", + &index, + true, + p, + )); + assert_eq!(code, "ValidationException"); + assert!(msg.contains("only table or stream is accepted"), "{msg}"); + // A well-formed stream ARN passes the shape check and gets the denial. + let input = json!({ "StreamArn": STREAM }); + let known = answer("DescribeStream", &input, "DescribeStream", STREAM, true, p); + assert_eq!( + message(&known).1, + resource_policy_denial(p, "DescribeStream", STREAM) + ); +} + +/// The service writes the apostrophe in "owner's" as U+2019, and the mixed +/// account message as measured. +#[test] +fn messages_are_the_service_text() { + assert_eq!( + OWNER_ONLY, + "This action is only supported by accounts that match the resource owner\u{2019}s account." + ); + assert_eq!( + MIXED_ACCOUNTS, + "Invalid request! All tables in the request must be from a single account." + ); + assert_eq!( + resource_policy_denial("P", "GetItem", FOREIGN), + format!( + "User: P is not authorized to perform: dynamodb:GetItem on resource: {FOREIGN} \ + because no resource-based policy allows the dynamodb:GetItem action" + ) + ); +} + +/// A role session is named by its STS ARN, as the service names it. +#[test] +fn principal_is_named_as_the_service_names_it() { + let user = AuthIdentity::User { + account_id: A.to_owned(), + user_name: "u".to_owned(), + }; + assert_eq!(principal_arn(&user), "arn:aws:iam::444455556666:user/u"); + let role = AuthIdentity::RoleSession { + account_id: A.to_owned(), + role_name: "r".to_owned(), + session_name: "s".to_owned(), + }; + assert_eq!( + principal_arn(&role), + "arn:aws:sts::444455556666:assumed-role/r/s" + ); +} diff --git a/crates/server/src/authz_resource/supplied_resource_tests.rs b/crates/server/src/authz_resource/supplied_resource_tests.rs new file mode 100644 index 000000000..8b3bfaed4 --- /dev/null +++ b/crates/server/src/authz_resource/supplied_resource_tests.rs @@ -0,0 +1,190 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The denial names the resource as the caller supplied it (issue #447). + +use super::{AuthzTarget, authz_targets, table_target}; +use serde_json::json; + +const R: &str = "us-east-1"; +const A: &str = "444455556666"; +const CANON: &str = "arn:aws:dynamodb:us-east-1:444455556666:table/bb-arn-main"; + +fn single(op: &str, input: serde_json::Value) -> AuthzTarget { + let mut t = authz_targets(op, &input, R, A); + assert_eq!(t.len(), 1, "{op}: {t:?}"); + t.remove(0) +} + +/// The three TagResource spellings the service was measured with: the +/// policies are matched against the ARN as written (measured 2026-11-03 with +/// a caller allowed only on the canonical ARN), the resource tags are read +/// under the canonical ARN, and the denial names the ARN as written +/// (measured 2026-10-20, no-policy caller). +#[test] +fn tag_resource_arn_respelled_fields_are_matched_and_named_as_written() { + for supplied in [ + "arn:aws-cn:dynamodb:us-east-1:444455556666:table/bb-arn-main", + "arn:aws:s3:us-east-1:444455556666:table/bb-arn-main", + "arn:aws:dynamodb:us-west-2:444455556666:table/bb-arn-main", + ] { + for op in ["TagResource", "UntagResource", "ListTagsOfResource"] { + let t = single(op, json!({ "ResourceArn": supplied })); + assert_eq!(t.resource, CANON, "{op} {supplied}"); + assert_eq!(t.supplied, supplied, "{op} {supplied}"); + assert_eq!(t.policy_resource(), supplied, "{op} {supplied}"); + } + } + let stream = + "arn:aws-cn:dynamodb:us-east-1:444455556666:table/T/stream/2026-10-20T16:17:40.188"; + let t = single("TagResource", json!({ "ResourceArn": stream })); + assert_eq!( + t.resource, + "arn:aws:dynamodb:us-east-1:444455556666:table/T/stream/2026-10-20T16:17:40.188" + ); + assert_eq!(t.policy_resource(), stream); +} + +/// A canonical ARN or a bare name names the canonical ARN in every role. +#[test] +fn canonical_arn_and_bare_name_name_the_canonical_arn() { + let t = single("TagResource", json!({ "ResourceArn": CANON })); + assert_eq!((t.resource.as_str(), t.supplied.as_str()), (CANON, CANON)); + assert_eq!(t.policy_resource(), CANON); + let t = single("GetItem", json!({ "TableName": "bb-arn-main" })); + assert_eq!((t.resource.as_str(), t.supplied.as_str()), (CANON, CANON)); + let t = single("GetItem", json!({ "TableName": CANON })); + assert_eq!((t.resource.as_str(), t.supplied.as_str()), (CANON, CANON)); + // A foreign-service ARN that is not a table resource is left verbatim + // in every role, so authorization denies it and names it as written. + let t = single( + "TagResource", + json!({ "ResourceArn": "arn:aws:s3:::bucket" }), + ); + assert_eq!(t.resource, "arn:aws:s3:::bucket"); + assert_eq!(t.supplied, "arn:aws:s3:::bucket"); + assert_eq!(t.policy_resource(), "arn:aws:s3:::bucket"); +} + +/// Every other ARN-bearing field keeps the caller's spelling for the +/// denial while matching the canonical resource. +#[test] +fn other_arn_fields_keep_the_supplied_spelling() { + let respelled_table = "arn:aws:dynamodb:us-west-2:444455556666:table/bb-arn-main"; + for op in [ + "GetItem", + "PutItem", + "Query", + "Scan", + "DescribeTable", + "CreateBackup", + "DescribeContinuousBackups", + ] { + let t = single(op, json!({ "TableName": respelled_table })); + assert_eq!(t.resource, CANON, "{op}"); + assert_eq!(t.supplied, respelled_table, "{op}"); + assert_eq!(t.policy_resource(), CANON, "{op}"); + } + let t = single( + "Query", + json!({ "TableName": respelled_table, "IndexName": "gsi1" }), + ); + assert_eq!(t.resource, format!("{CANON}/index/gsi1")); + assert_eq!(t.supplied, format!("{respelled_table}/index/gsi1")); + assert_eq!(t.policy_resource(), format!("{CANON}/index/gsi1")); + + let stream = + "arn:aws-cn:dynamodb:us-east-1:444455556666:table/T/stream/2026-10-20T16:17:40.188"; + let t = single("DescribeStream", json!({ "StreamArn": stream })); + assert_eq!( + t.resource, + "arn:aws:dynamodb:us-east-1:444455556666:table/T/stream/2026-10-20T16:17:40.188" + ); + assert_eq!(t.supplied, stream); + assert_eq!(t.policy_resource(), t.resource); + + let backup = "arn:aws:dynamodb:eu-west-1:444455556666:table/T/backup/01-ab"; + let t = single( + "DescribeBackup", + json!({ "BackupArn": format!("{backup}\n") }), + ); + assert_eq!( + t.resource, + "arn:aws:dynamodb:us-east-1:444455556666:table/T/backup/01-ab" + ); + assert_eq!( + t.supplied, backup, + "the trimmed ARN, as the engine resolves it" + ); + + let t = single( + "ExportTableToPointInTime", + json!({ "TableArn": respelled_table, "S3Bucket": "x" }), + ); + assert_eq!(t.resource, CANON); + assert_eq!(t.supplied, respelled_table); + + let v = authz_targets( + "RestoreTableToPointInTime", + &json!({ "SourceTableArn": respelled_table, "TargetTableName": "pitr" }), + R, + A, + ); + assert_eq!(v[0].resource, CANON); + assert_eq!(v[0].supplied, respelled_table); + let ptarget = "arn:aws:dynamodb:us-east-1:444455556666:table/pitr"; + assert!( + v[1..] + .iter() + .all(|t| t.resource == ptarget && t.supplied == ptarget) + ); + + // Batch and transaction tables go through `table_target`. + let t = table_target("BatchGetItem", respelled_table, R, A); + assert_eq!(t.action, "BatchGetItem"); + assert_eq!(t.resource, CANON); + assert_eq!(t.supplied, respelled_table); + let t = table_target("GetItem", "bb-arn-main", R, A); + assert_eq!((t.resource.as_str(), t.supplied.as_str()), (CANON, CANON)); +} + +/// An ARN in TableName that is not a table resource ARN (an index ARN, a +/// malformed resource) names the canonical index ARN on an index query, so +/// the denial never names a resource the caller did not write. +#[test] +fn index_query_with_a_non_table_arn_names_the_canonical_index_arn() { + for table_name in [ + "arn:aws:dynamodb:us-east-1:444455556666:table/bb-arn-main/index/gsi1", + "arn:aws:dynamodb:us-east-1:444455556666:TABLE/bb-arn-main", + "arn:aws:dynamodb:us-east-1:444455556666:table/x/table/bb-arn-main", + "arn:aws:dynamodb:us-east-1:444455556666:banana/x", + ] { + for op in ["Query", "Scan"] { + let t = single(op, json!({ "TableName": table_name, "IndexName": "gsi1" })); + assert!( + !t.supplied.contains("/index/gsi1/index/"), + "{op} {table_name}: {}", + t.supplied + ); + assert_eq!(t.supplied, t.resource, "{op} {table_name}"); + } + } +} + +/// The service reads ImportTable's new table name as a name even when it is +/// an ARN, and authorizes `table/` in the caller's account +/// (measured 2026-11-03 with another account's table ARN as the name). +#[test] +fn import_table_name_is_a_name_even_when_it_is_an_arn() { + let value = "arn:aws:dynamodb:us-east-1:111122223333:table/x"; + let t = single( + "ImportTable", + json!({ "TableCreationParameters": { "TableName": value } }), + ); + let expected = format!("arn:aws:dynamodb:us-east-1:444455556666:table/{value}"); + assert_eq!( + (t.resource.as_str(), t.supplied.as_str()), + (expected.as_str(), expected.as_str()) + ); + assert_eq!(t.policy_resource(), expected); +} diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index ab1791692..8c0d6e39c 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -186,11 +186,18 @@ pub(crate) async fn authorize_request( return Ok(authorized_key_info); } - // Tag operations validate the ResourceArn shape before the IAM decision, as - // the service does: an index ARN (tags are accepted only on a table or - // stream) is rejected with ValidationException for every principal, before - // authorization. A table or stream ARN passes; its account, region, and - // existence are checked after authorization by the engine tag handler. + // A request that names a resource in another account by ARN is refused + // before the IAM decision, for every principal, with the service's answer + // for the operation and for whether this deployment knows the account + // (measured 2026-11-03 for an admin and a no-policy caller; see + // `authz_resource::cross_account`). + authz_resource::cross_account::refuse(state, identity, operation, input, account_id).await?; + + // The tag operations' ResourceArn shape is validated before the IAM + // decision: an index ARN in this account (tags are accepted only on a + // table or stream) is rejected with ValidationException for every + // principal. A table or stream ARN passes; its region and existence are + // checked after authorization by the engine tag handler. if matches!( operation, "TagResource" | "UntagResource" | "ListTagsOfResource" @@ -241,8 +248,7 @@ pub(crate) async fn authorize_request( authorization::check_authorization( state.authz_cache.as_ref(), identity, - "GetRecords", - &resource_arn, + &authz_resource::AuthzTarget::new("GetRecords", resource_arn), false, base_params(request_tags, request_tag_keys), ) @@ -308,8 +314,7 @@ pub(crate) async fn authorize_request( authorization::check_authorization( state.authz_cache.as_ref(), identity, - operation, - &resource_arn, + &authz_resource::AuthzTarget::new(operation, resource_arn), false, base_params(request_tags, request_tag_keys), ) @@ -319,9 +324,13 @@ pub(crate) async fn authorize_request( // 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(&target.table, &state.region, account_id); + // per-table Deny. The denial names the key as written. + let authz_target = authz_resource::table_target( + &target.action, + &target.table, + &state.region, + account_id, + ); let params = extenddb_auth::policy::context::RequestParams { leading_keys: target.leading_keys, ..base_params(request_tags.clone(), request_tag_keys.clone()) @@ -329,8 +338,7 @@ pub(crate) async fn authorize_request( authorization::check_authorization( state.authz_cache.as_ref(), identity, - &target.action, - &resource_arn, + &authz_target, false, params, ) @@ -340,11 +348,12 @@ pub(crate) async fn authorize_request( return Ok(authorized_key_info); } - // 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`. + // Derive the targets 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 for the policy match, + // never a `table/*` fallback, with the field as written kept for the + // denial message. See `authz_resource`. let targets = authz_resource::authz_targets(operation, input, &state.region, account_id); let is_scan = operation == "Scan"; @@ -373,12 +382,10 @@ pub(crate) async fn authorize_request( .map(ToOwned::to_owned), ..base_params(request_tags, request_tag_keys) }; - let (action, resource_arn) = &targets[0]; authorization::check_authorization( state.authz_cache.as_ref(), identity, - action, - resource_arn, + &targets[0], is_scan, params, ) @@ -387,12 +394,11 @@ pub(crate) async fn authorize_request( // Multi-permission operations (restore). Each pair is authorized with // the request-level params; a Deny on any required resource fails the // request. - for (action, resource_arn) in &targets { + for target in &targets { authorization::check_authorization( state.authz_cache.as_ref(), identity, - action, - resource_arn, + target, false, base_params(request_tags.clone(), request_tag_keys.clone()), ) diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 95d235cff..a1dd6cd5f 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -4,6 +4,9 @@ This document lists all known behavioral differences between ExtendDB and real Amazon DynamoDB. Use it to understand what works identically and what requires adaptation when switching between ExtendDB and the real service. +A note such as (measured) or (measured on SQLite) means that the answer comes +from a request sent to that service or backend. + ## Storage and Infrastructure | Area | DynamoDB | ExtendDB | @@ -29,7 +32,8 @@ adaptation when switching between ExtendDB and the real service. | SourceIdentity, TransitiveTagKeys | Supported | Not implemented | | Resource policies | Supported | Not implemented (deferred) | | Table ARN as `TableName` | Accepted; the request acts on that table | Authorized against that table, then the request fails with `ResourceNotFoundException` | -| AccessDenied message for an ARN outside this deployment (caller with no policy) | Names the ARN as supplied; an ARN in another account gets `Access is denied` | Names the ARN rewritten to this deployment's partition, service, region and account; an ARN in another account gets the full `is not authorized to perform` message. The decision is the same. | +| AccessDenied message for an ARN outside this deployment (caller with no policy) | Names the ARN as supplied. A table ARN in `TableName` with another partition, service or region never reaches the IAM decision: it is `ValidationException` ("Invalid AWS partition", "Invalid vendor code", "Invalid AWS region") for every principal (measured) | Names the ARN as supplied. A table ARN in `TableName` with another partition, service or region is authorized as this deployment's table, and a caller with no policy is denied naming the ARN as supplied | +| A resource ARN in another account | Refused with the same answer for a caller allowed `dynamodb:*` on `*` and a caller with no policy. When the account exists: `AccessDeniedException` "User: is not authorized to perform: dynamodb: on resource: because no resource-based policy allows the dynamodb: action", except `CreateTable`, the time-to-live, continuous-backup, backup, export and restore operations, which answer `ValidationException` "This action is only supported by accounts that match the resource owner’s account.". When it does not: `AccessDeniedException` "The caller is not authorized to access the specified resource." on `GetItem`, `PutItem`, `UpdateItem`, `Query`, `Scan` and `BatchGetItem`, the same `ValidationException` on `RestoreTableToPointInTime`, and `AccessDeniedException` "Access is denied" on the rest. A batch or transaction with tables in more than one account is `ValidationException` "Invalid request! All tables in the request must be from a single account." (measured) | The same answers, before the IAM decision, where an account exists when this deployment's catalog holds it. `DeleteTable`, `DeleteItem` and `DeleteBackup` take the answers of `UpdateTable`, `UpdateItem` and `DescribeBackup` (not measured). A table ARN in `TableName` that also has another partition, service or region, or that names an index or backup, gets these answers too. The service answers it with a `ValidationException` (the partition, vendor or region text in the row above, or "Valid ARN format is ..." for an index or backup ARN): on `GetItem` whether or not the account exists, and on `DescribeTable` when it exists (measured; the other operations were not sent). A `StreamArn` with another partition or region, in an account this deployment's catalog holds, gets the denial above, where the service answers `ValidationException` "Invalid StreamArn" (measured) | ## Import and Export @@ -75,7 +79,7 @@ adaptation when switching between ExtendDB and the real service. | Area | DynamoDB | ExtendDB | |------|----------|------| | TagResource / UntagResource | Validates resource ARN exists, returns `ResourceNotFoundException` for missing tables | Matches DynamoDB — validates resource ARN and returns `ResourceNotFoundException` for missing tables. | -| `ResourceArn` naming a backup (`table//backup/`) | Backups take no tags: `TagResource`, `UntagResource`, and `ListTagsOfResource` take table and stream ARNs, and the authorization reference lists no `aws:ResourceTag` key for the backup resource. The service's reply to a backup ARN was not measured | `TagResource`, `UntagResource`, and `ListTagsOfResource` refuse it with `ValidationException` ("One or more parameter values were invalid: Provided Arn is not a DynamoDB resource arn: "), the reply already given for an ARN whose resource is not `table/...`. So are `table//backup`, `table//backup/`, and any longer path under a backup. The account and region checks come first (another account's backup ARN stays `AccessDeniedException`, another region's stays the region `ValidationException`) and the table is not looked up. Earlier builds accepted a backup ARN and stored its tags; such tags stay in storage but nothing reads them: `ListTagsOfResource` and `UntagResource` refuse the ARN, and authorization ignores them (see "Tag conditions on backups" under Backup and Restore). `TagResource` and `UntagResource` are still authorized against `table/*`, so a policy statement on `table/*/backup/*` does not apply to them. Measured on PostgreSQL and SQLite; the check is in the engine, the same for every backend | +| `ResourceArn` naming a backup (`table//backup/`) | Backups take no tags: `TagResource`, `UntagResource`, and `ListTagsOfResource` take table and stream ARNs, and the authorization reference lists no `aws:ResourceTag` key for the backup resource. The service's reply to a backup ARN was not measured | `TagResource`, `UntagResource`, and `ListTagsOfResource` refuse it with `ValidationException` ("One or more parameter values were invalid: Provided Arn is not a DynamoDB resource arn: "), the reply already given for an ARN whose resource is not `table/...`. So are `table//backup`, `table//backup/`, and any longer path under a backup. Another account's backup ARN is `AccessDeniedException` "Access is denied" when this deployment's catalog does not hold the account and gets this `ValidationException` when it does, as on the service (measured); another region's stays the region `ValidationException`, and the table is not looked up. Earlier builds accepted a backup ARN and stored its tags; such tags stay in storage but nothing reads them: `ListTagsOfResource` and `UntagResource` refuse the ARN, and authorization ignores them (see "Tag conditions on backups" under Backup and Restore). `TagResource` and `UntagResource` are still authorized against `table/*`, so a policy statement on `table/*/backup/*` does not apply to them. Measured on PostgreSQL and SQLite; the check is in the engine, the same for every backend | | Tag condition keys on tag operations | `aws:RequestTag/` and `aws:TagKeys` are populated for `TagResource` and `CreateTable`, and `aws:TagKeys` for `UntagResource` (service authorization reference, dynamodb v1.5). Whether `CreateTable` with `Tags` is also authorized as `dynamodb:TagResource` was not measured | The same three operations populate the same keys, and every other operation leaves both absent. The operator semantics (set operators, `Null`, a bare negated operator on an absent `aws:TagKeys`, tag name case) come from `iam simulate-custom-policy` runs, not from DynamoDB requests. A request that names one tag under two spellings with differing values fails closed: a `Deny` that reads the key blocks and an `Allow` that reads it does not grant; the service's answer was not measured. A condition value `${aws:TagKeys}` stays literal text, as on IAM and on a table resource policy (measured 2026-10-27). Tag names in `aws:RequestTag/` match without regard to case on both (`Étag` and `étag` match on the service), but ExtendDB's Unicode case folding also equates the sharp s with `ss` (`Straße` and `STRASSE`), which the service does not (measured on IAM and on a table resource policy, 2026-10-27). An `Allow` gated on `aws:RequestTag/STRASSE` grants a request carrying `Straße` on ExtendDB where the service would not (inferred from the same measurement; the Allow direction was not run) | | A tag key repeated in one `TagResource` or `CreateTable` request | `ValidationException` "Duplicate Tag Keys provided as input: Duplicate Tag Key found " on both operations (measured 2026-10-27), and nothing is stored | `TagResource` accepts it and stores the last value, and authorization reads that value. `CreateTable` answers HTTP 500 `InternalServerError` on SQLite and PostgreSQL and creates nothing, because the tags are inserted with a plain `INSERT`. Measured on SQLite and PostgreSQL on 2026-10-27 | @@ -110,7 +114,7 @@ Behaviour stated as measured, and every number below, was observed with release | Members of the `RestoreTableFromBackup` response | The `CREATING` description omits `DeletionProtectionEnabled`, the table's `ProvisionedThroughput.NumberOfDecreasesToday`, and each GSI's `IndexStatus` | The same on all four backends; `DescribeTable` reports the first two. Each GSI's `ProvisionedThroughput` keeps `NumberOfDecreasesToday`; what the service sends there was not measured | | Restoring into a name that exists | `TableAlreadyExistsException`; into a name that is itself a `CREATING` restore target, `TableInUseException`: "Table: is already being restored from backup: " for the same backup, and "Attempt to change a resource which is still in use: Table: is being used." for a different one (both measured) | `TableAlreadyExistsException` on all four backends for an existing table; `TableInUseException` for a name that is a `CREATING` restore target, whichever backup is restored, always with the same-backup text naming the backup the existing target is being restored from (the service's text for a different backup is not reproduced). Each backend checks the name before the backup and before a restore slot is taken, so the same answer comes back whatever state the backup is in (a missing backup reaches the backend only when it is deleted after the engine has read it) and a client retrying its own restore is answered at once; the target insert is the fence that tells an existing table from a `CREATING` target, so two simultaneous requests cannot both pass. The existing table or target is unchanged | | `UpdateTable` on a `CREATING` restore target | `ResourceNotFoundException` (measured on DynamoDB) | `ResourceInUseException` on all four backends, the engine's mapping of a table that is not `ACTIVE`. A plain `CreateTable` into the target's name is `ResourceInUseException` "Table already exists: ", the service's text for a taken name (measured on DynamoDB), without the backup ARN. The target is unchanged | -| `BackupArn` that is malformed or belongs to another account | Not an ARN of the right shape, too long (over 1024), a control character, a slash or `..` in the region: `ValidationException` "Invalid Request: BackupArn is not valid". The right shape with an id that is not a backup id (not hex, a wildcard): `ValidationException` "Invalid Backup ARN" (both measured; which input gets which text was captured for one input only: a bare 13-digit id is "Invalid Request: BackupArn is not valid"). Surrounding whitespace, a trailing newline for one, is trimmed, and an ARN naming another region of the caller's account resolves (measured). One in another partition is `ValidationException` (measured; text not captured) | The same two texts on all four backends, split by ExtendDB's parser: a string that is not `arn:aws:dynamodb:::table//backup/` with a region in `[a-z0-9-]`, a digit-only account, a name in `[a-zA-Z0-9_.-]`, only printable ASCII, and at most 1024 bytes after trimming is "Invalid Request: BackupArn is not valid" (so is one in a partition other than `aws`, the only one ExtendDB issues); one of that shape whose id is neither `[0-9]{14}-[0-9a-f]{8}` nor a shape an earlier build issued (13 digits, `-`, 8 hex digits, which every backend issued before the shared id; 13 bare digits, which PostgreSQL v0.1.0 to v0.1.2 issued) is "Invalid Backup ARN"; no build has put a UUID in a backup ARN, so none resolves. Surrounding ASCII whitespace is trimmed (only a trailing newline was measured on the service; trimming all surrounding whitespace is ExtendDB's generalisation), and the region is checked for shape only: the backup is looked up under the ARN rebuilt in this deployment's `server.region` first and, when that is not found and the caller wrote a different ARN, under the ARN as written, so another region's resolves and a backup created under an earlier `server.region` stays reachable by the ARN `ListBackups` reports for it, though not by that ARN rewritten with the new region (see the upgrade manual on `server.region`). A bare 13-digit id (the shape PostgreSQL v0.1.0 to v0.1.2 issued) passes the id check, so such an ARN that names no backup is `BackupNotFoundException`, where the service answers `ValidationException` "Invalid Request: BackupArn is not valid" (measured on DynamoDB). A `server.region` outside `[a-z0-9-]` makes the server's own ARNs "not valid"; the server starts and logs a warning saying so. A well-formed ARN naming another account, of any number of digits, is `AccessDeniedException` on `DescribeBackup`, `DeleteBackup`, and `RestoreTableFromBackup` (not measured on the service). The same checks apply to `ListBackups`'s `ExclusiveStartBackupArn`, where another account's or region's cursor is instead a `ValidationException` (see `ListBackups` paging). With authorization on (not dev mode), `DescribeBackup` and `DeleteBackup` first refuse a `BackupArn` that does not parse as a backup ARN at all (a table ARN, an `xrn:` prefix, an empty field) with "Invalid Backup ARN" before any policy is evaluated, as the service does (measured on DynamoDB), so those shapes get that text instead of the engine's. Every other `BackupArn` is authorized as the backup it names in this deployment (the table and id, in the server's region and the caller's account, surrounding whitespace trimmed as the engine trims it) and then checked by the engine as above. Same on all four backends | +| `BackupArn` that is malformed or belongs to another account | Not an ARN of the right shape, too long (over 1024), a control character, a slash or `..` in the region: `ValidationException` "Invalid Request: BackupArn is not valid". The right shape with an id that is not a backup id (not hex, a wildcard): `ValidationException` "Invalid Backup ARN" (both measured; which input gets which text was captured for one input only: a bare 13-digit id is "Invalid Request: BackupArn is not valid"). Surrounding whitespace, a trailing newline for one, is trimmed, and an ARN naming another region of the caller's account resolves (measured). One in another partition is `ValidationException` (measured; text not captured) | The same two texts on all four backends, split by ExtendDB's parser: a string that is not `arn:aws:dynamodb:::table//backup/` with a region in `[a-z0-9-]`, a digit-only account, a name in `[a-zA-Z0-9_.-]`, only printable ASCII, and at most 1024 bytes after trimming is "Invalid Request: BackupArn is not valid" (so is one in a partition other than `aws`, the only one ExtendDB issues); one of that shape whose id is neither `[0-9]{14}-[0-9a-f]{8}` nor a shape an earlier build issued (13 digits, `-`, 8 hex digits, which every backend issued before the shared id; 13 bare digits, which PostgreSQL v0.1.0 to v0.1.2 issued) is "Invalid Backup ARN"; no build has put a UUID in a backup ARN, so none resolves. Surrounding ASCII whitespace is trimmed (only a trailing newline was measured on the service; trimming all surrounding whitespace is ExtendDB's generalisation), and the region is checked for shape only: the backup is looked up under the ARN rebuilt in this deployment's `server.region` first and, when that is not found and the caller wrote a different ARN, under the ARN as written, so another region's resolves and a backup created under an earlier `server.region` stays reachable by the ARN `ListBackups` reports for it, though not by that ARN rewritten with the new region (see the upgrade manual on `server.region`). A bare 13-digit id (the shape PostgreSQL v0.1.0 to v0.1.2 issued) passes the id check, so such an ARN that names no backup is `BackupNotFoundException`, where the service answers `ValidationException` "Invalid Request: BackupArn is not valid" (measured on DynamoDB). A `server.region` outside `[a-z0-9-]` makes the server's own ARNs "not valid"; the server starts and logs a warning saying so. A well-formed ARN naming another account, of any number of digits, is refused on `DescribeBackup`, `DeleteBackup`, and `RestoreTableFromBackup` before any policy is evaluated, with the service's answers (see "A resource ARN in another account"); in dev mode the engine answers `AccessDeniedException`. The same checks apply to `ListBackups`'s `ExclusiveStartBackupArn`, where another account's or region's cursor is instead a `ValidationException` (see `ListBackups` paging). With authorization on (not dev mode), `DescribeBackup` and `DeleteBackup` first refuse a `BackupArn` that does not parse as a backup ARN at all (a table ARN, an `xrn:` prefix, an empty field) with "Invalid Backup ARN" before any policy is evaluated, as the service does (measured on DynamoDB), so those shapes get that text instead of the engine's. Every other `BackupArn` is authorized as the backup it names in this deployment (the table and id, in the server's region and the caller's account, surrounding whitespace trimmed as the engine trims it) and then checked by the engine as above. Same on all four backends | | Tag conditions on backups | The backup resource has no `aws:ResourceTag` condition key: a backup has no tags to match | `DescribeBackup`, `DeleteBackup`, and `RestoreTableFromBackup` are authorized on the backup ARN as a resource with no tags. An `aws:ResourceTag/` condition finds the key absent for a backup (and `dynamodb:ResourceTag/` is absent for every resource), so, for example, an Allow conditioned on `StringEquals` never grants on a backup and a Deny conditioned on `StringEquals` never denies one. This holds whatever an earlier build stored under the backup ARN (it accepted `TagResource` on one; see Tagging), and a backup does not take its table's tags. For `RestoreTableFromBackup`, the seven item actions evaluated on `table/` (see the authorization rows below) read that ARN's resource tags. A target that does not exist has no tags, so an `aws:ResourceTag/` condition on those actions finds none and does not grant the restore; a target that exists is evaluated with its tags, and a request that is otherwise valid then passes authorization and fails `TableAlreadyExistsException` (or `TableInUseException` for a `CREATING` restore target) when the condition is met, and fails `AccessDeniedException` naming the first item action when it is not (measured on SQLite). Whether the service reads a target's tags in these evaluations was not measured. Scope backup permissions by ARN (`table//backup/*`). Measured on PostgreSQL and SQLite for the Allow case, including tags written under a backup ARN by the earlier build and then served by this one; the Deny case and role sessions rest on unit tests of the authorization path, which is the same for every backend | | Authorization of `ListBackups` | `dynamodb:ListBackups` is evaluated on `arn:aws:dynamodb:::table/*/backup/*`, or on `table//backup/*` when `TableName` filters the list (measured on DynamoDB) | The same on all four backends (the check runs in the server before the backend is reached). A statement on `*` or `table/*` covers every list; one on `table//backup/*` (or `table//*`) covers only the list filtered to that table; one on the table's own ARN, `table/`, covers none. The unfiltered list is one resource, so a Deny on `table//backup/*` denies the list filtered to that table and not the unfiltered list that would include its backups, which follows from the resources the service evaluates. A `TableName` that is not a string is evaluated as no filter; the engine then refuses the request. These resources have been evaluated since v0.1.14; the admin guide's compatibility notes describe what earlier builds evaluated | | Authorization of `RestoreTableFromBackup` | `dynamodb:RestoreTableFromBackup` on the backup ARN, and `dynamodb:Scan`, `dynamodb:Query`, `dynamodb:GetItem`, `dynamodb:PutItem`, `dynamodb:UpdateItem`, `dynamodb:DeleteItem`, and `dynamodb:BatchWriteItem` on `table/`, each of which must be allowed; `dynamodb:RestoreTableFromBackup` is not evaluated on the target (measured on DynamoDB) | The same eight evaluations on all four backends, in that order: the backup first, then the target under each item action in the order listed, stopping at the first denial, which the `AccessDeniedException` names (`User: ... is not authorized to perform: dynamodb:Scan on resource: arn:aws:dynamodb:::table/`); the service's order was not measured. A missing `TargetTableName` is evaluated as `table/` (an empty name, which no statement but `*`-style wildcards covers); the engine then refuses the request. The seven item-action evaluations read the target ARN's resource tags: a target that does not exist has none, one that exists is evaluated with its tags and the restore then fails `TableAlreadyExistsException` (see Tag conditions on backups). Every DynamoDB condition key (`dynamodb:LeadingKeys`, `dynamodb:Attributes`, `dynamodb:Select`, `dynamodb:ReturnValues`, `dynamodb:ReturnConsumedCapacity`, `dynamodb:FullTableScan`, `dynamodb:EnclosingOperation`) is absent in these seven evaluations, so `ForAllValues:*` and `Null: true` conditions pass and bare `StringEquals` and `Bool` conditions fail: an Allow of the item actions conditioned on `ForAllValues:StringEquals dynamodb:LeadingKeys` grants the restore of every item in the backup, an Allow of `dynamodb:Scan` conditioned on `Bool dynamodb:FullTableScan: false` blocks every restore, and a Deny of `dynamodb:Scan` conditioned on `Bool dynamodb:FullTableScan: true` blocks none; the keys the service sets in these evaluations were not measured. The backup ARN is evaluated as the backup it names in this deployment: the table and backup id from the request, in the server's region and the caller's account, whatever partition, region or account the caller wrote, with surrounding whitespace trimmed as the engine trims it. The engine resolves the same table and id (under the server's region first, then as written for a backup created under an earlier `server.region`), so it never acts on a backup other than the one evaluated. `DescribeBackup` and `DeleteBackup` are evaluated the same way. These resources have been evaluated since v0.1.14 (see the admin guide's compatibility notes). A Deny of `dynamodb:RestoreTableFromBackup` on a table does not block restores into it; deny one of the seven item actions on it instead | @@ -120,7 +124,7 @@ Behaviour stated as measured, and every number below, was observed with release | Restore interrupted by the server stopping | Managed by the service | The copy runs in the server process, so a stopped process leaves its target `CREATING`. Each backend removes such a target without operator action, logs the removal with the table name (at warn; at info on Cassandra), and then lets `DeleteBackup` and a new restore into the name succeed. Measured after `SIGKILL` mid-copy and an immediate restart *(earlier build)*: **PostgreSQL** removes it on a control-plane pass once the target is older than 60 s and its restore lock is free (removed 60 s after the kill). **SQLite** removes it when the next server starts (0.1 s after the restart). **MongoDB** and **Cassandra** remove it once its owner's heartbeat is more than 60 s old, by a sweep that runs at startup and every 15 s, so about 45 to 80 s after the owner stopped (the heartbeat's last refresh can be up to one 15 s interval before the stop; measured: 60 s on MongoDB, 62 s on Cassandra). Until then `DeleteTable` and `DeleteBackup` are refused as during a running restore. Not covered *(from the code)*: on MongoDB, a target whose base copy had finished is not swept but is finished by the index backfill worker after a restart; if that backfill fails on every attempt, the worker retries it every 5 s with a warning and never gives up, so the target stays `CREATING` (and `DeleteTable` and `DeleteBackup` stay refused) until the cause is fixed or the target's catalog document is removed by hand (no failing case was found to reproduce this). On Cassandra, a target left `CREATING` by a build before catalog 0.0.4 carries no heartbeat and is not swept. The MongoDB and Cassandra heartbeats compare wall clocks across servers (MongoDB uses the database server's clock for refreshes), so a server whose clock is far behind can have a live restore swept | | Point-in-time recovery | 35-day window: `UpdateContinuousBackups` enables continuous backups, and `RestoreTableToPointInTime` restores to any second in the window | Not supported; use on-demand backups (`CreateBackup` and `RestoreTableFromBackup`) instead. `DescribeContinuousBackups` reports `ContinuousBackupsStatus: ENABLED` (the service reports this unconditionally) with `PointInTimeRecoveryStatus: DISABLED` and no restorable-time window. `UpdateContinuousBackups` with `PointInTimeRecoveryEnabled: true` returns `ContinuousBackupsUnavailableException`; with `false` it returns the same description. `RestoreTableToPointInTime` checks the source table, then returns `PointInTimeRecoveryUnavailableException` ("Point in time recovery is not enabled for table ''"), the exception the service models on that operation. A table that does not exist is `TableNotFoundException` ("Table not found: ") on all three operations, as on the service. Same on all four backends | | Where backups live | Managed storage, independent of the table | Inside the database the server already uses, so a lost database loses its backups too: on PostgreSQL in the catalog database (`backups`, `backup_items`, `backup_definitions`, `table_restores`), on SQLite in the one database file, on MongoDB as a `backups` metadata collection in the catalog database and one data collection per backup in the data database, on Cassandra as backup metadata in the catalog keyspace and item payloads in the account's keyspace. Take database-level backups for off-host copies until the backup store program ships | -| Backup creation | `CreateBackup` returns while the backup is `CREATING`; `ListBackups` and `DescribeBackup` expose that state until it becomes `AVAILABLE`. `BackupName` and `TableName` are checked as the model says: length 3 to 255 and `[a-zA-Z0-9_.-]+`, with "1 validation error detected: Value '' at 'backupName' failed to satisfy constraint: " texts, joined under "2 validation errors detected:" when both fail (measured). `TableName` may be the table's ARN (measured). A table that does not exist or is still `CREATING` is `TableNotFoundException` "Table not found: " (measured) | `CreateBackup` copies the items before it returns and returns the backup `AVAILABLE` on all four backends. On PostgreSQL, MongoDB, and Cassandra a concurrent `ListBackups` never saw it `CREATING` *(earlier build)*; on SQLite the backup row is visible as `CREATING` to other requests while the copy runs. Measured *(earlier build)*: 1.3 s for 60,000 items on PostgreSQL, 1.9 s for 60,000 on SQLite, 0.2 s for 20,000 on MongoDB, 5.1 s for 20,000 on Cassandra. MongoDB reports `BackupSizeBytes` 0. The name checks are the service's, with its texts (the length clause before the pattern clause when both fail; the service's order was not captured), counting characters, with `TableName` taking the deployment's table-name limits; before them a 200 KB name was stored and returned in every `ListBackups` page (a 500 on Cassandra) and a NUL byte was a 500 on PostgreSQL. `TableName` as an ARN (`arn:aws:dynamodb:::table/`) is accepted on all four backends: another region's resolves, another account's is `AccessDeniedException` (neither measured on the service); an SDK built from the model refuses the ARN form client-side, so only a hand-built request sends it. `TableNotFoundException` "Table not found: " for a missing or `CREATING` table on all four backends, built from the request's name (the backends' own payloads differ) | +| Backup creation | `CreateBackup` returns while the backup is `CREATING`; `ListBackups` and `DescribeBackup` expose that state until it becomes `AVAILABLE`. `BackupName` and `TableName` are checked as the model says: length 3 to 255 and `[a-zA-Z0-9_.-]+`, with "1 validation error detected: Value '' at 'backupName' failed to satisfy constraint: " texts, joined under "2 validation errors detected:" when both fail (measured). `TableName` may be the table's ARN (measured). A table that does not exist or is still `CREATING` is `TableNotFoundException` "Table not found: " (measured) | `CreateBackup` copies the items before it returns and returns the backup `AVAILABLE` on all four backends. On PostgreSQL, MongoDB, and Cassandra a concurrent `ListBackups` never saw it `CREATING` *(earlier build)*; on SQLite the backup row is visible as `CREATING` to other requests while the copy runs. Measured *(earlier build)*: 1.3 s for 60,000 items on PostgreSQL, 1.9 s for 60,000 on SQLite, 0.2 s for 20,000 on MongoDB, 5.1 s for 20,000 on Cassandra. MongoDB reports `BackupSizeBytes` 0. The name checks are the service's, with its texts (the length clause before the pattern clause when both fail; the service's order was not captured), counting characters, with `TableName` taking the deployment's table-name limits; before them a 200 KB name was stored and returned in every `ListBackups` page (a 500 on Cassandra) and a NUL byte was a 500 on PostgreSQL. `TableName` as an ARN (`arn:aws:dynamodb:::table/`) is accepted on all four backends: another region's resolves (not measured on the service), another account's is refused with the service's answers (see "A resource ARN in another account"); an SDK built from the model refuses the ARN form client-side, so only a hand-built request sends it. `TableNotFoundException` "Table not found: " for a missing or `CREATING` table on all four backends, built from the request's name (the backends' own payloads differ) | | `DeleteBackup` response | The full `DescribeBackup` description with `BackupStatus` `DELETED`, including `SourceTableDetails.ProvisionedThroughput` (a required member) and `SourceTableFeatureDetails` (measured) | The same on all four backends: the engine reads the backup's recorded definition before the delete and fills the description as `DescribeBackup` does, with `BackupStatus` `DELETED`. A backup whose definition this build cannot read or that does not parse is still deleted and described without feature details (logged at warn), which means a damaged `PROVISIONED` backup is described with on-demand `ProvisionedThroughput` `{0, 0}`: ExtendDB's choice, deletability over the throughput member of a row that is being removed, where `DescribeBackup` on the same row is an `InternalServerError`; a failure to reach the database fails the request and deletes nothing. | | `DescribeBackup` when the definition cannot be read | Not applicable | A definition row that does not parse, a renamed column, or a revoked privilege (`Internal` from the backend) is an `InternalServerError`, as it is for `RestoreTableFromBackup` on the same read; only a definition version this build does not know is described without `SourceTableFeatureDetails`. | | Consistency of a backup | Point-in-time snapshot of the table | **PostgreSQL** and **SQLite** take the items from one snapshot. How *(from the code)*: PostgreSQL holds the table's catalog row shared from the first definition read until the data snapshot (`REPEATABLE READ`, pinned by a read of the table before the row is released) is taken, so the definition recorded is the one in force for the items read; the catalog and the data are separate databases, so this is a barrier, not one cross-database snapshot. SQLite reads all items in one deferred read transaction opened while the engine's write lock was held for the definition read, so writers continue during the copy; that long reader pins WAL checkpointing, so the WAL grows for the duration of the backup by the size of the backup rows written plus any concurrent writes. **MongoDB** (`$out` from the live collection) and **Cassandra** (paging the live table) are not point-in-time: items copied early reflect the table as it was when the copy started, items copied late reflect writes made during the copy. A backup can therefore hold a write without an earlier write it followed, a state the table was never in. Measured *(earlier build)* with a writer that updated an early item and then a late item, pair after pair, during `CreateBackup`: 16 of 66 pairs on MongoDB and 123 of 800 on Cassandra had the late write without the early one; 0 on PostgreSQL (of 228) and SQLite (of 230). Back up a MongoDB or Cassandra table while it is not being written if the backup must be consistent | diff --git a/tests/python/test_streams.py b/tests/python/test_streams.py index 4ce98acf1..0cd4efe26 100755 --- a/tests/python/test_streams.py +++ b/tests/python/test_streams.py @@ -165,14 +165,14 @@ def test_describe_stream_basic(self, dynamodb_client, streams_client): wait_for_deleted(dynamodb_client, name) def test_describe_stream_invalid_arn(self, streams_client): - """DescribeStream with invalid ARN fails.""" + """DescribeStream with a stream ARN in an account that does not exist + is refused before the stream is looked up. The service answers + AccessDeniedException "Access is denied" (measured 2026-11-03 in + us-east-1, for an admin and for a caller with no policy).""" with pytest.raises(ClientError) as exc: streams_client.describe_stream(StreamArn="arn:aws:dynamodb:us-east-1:000000000000:table/nonexistent/stream/2026-01-01T00:00:00.000") - # Could be ResourceNotFoundException or ValidationException - assert exc.value.response["Error"]["Code"] in ( - "ResourceNotFoundException", - "ValidationException", - ) + error = exc.value.response["Error"] + assert (error["Code"], error["Message"]) == ("AccessDeniedException", "Access is denied") def _arn_with_another_label(stream_arn: str) -> str: diff --git a/tests/test_authz_cross_account.py b/tests/test_authz_cross_account.py new file mode 100644 index 000000000..b34fbf398 --- /dev/null +++ b/tests/test_authz_cross_account.py @@ -0,0 +1,360 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""A resource ARN in another account, and the tag operations' policy match. + +ExtendDB has no resource-based policies, so a request that names a resource in +another account by ARN is refused before the IAM decision. The answer matches +DynamoDB, which gives a caller allowed dynamodb:* on * and a caller with no +policy the same answer, chosen by the operation and by whether the account +exists (measured 2026-11-03 in us-east-1 with an account that exists and with +999999999999, which does not). Here an account exists when the deployment's +catalog holds it: the tests create one and use another that is never created. + +The tag operations match their policies against the ResourceArn as written, +as the service does: a caller allowed only on the canonical table ARN is denied +the aws-cn, s3 and us-west-2 spellings of it (measured 2026-11-03). + +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 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 + +ALLOW_ALL = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}], +} +NO_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "dynamodb:ListTables", "Resource": "*"}], +} +OWNER_ONLY = ( + "ValidationException", + "This action is only supported by accounts that match the resource owner\u2019s account.", +) +ACCESS_IS_DENIED = ("AccessDeniedException", "Access is denied") +CALLER_NOT_AUTHORIZED = ( + "AccessDeniedException", + "The caller is not authorized to access the specified resource.", +) +MIXED_ACCOUNTS = ( + "ValidationException", + "Invalid request! All tables in the request must be from a single account.", +) +KEY = {"pk": {"S": "p1"}} + + +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) + + +def _account(mgmt: ManagementClient, label: str) -> str: + acct = f"{uuid.uuid4().int % 10**12:012d}" + resp = mgmt.create_account(acct, f"{label}-{acct}") + assert resp.status_code == 201, resp.text + return acct + + +@pytest.fixture(scope="module") +def env(): + 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: cross-account authz tests require EXTENDDB_TEST_ENDPOINT, " + "EXTENDDB_ADMIN_USER, and EXTENDDB_ADMIN_PASSWORD. " + "These must be set by devtools/run-tests before test execution." + ) + mgmt = ManagementClient(endpoint, admin_user, admin_pass) + region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") + caller = _account(mgmt, "xacct-caller") + known = _account(mgmt, "xacct-known") + # Never created, so the catalog does not hold it. + unknown = f"{uuid.uuid4().int % 10**12:012d}" + yield {"endpoint": endpoint, "mgmt": mgmt, "region": region, + "caller": caller, "known": known, "unknown": unknown} + mgmt.delete_account(known) + mgmt.delete_account(caller) + + +def _principal(env: dict, policy: dict, service: str = "dynamodb") -> Any: + mgmt, caller = env["mgmt"], env["caller"] + user = f"u{uuid.uuid4().hex[:10]}" + assert mgmt.create_user(caller, user, "TestPass123!").status_code == 201 + assert mgmt.put_user_policy(caller, user, "p", policy).status_code == 204 + resp = mgmt.create_access_key(caller, user) + assert resp.status_code == 201, resp.text + creds = resp.json() + return { + name: _client(env["endpoint"], creds["access_key_id"], + creds["secret_access_key"], env["region"], name) + for name in ("dynamodb", "dynamodbstreams") + } + + +@pytest.fixture(scope="module") +def table(env): + """A table in the caller's account with a GSI, a stream and a backup.""" + admin = _principal(env, ALLOW_ALL)["dynamodb"] + 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) + desc = admin.describe_table(TableName=name)["Table"] + backup_arn = admin.create_backup(TableName=name, BackupName="xbk")["BackupDetails"]["BackupArn"] + return { + "name": name, + "table_arn": desc["TableArn"], + "stream_label": desc["LatestStreamArn"].split("/stream/", 1)[1], + "backup_id": backup_arn.split("/backup/", 1)[1], + } + + +def _error(fn) -> tuple[str, str]: + with pytest.raises(ClientError) as exc: + fn() + err = exc.value.response["Error"] + return err["Code"], err.get("Message", "") + + +def _requests(t: str, table: dict) -> list[tuple[str, str, str, str, Any]]: + """(label, family, action, resource the denial names, call) per request. + + Families: "table" answers the resource-based-policy denial for an account + that exists and "Access is denied" for one that does not; "item" answers + the same denial and "The caller is not authorized ..."; "owner" answers + the owner-only ValidationException and "Access is denied"; "pitr" answers + the owner-only ValidationException for both. + """ + stream = f"{t}/stream/{table['stream_label']}" + backup = f"{t}/backup/{table['backup_id']}" + put = {"Item": {"pk": {"S": "p1"}}} + schema = { + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "BillingMode": "PAY_PER_REQUEST", + } + return [ + ("TagResource", "table", "TagResource", t, + lambda c: c["dynamodb"].tag_resource(ResourceArn=t, Tags=[{"Key": "k", "Value": "v"}])), + ("UntagResource", "table", "UntagResource", t, + lambda c: c["dynamodb"].untag_resource(ResourceArn=t, TagKeys=["k"])), + ("ListTagsOfResource", "table", "ListTagsOfResource", t, + lambda c: c["dynamodb"].list_tags_of_resource(ResourceArn=t)), + ("TagResource stream", "table", "TagResource", stream, + lambda c: c["dynamodb"].tag_resource(ResourceArn=stream, Tags=[{"Key": "k", "Value": "v"}])), + ("GetItem", "item", "GetItem", t, + lambda c: c["dynamodb"].get_item(TableName=t, Key=KEY)), + ("PutItem", "item", "PutItem", t, + lambda c: c["dynamodb"].put_item(TableName=t, **put)), + ("UpdateItem", "item", "UpdateItem", t, + lambda c: c["dynamodb"].update_item( + TableName=t, Key=KEY, UpdateExpression="SET v = :v", + ExpressionAttributeValues={":v": {"S": "x"}})), + ("Query", "item", "Query", t, + lambda c: c["dynamodb"].query( + TableName=t, KeyConditionExpression="pk = :p", + ExpressionAttributeValues={":p": {"S": "p1"}})), + ("Query index", "item", "Query", f"{t}/index/gsi1", + lambda c: c["dynamodb"].query( + TableName=t, IndexName="gsi1", KeyConditionExpression="g1pk = :p", + ExpressionAttributeValues={":p": {"S": "g1"}})), + ("Scan", "item", "Scan", t, lambda c: c["dynamodb"].scan(TableName=t)), + ("BatchGetItem", "item", "BatchGetItem", t, + lambda c: c["dynamodb"].batch_get_item(RequestItems={t: {"Keys": [KEY]}})), + ("DescribeTable", "table", "DescribeTable", t, + lambda c: c["dynamodb"].describe_table(TableName=t)), + ("BatchWriteItem", "table", "BatchWriteItem", t, + lambda c: c["dynamodb"].batch_write_item(RequestItems={t: [{"PutRequest": put}]})), + ("TransactGetItems", "table", "GetItem", t, + lambda c: c["dynamodb"].transact_get_items( + TransactItems=[{"Get": {"TableName": t, "Key": KEY}}])), + ("TransactWriteItems", "table", "PutItem", t, + lambda c: c["dynamodb"].transact_write_items( + TransactItems=[{"Put": {"TableName": t, **put}}])), + ("DescribeStream", "table", "DescribeStream", stream, + lambda c: c["dynamodbstreams"].describe_stream(StreamArn=stream)), + ("GetShardIterator", "table", "GetShardIterator", stream, + lambda c: c["dynamodbstreams"].get_shard_iterator( + StreamArn=stream, ShardId="shardId-00000001700000000000-00000000", + ShardIteratorType="TRIM_HORIZON")), + ("DescribeBackup", "owner", "", "", + lambda c: c["dynamodb"].describe_backup(BackupArn=backup)), + ("RestoreTableFromBackup", "owner", "", "", + lambda c: c["dynamodb"].restore_table_from_backup( + BackupArn=backup, TargetTableName=f"r{uuid.uuid4().hex[:8]}")), + ("CreateBackup", "owner", "", "", + lambda c: c["dynamodb"].create_backup(TableName=t, BackupName="xbk")), + ("ListBackups", "owner", "", "", + lambda c: c["dynamodb"].list_backups(TableName=t)), + ("DescribeContinuousBackups", "owner", "", "", + lambda c: c["dynamodb"].describe_continuous_backups(TableName=t)), + ("DescribeTimeToLive", "owner", "", "", + lambda c: c["dynamodb"].describe_time_to_live(TableName=t)), + ("CreateTable", "owner", "", "", + lambda c: c["dynamodb"].create_table(TableName=t, **schema)), + ("ExportTableToPointInTime", "owner", "", "", + lambda c: c["dynamodb"].export_table_to_point_in_time(TableArn=t, S3Bucket="b")), + ("RestoreTableToPointInTime", "pitr", "", "", + lambda c: c["dynamodb"].restore_table_to_point_in_time( + SourceTableArn=t, TargetTableName=f"p{uuid.uuid4().hex[:8]}")), + ] + + +@pytest.mark.parametrize("policy", [NO_POLICY, ALLOW_ALL], ids=["no-policy", "admin"]) +def test_other_account_answer_depends_on_operation_and_account(env, table, policy): + clients = _principal(env, policy) + region, caller = env["region"], env["caller"] + for known, account in ((True, env["known"]), (False, env["unknown"])): + t = f"arn:aws:dynamodb:{region}:{account}:table/{table['name']}" + for label, family, action, resource, call in _requests(t, table): + code, msg = _error(lambda: call(clients)) + where = (label, "known" if known else "unknown", code, msg) + if family == "pitr" or (family == "owner" and known): + assert (code, msg) == OWNER_ONLY, where + elif known: + assert code == "AccessDeniedException", where + assert msg.startswith(f"User: arn:aws:iam::{caller}:user/"), where + assert msg.endswith( + f" is not authorized to perform: dynamodb:{action} on resource: " + f"{resource} because no resource-based policy allows the " + f"dynamodb:{action} action" + ), where + elif family == "item": + assert (code, msg) == CALLER_NOT_AUTHORIZED, where + else: + assert (code, msg) == ACCESS_IS_DENIED, where + + +@pytest.mark.parametrize("policy", [NO_POLICY, ALLOW_ALL], ids=["no-policy", "admin"]) +def test_tables_in_more_than_one_account_are_refused(env, table, policy): + d = _principal(env, policy)["dynamodb"] + region = env["region"] + for account in (env["known"], env["unknown"]): + t = f"arn:aws:dynamodb:{region}:{account}:table/{table['name']}" + own = table["name"] + calls = [ + lambda: d.batch_get_item(RequestItems={own: {"Keys": [KEY]}, t: {"Keys": [KEY]}}), + lambda: d.transact_get_items(TransactItems=[ + {"Get": {"TableName": table["table_arn"], "Key": KEY}}, + {"Get": {"TableName": t, "Key": KEY}}]), + lambda: d.transact_get_items(TransactItems=[ + {"Get": {"TableName": t, "Key": KEY}}, + {"Get": {"TableName": own, "Key": KEY}}]), + ] + for call in calls: + assert _error(call) == MIXED_ACCOUNTS, account + + +def test_shape_checks_run_before_a_known_accounts_denial(env, table): + # Measured 2026-11-03: an index or backup ARN in ResourceArn gets the + # shape ValidationException in an account that exists and "Access is + # denied" in one that does not. The service answers a StreamArn in another + # partition or region the same way; ExtendDB does not check that yet + # (#451), so the StreamArn case below uses a table ARN, which its + # StreamArn check refuses. That input was not sent to the service with + # another account's ARN. + clients = _principal(env, NO_POLICY) + d, streams = clients["dynamodb"], clients["dynamodbstreams"] + region = env["region"] + known = f"arn:aws:dynamodb:{region}:{env['known']}:table/{table['name']}" + unknown = f"arn:aws:dynamodb:{region}:{env['unknown']}:table/{table['name']}" + code, msg = _error(lambda: d.list_tags_of_resource(ResourceArn=f"{known}/index/gsi1")) + assert code == "ValidationException" and "only table or stream is accepted" in msg, msg + backup = f"{known}/backup/{table['backup_id']}" + code, msg = _error(lambda: d.list_tags_of_resource(ResourceArn=backup)) + assert code == "ValidationException" and "Provided Arn is not a DynamoDB resource arn" in msg, msg + for arn in (f"{unknown}/index/gsi1", f"{unknown}/backup/{table['backup_id']}"): + assert _error(lambda a=arn: d.list_tags_of_resource(ResourceArn=a)) == ACCESS_IS_DENIED, arn + # A table ARN where a stream ARN belongs. + assert _error(lambda: streams.describe_stream(StreamArn=known)) == ( + "ValidationException", "Invalid StreamArn") + assert _error(lambda: streams.describe_stream(StreamArn=unknown)) == ACCESS_IS_DENIED + + +def test_tag_operations_match_the_arn_as_written(env, table): + # Measured 2026-11-03 with ListTagsOfResource for a caller allowed + # dynamodb:* on the canonical table ARN only: the canonical ARN is + # allowed, and each respelling is denied naming the ARN as written. + canonical = table["table_arn"] + d = _principal(env, {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": canonical}]})["dynamodb"] + d.list_tags_of_resource(ResourceArn=canonical) + region = canonical.split(":")[3] + other_region = "us-west-2" if region != "us-west-2" else "us-east-1" + for spelled in ( + canonical.replace("arn:aws:", "arn:aws-cn:", 1), + canonical.replace(":dynamodb:", ":s3:", 1), + canonical.replace(f":{region}:", f":{other_region}:", 1), + ): + for op, call in ( + ("ListTagsOfResource", lambda a=spelled: d.list_tags_of_resource(ResourceArn=a)), + ("TagResource", lambda a=spelled: d.tag_resource( + ResourceArn=a, Tags=[{"Key": "k", "Value": "v"}])), + ): + code, msg = _error(call) + assert code == "AccessDeniedException", (op, spelled, code, msg) + assert msg.endswith( + f"dynamodb:{op} on resource: {spelled} because no identity-based " + f"policy allows the dynamodb:{op} action" + ), (op, spelled, msg) + + +def test_import_table_name_is_read_as_a_name(env, table): + # Measured 2026-11-03: the service authorizes ImportTable on + # table/ in the caller's account, also for an ARN. + d = _principal(env, NO_POLICY)["dynamodb"] + value = f"arn:aws:dynamodb:{env['region']}:{env['known']}:table/{table['name']}" + code, msg = _error(lambda: d.import_table( + S3BucketSource={"S3Bucket": "b"}, + InputFormat="DYNAMODB_JSON", + TableCreationParameters={ + "TableName": value, + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "BillingMode": "PAY_PER_REQUEST", + }, + )) + expected = f"arn:aws:dynamodb:{env['region']}:{env['caller']}:table/{value}" + assert code == "AccessDeniedException", (code, msg) + assert f"dynamodb:ImportTable on resource: {expected}" in msg, msg diff --git a/tests/test_authz_resource_arn.py b/tests/test_authz_resource_arn.py index 3d4accb65..4d81c5955 100644 --- a/tests/test_authz_resource_arn.py +++ b/tests/test_authz_resource_arn.py @@ -534,6 +534,64 @@ def test_tag_ordering_admin_matches_service(self, table): lambda a=arn, dd=d: dd.tag_resource(ResourceArn=a, Tags=[{"Key": "k", "Value": "v"}]), code, substr) + # -- the denial names the ARN as the caller supplied it (issue #447) -- + # Measured on DynamoDB 2026-10-20 (TagResource, no-policy caller): the + # message carries the caller's partition, service and region unchanged, and + # an ARN in another account answers "Access is denied" for an admin and a + # no-policy caller alike. + + def _denial_message(self, fn) -> str: + with pytest.raises(ClientError) as exc: + fn() + err = exc.value.response["Error"] + assert err["Code"] == "AccessDeniedException", err + return err["Message"] + + def test_tag_denial_names_supplied_arn_noperm(self, table): + v = self._ordering_variants(table) + noperm = {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:ListTables", "Resource": "*"}]} + d = self._ddb(noperm) + for label in ("wrong_partition", "wrong_service", "cross_region", "nonexistent"): + arn = v[label] + msg = self._denial_message( + lambda a=arn: d.tag_resource(ResourceArn=a, Tags=[{"Key": "k", "Value": "v"}])) + assert f"on resource: {arn} because no identity-based policy allows " in msg, (label, msg) + + def test_tag_cross_account_is_access_denied_for_every_principal(self, table): + arn = self._ordering_variants(table)["cross_account"] + noperm = {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:ListTables", "Resource": "*"}]} + for policy in (noperm, ALLOW_ALL): + d = self._ddb(policy) + msg = self._denial_message( + lambda: d.tag_resource(ResourceArn=arn, Tags=[{"Key": "k", "Value": "v"}])) + assert msg == "Access is denied", msg + + def test_tag_cross_account_index_and_backup_arns_are_access_denied(self, table): + # Measured on DynamoDB 2026-10-24 (admin): another account's index or + # backup ARN answers "Access is denied", not the shape ValidationException + # the same ARN in this account gets. + region = table["table_arn"].split(":")[3] + base = f"arn:aws:dynamodb:{region}:111122223333:table/{table['name']}" + d = self._ddb(ALLOW_ALL) + for arn in (f"{base}/index/gsi1", f"{base}/backup/01700000000000-aaaaaaaa"): + msg = self._denial_message( + lambda a=arn: d.tag_resource(ResourceArn=a, Tags=[{"Key": "k", "Value": "v"}])) + assert msg == "Access is denied", (arn, msg) + + def test_get_item_table_arn_in_tablename_denial_names_table_arn(self, table): + # Measured on DynamoDB 2026-10-20 (resource-probe GetItem_tableArnInTableName). + noperm = {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:ListTables", "Resource": "*"}]} + d = self._ddb(noperm) + msg = self._denial_message( + lambda: d.get_item(TableName=table["table_arn"], Key={"pk": {"S": "p1"}})) + assert ( + f"dynamodb:GetItem on resource: {table['table_arn']} because no identity-based " + "policy allows the dynamodb:GetItem action" + ) in msg, msg + # -- permission chains: a Deny anywhere in the chain blocks the operation -- def test_restore_from_backup_deny_target_item_action_blocks(self, table):