From 61d0dc8c17897b9c7feb9e7e09ddfa8662c84f91 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Mon, 14 Sep 2026 10:48:28 -0300 Subject: [PATCH 1/5] feat(dynamodb): cross-account table and stream access through ARNs - A table ARN naming another account reaches that account's table for the operations AWS supports cross-account: item operations, Query, Scan, batches, transactions, DescribeTable/UpdateTable/DeleteTable, tagging, and the Streams DescribeStream/GetShardIterator/GetRecords - Batches and transactions resolve each table's owner account; a transaction snapshots and reverts per account, so it stays atomic - Other operations do not find another account's table, and an ARN naming another region is not found for any operation - IAM authorizes the table the handler serves, so the table's resource policy combines with the caller's identity policy cross-account - Unit and two-account strict-mode e2e tests; docs --- .../fakecloud-dynamodb/src/service/batch.rs | 117 +++-- .../src/service/cross_account.rs | 331 ++++++++++++ crates/fakecloud-dynamodb/src/service/iam.rs | 35 +- .../src/service/iam_conditions.rs | 33 +- crates/fakecloud-dynamodb/src/service/mod.rs | 14 +- .../fakecloud-dynamodb/src/service/tests.rs | 309 ++++++++++++ .../src/streams_dataplane.rs | 83 ++- .../tests/dynamodb_cross_account.rs | 476 ++++++++++++++++++ website/content/docs/reference/limitations.md | 2 +- website/content/docs/reference/security.md | 2 +- website/content/docs/services/dynamodb.md | 2 +- 11 files changed, 1331 insertions(+), 73 deletions(-) create mode 100644 crates/fakecloud-dynamodb/src/service/cross_account.rs create mode 100644 crates/fakecloud-e2e/tests/dynamodb_cross_account.rs diff --git a/crates/fakecloud-dynamodb/src/service/batch.rs b/crates/fakecloud-dynamodb/src/service/batch.rs index 5c24ac7d9..cda8c2989 100644 --- a/crates/fakecloud-dynamodb/src/service/batch.rs +++ b/crates/fakecloud-dynamodb/src/service/batch.rs @@ -27,6 +27,8 @@ use super::{ validate_key_attributes_in_key, validate_key_in_item, DynamoDbService, }; +use super::cross_account::{table_id, tables_of, tables_of_mut}; + impl DynamoDbService { pub(super) fn batch_get_item(&self, req: &AwsRequest) -> Result { let body = Self::parse_body(req)?; @@ -68,14 +70,14 @@ impl DynamoDbService { )); } + // Each table is looked up in the account that owns it: a table ARN + // may name another account's table. let accounts = self.state.read(); - let empty_ddb = crate::state::DynamoDbState::new(&req.account_id, &req.region); - let state = accounts.get(&req.account_id).unwrap_or(&empty_ddb); let mut responses: HashMap> = HashMap::new(); let mut consumed_capacity: Vec = Vec::new(); for (table_name, params) in &request_items { - let table = get_table(&state.tables, table_name)?; + let table = get_table(tables_of(&accounts, req, table_name), table_name)?; let keys = params["Keys"].as_array().ok_or_else(|| { AwsServiceError::aws_error( StatusCode::BAD_REQUEST, @@ -190,7 +192,6 @@ impl DynamoDbService { } let mut accounts = self.state.write(); - let state = accounts.get_or_create(&req.account_id); let mut consumed_capacity: Vec = Vec::new(); let mut item_collection_metrics: HashMap> = HashMap::new(); @@ -199,8 +200,7 @@ impl DynamoDbService { // the whole call (AWS rejects these up-front, not after partial // application). for (table_name, requests) in &request_items { - let table = state - .tables + let table = tables_of(&accounts, req, table_name) .get(super::resolve_table_name(table_name)) .ok_or_else(|| { AwsServiceError::aws_error( @@ -278,8 +278,7 @@ impl DynamoDbService { } for (table_name, requests) in &request_items { - let table = state - .tables + let table = tables_of_mut(&mut accounts, req, table_name) .get_mut(super::resolve_table_name(table_name)) .ok_or_else(|| { AwsServiceError::aws_error( @@ -400,12 +399,12 @@ impl DynamoDbService { )); } + // Each table is looked up in the account that owns it: a table ARN + // may name another account's table. let accounts = self.state.read(); - let empty_ddb = crate::state::DynamoDbState::new(&req.account_id, &req.region); - let state = accounts.get(&req.account_id).unwrap_or(&empty_ddb); let mut responses: Vec = Vec::new(); let mut per_table_count: HashMap = HashMap::new(); - let mut seen_keys: Vec<(String, HashMap)> = Vec::new(); + let mut seen_keys: Vec<((String, String), HashMap)> = Vec::new(); for ti in transact_items { let get = &ti["Get"]; @@ -417,7 +416,7 @@ impl DynamoDbService { ) })?; - let table = get_table(&state.tables, table_name)?; + let table = get_table(tables_of(&accounts, req, table_name), table_name)?; // Parse the Key strictly and reject an under-specified/malformed key // the same way GetItem does, instead of coercing it to `{}` (which // matched nothing and returned a phantom miss). @@ -432,19 +431,18 @@ impl DynamoDbService { validate_key_attributes_in_key(table, &key)?; // AWS rejects a transaction that reads the same item more than once. - if seen_keys.iter().any(|(t, k)| { - t == super::resolve_table_name(table_name) && keys_equal(table, k, &key) - }) { + let id = table_id(req, table_name); + if seen_keys + .iter() + .any(|(t, k)| *t == id && keys_equal(table, k, &key)) + { return Err(AwsServiceError::aws_error( StatusCode::BAD_REQUEST, "ValidationException", "Transaction request cannot include multiple operations on one item", )); } - seen_keys.push(( - super::resolve_table_name(table_name).to_string(), - key.clone(), - )); + seen_keys.push((id, key.clone())); match table.find_item_index(&key) { Some(idx) => { @@ -599,7 +597,8 @@ impl DynamoDbService { } } - let state = accounts.get_or_create(&req.account_id); + // Each table is looked up, validated, snapshotted and written in the + // account that owns it: a table ARN may name another account's table. // Validate every referenced table exists up-front. Without this // check a missing TableName on a Put with no condition would fail @@ -609,7 +608,7 @@ impl DynamoDbService { for op_key in ["Put", "Delete", "Update", "ConditionCheck"] { if let Some(op) = ti.get(op_key) { let table_name = op["TableName"].as_str().unwrap_or_default(); - get_table(&state.tables, table_name)?; + get_table(tables_of(&accounts, req, table_name), table_name)?; } } } @@ -629,7 +628,9 @@ impl DynamoDbService { let table_name = put["TableName"].as_str().unwrap_or_default(); let item: HashMap = serde_json::from_value(put["Item"].clone()).unwrap_or_default(); - if let Some(table) = state.tables.get(super::resolve_table_name(table_name)) { + if let Some(table) = + tables_of(&accounts, req, table_name).get(super::resolve_table_name(table_name)) + { validate_key_in_item(table, &item)?; } // Malformed values (bad numbers, empty/duplicate sets) are a @@ -641,7 +642,9 @@ impl DynamoDbService { let table_name = op["TableName"].as_str().unwrap_or_default(); let key: HashMap = serde_json::from_value(op["Key"].clone()).unwrap_or_default(); - if let Some(table) = state.tables.get(super::resolve_table_name(table_name)) { + if let Some(table) = + tables_of(&accounts, req, table_name).get(super::resolve_table_name(table_name)) + { validate_key_attributes_in_key(table, &key)?; if let Some(expr) = ti .get("Update") @@ -671,12 +674,12 @@ impl DynamoDbService { // a transaction applied last-writer-wins and reported success. The key // is the table's primary key, extracted from a Put's Item or the // Key field of Update/Delete/ConditionCheck. - let mut seen_keys: Vec<(String, HashMap)> = Vec::new(); + let mut seen_keys: Vec<((String, String), HashMap)> = Vec::new(); for ti in transact_items { for op_key in ["Put", "Delete", "Update", "ConditionCheck"] { let Some(op) = ti.get(op_key) else { continue }; let table_name = op["TableName"].as_str().unwrap_or_default(); - let table = get_table(&state.tables, table_name)?; + let table = get_table(tables_of(&accounts, req, table_name), table_name)?; let key = if op_key == "Put" { let item: HashMap = serde_json::from_value(op["Item"].clone()).unwrap_or_default(); @@ -684,16 +687,18 @@ impl DynamoDbService { } else { serde_json::from_value(op["Key"].clone()).unwrap_or_default() }; - if seen_keys.iter().any(|(t, k)| { - t == super::resolve_table_name(table_name) && keys_equal(table, k, &key) - }) { + let id = table_id(req, table_name); + if seen_keys + .iter() + .any(|(t, k)| *t == id && keys_equal(table, k, &key)) + { return Err(AwsServiceError::aws_error( StatusCode::BAD_REQUEST, "ValidationException", "Transaction request cannot include multiple operations on one item", )); } - seen_keys.push((super::resolve_table_name(table_name).to_string(), key)); + seen_keys.push((id, key)); } } @@ -737,7 +742,7 @@ impl DynamoDbService { let return_values = put["ReturnValuesOnConditionCheckFailure"].as_str(); if let Some(cond) = condition { - let table = get_table(&state.tables, table_name)?; + let table = get_table(tables_of(&accounts, req, table_name), table_name)?; let expr_attr_names = parse_expression_attribute_names(put); let expr_attr_values = parse_expression_attribute_values(put); let key = extract_key(table, &item); @@ -764,7 +769,7 @@ impl DynamoDbService { let return_values = delete["ReturnValuesOnConditionCheckFailure"].as_str(); if let Some(cond) = condition { - let table = get_table(&state.tables, table_name)?; + let table = get_table(tables_of(&accounts, req, table_name), table_name)?; let expr_attr_names = parse_expression_attribute_names(delete); let expr_attr_values = parse_expression_attribute_values(delete); let existing_idx = table.find_item_index(&key); @@ -790,7 +795,7 @@ impl DynamoDbService { let return_values = update["ReturnValuesOnConditionCheckFailure"].as_str(); if let Some(cond) = condition { - let table = get_table(&state.tables, table_name)?; + let table = get_table(tables_of(&accounts, req, table_name), table_name)?; let expr_attr_names = parse_expression_attribute_names(update); let expr_attr_values = parse_expression_attribute_values(update); let existing_idx = table.find_item_index(&key); @@ -815,7 +820,7 @@ impl DynamoDbService { let cond = check["ConditionExpression"].as_str().unwrap_or_default(); let return_values = check["ReturnValuesOnConditionCheckFailure"].as_str(); - let table = get_table(&state.tables, table_name)?; + let table = get_table(tables_of(&accounts, req, table_name), table_name)?; let expr_attr_names = parse_expression_attribute_names(check); let expr_attr_values = parse_expression_attribute_values(check); let existing_idx = table.find_item_index(&key); @@ -863,21 +868,23 @@ impl DynamoDbService { // UpdateExpression). DDB transactions are all-or-nothing — without // this, an UpdateExpression error after a successful Put would // leave the Put committed. - let mut snapshots: HashMap>> = HashMap::new(); + let mut snapshots: HashMap<(String, String), Vec>> = + HashMap::new(); for ti in transact_items { for op_key in ["Put", "Delete", "Update"] { if let Some(op) = ti.get(op_key) { - // Keyed by the resolved name, so a table named once by - // name and once by ARN is snapshotted, and reverted, once. - let table_name = - super::resolve_table_name(op["TableName"].as_str().unwrap_or_default()); - snapshots.entry(table_name.to_string()).or_insert_with(|| { - state - .tables - .get(table_name) - .map(|t| t.items.to_vec()) - .unwrap_or_default() - }); + // Keyed by owner account and resolved name, so a table + // named once by name and once by ARN is snapshotted, and + // reverted, once. + let table_name = op["TableName"].as_str().unwrap_or_default(); + snapshots + .entry(table_id(req, table_name)) + .or_insert_with(|| { + tables_of(&accounts, req, table_name) + .get(super::resolve_table_name(table_name)) + .map(|t| t.items.to_vec()) + .unwrap_or_default() + }); } } } @@ -901,7 +908,8 @@ impl DynamoDbService { let item: HashMap = serde_json::from_value(put["Item"].clone()).unwrap_or_default(); let table = - get_table_mut(&mut state.tables, table_name).map_err(|e| (op_idx, e))?; + get_table_mut(tables_of_mut(&mut accounts, req, table_name), table_name) + .map_err(|e| (op_idx, e))?; let key = extract_key(table, &item); let old_image = table.find_item_index(&key).map(|i| table.items[i].clone()); let is_modify = old_image.is_some(); @@ -932,7 +940,8 @@ impl DynamoDbService { let key: HashMap = serde_json::from_value(delete["Key"].clone()).unwrap_or_default(); let table = - get_table_mut(&mut state.tables, table_name).map_err(|e| (op_idx, e))?; + get_table_mut(tables_of_mut(&mut accounts, req, table_name), table_name) + .map_err(|e| (op_idx, e))?; let old_image = table.find_item_index(&key).map(|i| table.items[i].clone()); table.remove_item_by_key(&key); if old_image.is_some() { @@ -966,7 +975,8 @@ impl DynamoDbService { let expr_attr_values = parse_expression_attribute_values(update); let table = - get_table_mut(&mut state.tables, table_name).map_err(|e| (op_idx, e))?; + get_table_mut(tables_of_mut(&mut accounts, req, table_name), table_name) + .map_err(|e| (op_idx, e))?; // The `&self` lookups below cannot build the index, so a // restored table would scan on every transactional update // without this. @@ -1032,8 +1042,11 @@ impl DynamoDbService { // surface the failure as a TransactionCanceledException // whose CancellationReasons array marks the offending op // with `ValidationError` and leaves siblings as `None`. - for (table_name, items) in snapshots { - if let Some(table) = state.tables.get_mut(super::resolve_table_name(&table_name)) { + for ((account, table_name), items) in snapshots { + if let Some(table) = accounts + .get_mut(&account) + .and_then(|state| state.tables.get_mut(&table_name)) + { table.replace_items(items); } } @@ -1064,7 +1077,9 @@ impl DynamoDbService { // Append all pending stream records under each table's // stream_records lock now that the transaction has committed. for (table_name, record) in pending_stream { - if let Some(table) = state.tables.get_mut(super::resolve_table_name(&table_name)) { + if let Some(table) = tables_of_mut(&mut accounts, req, &table_name) + .get_mut(super::resolve_table_name(&table_name)) + { crate::streams::add_stream_record(table, record); } } diff --git a/crates/fakecloud-dynamodb/src/service/cross_account.rs b/crates/fakecloud-dynamodb/src/service/cross_account.rs new file mode 100644 index 000000000..eebc9b971 --- /dev/null +++ b/crates/fakecloud-dynamodb/src/service/cross_account.rs @@ -0,0 +1,331 @@ +//! Cross-account access to DynamoDB tables and streams. +//! +//! A `TableName` (or `ResourceArn`, `StreamArn`, ...) may be an ARN naming a +//! table in another account; a principal authorized by that table's +//! resource-based policy operates on it there. AWS allows this only for the +//! data plane (item operations, Query, Scan, batches, transactions), for +//! DescribeTable / UpdateTable / DeleteTable, for tagging, and for the stream +//! reads. For any other operation, and for an ARN naming another region than +//! the request's, the resource is simply not found. + +use fakecloud_core::multi_account::MultiAccountState; +use fakecloud_core::service::AwsRequest; +use http::StatusCode; +use serde_json::Value; +use std::collections::BTreeMap; + +use crate::state::{DynamoDbState, DynamoTable}; +use fakecloud_core::service::AwsServiceError; + +/// Operations that may address another account's table. +pub(crate) const CROSS_ACCOUNT_OPERATIONS: &[&str] = &[ + "GetItem", + "PutItem", + "UpdateItem", + "DeleteItem", + "Query", + "Scan", + "BatchGetItem", + "BatchWriteItem", + "TransactGetItems", + "TransactWriteItems", + "DescribeTable", + "UpdateTable", + "DeleteTable", + "ListTagsOfResource", + "TagResource", + "UntagResource", +]; + +/// DynamoDB Streams operations that may read another account's stream. +pub(crate) const STREAMS_CROSS_ACCOUNT_OPERATIONS: &[&str] = + &["DescribeStream", "GetShardIterator", "GetRecords"]; + +/// The region and account of a DynamoDB ARN (`arn:aws:dynamodb:REGION:ACCOUNT:...`). +pub(crate) fn arn_scope(arn: &str) -> Option<(&str, &str)> { + let rest = arn.strip_prefix("arn:aws:dynamodb:")?; + let mut parts = rest.splitn(3, ':'); + let region = parts.next()?; + let account = parts.next()?; + parts.next()?; + Some((region, account)) +} + +/// The account that owns the table a `TableName` value names: the account in +/// a table ARN, or the caller's for a plain name. +pub(crate) fn owner_account<'a>(req: &'a AwsRequest, name_or_arn: &'a str) -> &'a str { + match arn_scope(name_or_arn) { + Some((_, account)) if !account.is_empty() => account, + _ => req.account_id.as_str(), + } +} + +/// The tables of the account that owns `name_or_arn`, or none if that +/// account holds no DynamoDB state. +pub(crate) fn tables_of<'a>( + accounts: &'a MultiAccountState, + req: &AwsRequest, + name_or_arn: &str, +) -> &'a BTreeMap { + static EMPTY: BTreeMap = BTreeMap::new(); + accounts + .get(owner_account(req, name_or_arn)) + .map_or(&EMPTY, |state| &state.tables) +} + +/// Mutable [`tables_of`]. An account that has never held DynamoDB state gets +/// an empty one, in which the table is then not found. +pub(crate) fn tables_of_mut<'a>( + accounts: &'a mut MultiAccountState, + req: &AwsRequest, + name_or_arn: &str, +) -> &'a mut BTreeMap { + &mut accounts + .get_or_create(owner_account(req, name_or_arn)) + .tables +} + +/// Every table or stream ARN a request names, with the error code its +/// operation declares for a resource it cannot find. +fn referenced_arns(action: &str, body: &Value) -> Vec<(String, &'static str)> { + let mut out = Vec::new(); + let mut push = |value: &Value, code: &'static str| { + if let Some(s) = value.as_str() { + if s.starts_with("arn:") { + out.push((s.to_string(), code)); + } + } + }; + let table_code = match action { + "CreateBackup" + | "DescribeContinuousBackups" + | "UpdateContinuousBackups" + | "RestoreTableToPointInTime" + | "ExportTableToPointInTime" => "TableNotFoundException", + _ => "ResourceNotFoundException", + }; + for field in [ + "TableName", + "TableArn", + "ResourceArn", + "SourceTableArn", + "SourceTableName", + "StreamArn", + ] { + push(&body[field], table_code); + } + push(&body["BackupArn"], "BackupNotFoundException"); + push(&body["ExportArn"], "ExportNotFoundException"); + push(&body["ImportArn"], "ImportNotFoundException"); + if let Some(items) = body["RequestItems"].as_object() { + for name in items.keys() { + push(&Value::String(name.clone()), table_code); + } + } + for item in body["TransactItems"].as_array().into_iter().flatten() { + for member in ["Get", "Put", "Update", "Delete", "ConditionCheck"] { + push(&item[member]["TableName"], table_code); + } + } + if let Some(iterator) = body["ShardIterator"].as_str() { + if let Some(stream) = iterator.split('|').next() { + push(&Value::String(stream.to_string()), table_code); + } + } + let statements = body["Statement"] + .as_str() + .into_iter() + .chain( + body["Statements"] + .as_array() + .into_iter() + .chain(body["TransactStatements"].as_array()) + .flatten() + .filter_map(|s| s["Statement"].as_str()), + ) + .collect::>(); + for statement in statements { + if let Some((_, table)) = super::iam::partiql_verb_and_table(statement) { + push(&Value::String(table), table_code); + } + } + out +} + +/// Reject a request naming a table or stream it cannot reach: one in another +/// region, or -- for an operation without cross-account support -- one in +/// another account. Both are resources that do not exist for this request. +pub(crate) fn check_references( + req: &AwsRequest, + body: &Value, + cross_account_operations: &[&str], +) -> Result<(), AwsServiceError> { + let cross_account = cross_account_operations.contains(&req.action.as_str()); + for (arn, code) in referenced_arns(&req.action, body) { + let Some((region, account)) = arn_scope(&arn) else { + continue; + }; + let foreign_region = !region.is_empty() && region != req.region; + let foreign_account = !account.is_empty() && account != req.account_id; + if foreign_region || (foreign_account && !cross_account) { + return Err(AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + code, + format!("Requested resource not found: {arn}"), + )); + } + } + Ok(()) +} + +/// The account that owns the single table (or stream) an operation acts on, +/// when that is not the caller's: the request is then served in that account. +/// `None` for the caller's own resources and for batches and transactions, +/// which resolve each table's account separately. +pub(crate) fn single_resource_owner(req: &AwsRequest, body: &Value) -> Option { + let reference = match req.action.as_str() { + "BatchGetItem" | "BatchWriteItem" | "TransactGetItems" | "TransactWriteItems" => { + return None + } + "ListTagsOfResource" | "TagResource" | "UntagResource" => body["ResourceArn"].as_str(), + "DescribeStream" | "GetShardIterator" => body["StreamArn"].as_str(), + "GetRecords" => body["ShardIterator"] + .as_str() + .and_then(|it| it.split('|').next()), + _ => body["TableName"].as_str(), + }?; + let (_, account) = arn_scope(reference)?; + (!account.is_empty() && account != req.account_id).then(|| account.to_string()) +} + +/// A table's identity across accounts: its owner account and resolved name. +pub(crate) fn table_id(req: &AwsRequest, name_or_arn: &str) -> (String, String) { + ( + owner_account(req, name_or_arn).to_string(), + super::resolve_table_name(name_or_arn).to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn request(action: &str, body: Value) -> AwsRequest { + AwsRequest { + service: "dynamodb".into(), + action: action.into(), + region: "us-east-1".into(), + account_id: "111122223333".into(), + request_id: "r".into(), + headers: http::HeaderMap::new(), + query_params: std::collections::HashMap::new(), + body: serde_json::to_vec(&body).unwrap().into(), + body_stream: parking_lot::Mutex::new(None), + path_segments: vec![], + raw_path: "/".into(), + raw_query: String::new(), + method: http::Method::POST, + is_query_protocol: false, + access_key_id: None, + principal: None, + } + } + + fn check(action: &str, body: Value) -> Result<(), String> { + let req = request(action, body.clone()); + check_references(&req, &body, CROSS_ACCOUNT_OPERATIONS).map_err(|e| e.code().to_string()) + } + + const FOREIGN: &str = "arn:aws:dynamodb:us-east-1:444455556666:table/T"; + + #[test] + fn arn_scope_reads_region_and_account() { + assert_eq!(arn_scope(FOREIGN), Some(("us-east-1", "444455556666"))); + assert_eq!(arn_scope("T"), None); + assert_eq!(arn_scope("arn:aws:s3:::bucket"), None); + } + + #[test] + fn foreign_references_are_checked_per_operation() { + assert_eq!(check("GetItem", json!({"TableName": FOREIGN})), Ok(())); + assert_eq!(check("GetItem", json!({"TableName": "T"})), Ok(())); + assert_eq!( + check("UpdateTimeToLive", json!({"TableName": FOREIGN})), + Err("ResourceNotFoundException".into()) + ); + assert_eq!( + check("CreateBackup", json!({"TableName": FOREIGN})), + Err("TableNotFoundException".into()) + ); + assert_eq!( + check( + "DescribeBackup", + json!({"BackupArn": format!("{FOREIGN}/backup/01")}) + ), + Err("BackupNotFoundException".into()) + ); + assert_eq!( + check( + "BatchExecuteStatement", + json!({"Statements": [{"Statement": format!("SELECT * FROM \"{FOREIGN}\"")}]}) + ), + Err("ResourceNotFoundException".into()) + ); + let other_region = "arn:aws:dynamodb:eu-west-1:111122223333:table/T"; + for (action, body) in [ + ("GetItem", json!({"TableName": other_region})), + ( + "BatchWriteItem", + json!({"RequestItems": {other_region: []}}), + ), + ( + "TransactWriteItems", + json!({"TransactItems": [{"ConditionCheck": {"TableName": other_region}}]}), + ), + ("ListTagsOfResource", json!({"ResourceArn": other_region})), + ] { + assert_eq!( + check(action, body), + Err("ResourceNotFoundException".into()), + "{action}" + ); + } + } + + #[test] + fn a_single_foreign_resource_names_its_owner() { + let owner = |action: &str, body: Value| { + let req = request(action, body.clone()); + single_resource_owner(&req, &body) + }; + assert_eq!( + owner("GetItem", json!({"TableName": FOREIGN})).as_deref(), + Some("444455556666") + ); + assert_eq!(owner("GetItem", json!({"TableName": "T"})), None); + assert_eq!( + owner( + "GetItem", + json!({"TableName": "arn:aws:dynamodb:us-east-1:111122223333:table/T"}) + ), + None + ); + assert_eq!( + owner("TagResource", json!({"ResourceArn": FOREIGN})).as_deref(), + Some("444455556666") + ); + assert_eq!( + owner( + "GetRecords", + json!({"ShardIterator": format!("{FOREIGN}/stream/x|shard|0")}) + ) + .as_deref(), + Some("444455556666") + ); + assert_eq!( + owner("BatchGetItem", json!({"RequestItems": {FOREIGN: {}}})), + None + ); + } +} diff --git a/crates/fakecloud-dynamodb/src/service/iam.rs b/crates/fakecloud-dynamodb/src/service/iam.rs index 9881aa225..f5c31dd4d 100644 --- a/crates/fakecloud-dynamodb/src/service/iam.rs +++ b/crates/fakecloud-dynamodb/src/service/iam.rs @@ -47,6 +47,8 @@ struct Scope<'a> { account: &'a str, region: &'a str, accounts: &'a fakecloud_core::multi_account::MultiAccountState, + /// Whether the operation may act on another account's table. + cross_account: bool, } impl Scope<'_> { @@ -59,20 +61,31 @@ impl Scope<'_> { /// policy scoped to the real table's region could be sidestepped. A table /// that does not exist yet (CreateTable) is authorized at the ARN it will /// get: the caller's account and the request's region. + /// + /// The handler serves an operation with cross-account support in the + /// account a table ARN names, and any other operation in the caller's + /// account -- where it does not find another account's table at all -- so + /// the table looked up is the one in that account. fn table(&self, name_or_arn: &str) -> String { - // Handlers look a table up by name in the caller's own account, whatever - // account a table ARN names, so that is the table to authorize. - let name = match table_arn_of(name_or_arn) { - Some(arn) => arn - .rsplit("table/") - .next() - .unwrap_or(name_or_arn) - .to_string(), - None => name_or_arn.to_string(), + let (name, owner) = match table_arn_of(name_or_arn) { + Some(arn) => { + let owner = arn.split(':').nth(4).filter(|a| !a.is_empty()); + let name = arn + .rsplit("table/") + .next() + .unwrap_or(name_or_arn) + .to_string(); + match owner { + Some(owner) if owner != self.account && !self.cross_account => return arn, + Some(owner) => (name, owner.to_string()), + None => (name, self.account.to_string()), + } + } + None => (name_or_arn.to_string(), self.account.to_string()), }; if let Some(table) = self .accounts - .get(self.account) + .get(&owner) .and_then(|state| state.tables.get(&name)) { return table.arn.clone(); @@ -134,6 +147,8 @@ pub(crate) fn actions_for( account, region: request.region.as_str(), accounts: &accounts, + cross_account: super::cross_account::CROSS_ACCOUNT_OPERATIONS + .contains(&request.action.as_str()), }; let op: &'static str = match DYNAMODB_ACTIONS .iter() diff --git a/crates/fakecloud-dynamodb/src/service/iam_conditions.rs b/crates/fakecloud-dynamodb/src/service/iam_conditions.rs index c6abb4bf3..fdad4d1b6 100644 --- a/crates/fakecloud-dynamodb/src/service/iam_conditions.rs +++ b/crates/fakecloud-dynamodb/src/service/iam_conditions.rs @@ -1329,18 +1329,37 @@ mod tests { ); } - /// A table ARN naming another account still authorizes the caller's own - /// table: that is the table the handler serves. + /// A table ARN naming another account authorizes that account's table + /// for an operation with cross-account support, and is authorized as + /// written for one without it (which the handler answers not found). #[test] - fn a_foreign_account_arn_authorizes_the_callers_table() { + fn a_foreign_account_arn_authorizes_the_table_the_handler_serves() { let (_svc, state) = service_with_table(); - let req = request( - "GetItem", - serde_json::json!({"TableName": "arn:aws:dynamodb:us-east-1:444455556666:table/Games"}), + { + let mut accounts = state.write(); + let src = accounts.get("123456789012").unwrap().tables["Games"].clone(); + let foreign = accounts.get_or_create("444455556666"); + let mut table = src; + table.arn = "arn:aws:dynamodb:us-east-1:444455556666:table/Games".to_string(); + foreign.tables.insert("Games".to_string(), table); + } + let arn = "arn:aws:dynamodb:us-east-1:444455556666:table/Games"; + let req = request("GetItem", serde_json::json!({"TableName": arn})); + assert_eq!( + super::super::iam::actions_for(&state, &req)[0].resource, + arn + ); + let req = request("DescribeTimeToLive", serde_json::json!({"TableName": arn})); + assert_eq!( + super::super::iam::actions_for(&state, &req)[0].resource, + arn ); + // A table the named account does not hold is authorized at its ARN. + let missing = "arn:aws:dynamodb:us-east-1:444455556666:table/Nope"; + let req = request("GetItem", serde_json::json!({"TableName": missing})); assert_eq!( super::super::iam::actions_for(&state, &req)[0].resource, - "arn:aws:dynamodb:us-east-1:123456789012:table/Games" + missing ); } diff --git a/crates/fakecloud-dynamodb/src/service/mod.rs b/crates/fakecloud-dynamodb/src/service/mod.rs index d32f6d4fa..72a8e8dab 100644 --- a/crates/fakecloud-dynamodb/src/service/mod.rs +++ b/crates/fakecloud-dynamodb/src/service/mod.rs @@ -1,4 +1,5 @@ mod batch; +pub(crate) mod cross_account; #[cfg(test)] mod expression_corpus_tests; mod global_tables; @@ -443,7 +444,18 @@ impl AwsService for DynamoDbService { "dynamodb" } - async fn handle(&self, req: AwsRequest) -> Result { + async fn handle(&self, mut req: AwsRequest) -> Result { + // A table or stream ARN in another region, or in another account for + // an operation without cross-account support, is not found. A single + // table in another account is served in that account; batches and + // transactions resolve each table's account themselves. + { + let body = req.json_body(); + cross_account::check_references(&req, &body, cross_account::CROSS_ACCOUNT_OPERATIONS)?; + if let Some(owner) = cross_account::single_resource_owner(&req, &body) { + req.account_id = owner; + } + } // Avoid parsing the body for ops where the action alone tells us // they mutate (or don't). Only PartiQL ops need statement // inspection. diff --git a/crates/fakecloud-dynamodb/src/service/tests.rs b/crates/fakecloud-dynamodb/src/service/tests.rs index 83e89e05b..07ba8c175 100644 --- a/crates/fakecloud-dynamodb/src/service/tests.rs +++ b/crates/fakecloud-dynamodb/src/service/tests.rs @@ -7135,3 +7135,312 @@ async fn partiql_select_columns_page_and_validate() { ) .await; } + +// ── Cross-account table ARNs ─────────────────────────────────────────── + +const OWNER: &str = "444455556666"; +const OWNER_ARN: &str = "arn:aws:dynamodb:us-east-1:444455556666:table/Shared"; + +async fn call_as( + svc: &DynamoDbService, + account: &str, + action: &str, + body: Value, +) -> (StatusCode, Value) { + let mut req = make_request(action, body); + req.account_id = account.to_string(); + match svc.handle(req).await { + Ok(resp) => ( + resp.status, + serde_json::from_slice(resp.body.expect_bytes()).unwrap_or(Value::Null), + ), + Err(err) => (err.status(), json!({ "__type": err.code() })), + } +} + +/// A table named `Shared` in both the caller's account and [`OWNER`], each +/// holding one item marking whose it is. +async fn two_account_tables() -> DynamoDbService { + let svc = make_service(); + for account in ["123456789012", OWNER] { + let (status, body) = call_as( + &svc, + account, + "CreateTable", + json!({ + "TableName": "Shared", + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + "BillingMode": "PAY_PER_REQUEST", + "StreamSpecification": {"StreamEnabled": true, "StreamViewType": "NEW_IMAGE"} + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let (status, body) = call_as( + &svc, + account, + "PutItem", + json!({"TableName": "Shared", "Item": {"pk": {"S": "owner"}, "who": {"S": account}}}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + } + svc +} + +fn item_count(svc: &DynamoDbService, account: &str) -> usize { + svc.state.read().get(account).unwrap().tables["Shared"] + .items + .len() +} + +#[tokio::test] +async fn cross_account_item_operations_act_on_the_owners_table() { + let svc = two_account_tables().await; + let (status, body) = call_as( + &svc, + "123456789012", + "GetItem", + json!({"TableName": OWNER_ARN, "Key": {"pk": {"S": "owner"}}}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["Item"]["who"]["S"], OWNER); + + let (status, _) = call_as( + &svc, + "123456789012", + "PutItem", + json!({"TableName": OWNER_ARN, "Item": {"pk": {"S": "written"}}}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(item_count(&svc, OWNER), 2); + assert_eq!(item_count(&svc, "123456789012"), 1); + + let (_, body) = call_as( + &svc, + "123456789012", + "DescribeTable", + json!({"TableName": OWNER_ARN}), + ) + .await; + assert_eq!(body["Table"]["TableArn"], OWNER_ARN); + + let (_, body) = call_as( + &svc, + "123456789012", + "Scan", + json!({"TableName": OWNER_ARN}), + ) + .await; + assert_eq!(body["Count"], 2); + + let (status, _) = call_as( + &svc, + "123456789012", + "TagResource", + json!({"ResourceArn": OWNER_ARN, "Tags": [{"Key": "team", "Value": "blue"}]}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + svc.state.read().get(OWNER).unwrap().tables["Shared"].tags["team"], + "blue" + ); +} + +#[tokio::test] +async fn cross_account_batches_resolve_each_tables_account() { + let svc = two_account_tables().await; + // The same key in two accounts' tables is two different items. + let (status, body) = call_as( + &svc, + "123456789012", + "BatchWriteItem", + json!({"RequestItems": { + "Shared": [{"PutRequest": {"Item": {"pk": {"S": "k"}, "v": {"S": "mine"}}}}], + OWNER_ARN: [{"PutRequest": {"Item": {"pk": {"S": "k"}, "v": {"S": "theirs"}}}}] + }}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + + let (status, body) = call_as( + &svc, + "123456789012", + "BatchGetItem", + json!({"RequestItems": { + "Shared": {"Keys": [{"pk": {"S": "k"}}]}, + OWNER_ARN: {"Keys": [{"pk": {"S": "k"}}]} + }}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["Responses"]["Shared"][0]["v"]["S"], "mine"); + assert_eq!(body["Responses"][OWNER_ARN][0]["v"]["S"], "theirs"); + + let (status, body) = call_as( + &svc, + "123456789012", + "TransactGetItems", + json!({"TransactItems": [ + {"Get": {"TableName": "Shared", "Key": {"pk": {"S": "k"}}}}, + {"Get": {"TableName": OWNER_ARN, "Key": {"pk": {"S": "k"}}}} + ]}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["Responses"][0]["Item"]["v"]["S"], "mine"); + assert_eq!(body["Responses"][1]["Item"]["v"]["S"], "theirs"); +} + +#[tokio::test] +async fn cross_account_transactions_are_atomic_across_accounts() { + let svc = two_account_tables().await; + let own_arn = "arn:aws:dynamodb:us-east-1:123456789012:table/Shared"; + let (status, body) = call_as( + &svc, + "123456789012", + "TransactWriteItems", + json!({"TransactItems": [ + {"Put": {"TableName": "Shared", "Item": {"pk": {"S": "t"}}}}, + {"Put": {"TableName": OWNER_ARN, "Item": {"pk": {"S": "t"}}}} + ]}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(item_count(&svc, "123456789012"), 2); + assert_eq!(item_count(&svc, OWNER), 2); + // The writes landed on each table's stream. + for account in ["123456789012", OWNER] { + let accounts = svc.state.read(); + let records = accounts.get(account).unwrap().tables["Shared"] + .stream_records + .read() + .len(); + assert_eq!(records, 2, "{account}"); + } + + // The same item named by name and by the caller's own ARN is one item. + let (status, body) = call_as( + &svc, + "123456789012", + "TransactWriteItems", + json!({"TransactItems": [ + {"Put": {"TableName": "Shared", "Item": {"pk": {"S": "d"}}}}, + {"Delete": {"TableName": own_arn, "Key": {"pk": {"S": "d"}}}} + ]}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["__type"], "ValidationException"); + + // A failure applying a later write reverts the writes already applied + // in both accounts. + let (status, body) = call_as( + &svc, + "123456789012", + "TransactWriteItems", + json!({"TransactItems": [ + {"Put": {"TableName": OWNER_ARN, "Item": {"pk": {"S": "r"}}}}, + {"Put": {"TableName": "Shared", "Item": {"pk": {"S": "r"}}}}, + {"Update": { + "TableName": OWNER_ARN, + "Key": {"pk": {"S": "r2"}}, + "UpdateExpression": "BOGUS expression that won't parse" + }} + ]}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert_eq!(body["__type"], "TransactionCanceledException"); + assert_eq!(item_count(&svc, "123456789012"), 2); + assert_eq!(item_count(&svc, OWNER), 2); + + // A condition failing on the foreign table cancels the whole transaction. + let (status, body) = call_as( + &svc, + "123456789012", + "TransactWriteItems", + json!({"TransactItems": [ + {"Put": {"TableName": "Shared", "Item": {"pk": {"S": "c"}}}}, + {"ConditionCheck": { + "TableName": OWNER_ARN, + "Key": {"pk": {"S": "owner"}}, + "ConditionExpression": "attribute_not_exists(pk)" + }} + ]}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["__type"], "TransactionCanceledException"); + assert_eq!(item_count(&svc, "123456789012"), 2); +} + +#[tokio::test] +async fn other_accounts_tables_are_not_found_without_cross_account_support() { + let svc = two_account_tables().await; + for (action, body, code) in [ + ( + "CreateBackup", + json!({"TableName": OWNER_ARN, "BackupName": "b"}), + "TableNotFoundException", + ), + ( + "DescribeTimeToLive", + json!({"TableName": OWNER_ARN}), + "ResourceNotFoundException", + ), + ( + "GetResourcePolicy", + json!({"ResourceArn": OWNER_ARN}), + "ResourceNotFoundException", + ), + ( + "ExecuteStatement", + json!({"Statement": format!("SELECT * FROM \"{OWNER_ARN}\"")}), + "ResourceNotFoundException", + ), + ( + "DescribeContinuousBackups", + json!({"TableName": OWNER_ARN}), + "TableNotFoundException", + ), + ] { + let (status, got) = call_as(&svc, "123456789012", action, body).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{action}: {got}"); + assert_eq!(got["__type"], code, "{action}"); + } + + // Another region's table is not found, whoever owns it. + for arn in [ + "arn:aws:dynamodb:us-west-2:123456789012:table/Shared", + "arn:aws:dynamodb:us-west-2:444455556666:table/Shared", + ] { + let (status, got) = call_as( + &svc, + "123456789012", + "GetItem", + json!({"TableName": arn, "Key": {"pk": {"S": "owner"}}}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{arn}"); + assert_eq!(got["__type"], "ResourceNotFoundException", "{arn}"); + } + + // A batch naming another region's table fails as a whole. + let (status, got) = call_as( + &svc, + "123456789012", + "BatchGetItem", + json!({"RequestItems": { + "Shared": {"Keys": [{"pk": {"S": "owner"}}]}, + "arn:aws:dynamodb:eu-west-1:444455556666:table/Shared": {"Keys": [{"pk": {"S": "owner"}}]} + }}), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(got["__type"], "ResourceNotFoundException"); +} diff --git a/crates/fakecloud-dynamodb/src/streams_dataplane.rs b/crates/fakecloud-dynamodb/src/streams_dataplane.rs index 4a44ff622..6fc9eca05 100644 --- a/crates/fakecloud-dynamodb/src/streams_dataplane.rs +++ b/crates/fakecloud-dynamodb/src/streams_dataplane.rs @@ -14,6 +14,7 @@ use serde_json::{json, Value}; use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError}; +use crate::service::cross_account; use crate::state::{DynamoTable, SharedDynamoDbState}; pub struct DynamoDbStreamsService { @@ -32,8 +33,18 @@ impl AwsService for DynamoDbStreamsService { "dynamodbstreams" } - async fn handle(&self, req: AwsRequest) -> Result { + async fn handle(&self, mut req: AwsRequest) -> Result { let body: Value = serde_json::from_slice(&req.body).unwrap_or_default(); + // A stream in another region, or in another account for ListStreams, + // is not found; a stream read in another account is served there. + cross_account::check_references( + &req, + &body, + cross_account::STREAMS_CROSS_ACCOUNT_OPERATIONS, + )?; + if let Some(owner) = cross_account::single_resource_owner(&req, &body) { + req.account_id = owner; + } match req.action.as_str() { "ListStreams" => self.list_streams(&req, &body), "DescribeStream" => self.describe_stream(&req, &body), @@ -704,4 +715,74 @@ mod tests { .expect("expected ResourceNotFound"); assert!(format!("{:?}", err).contains("ResourceNotFoundException")); } + + /// Another account's stream is read in that account; ListStreams lists + /// only the caller's own streams; a stream in another region is not found. + #[tokio::test] + async fn another_accounts_stream_is_read_in_its_account() { + let state = make_state(); + let arn = seed_table(&state); + let svc = DynamoDbStreamsService::new(state); + let as_other = |action: &str, body: Value| { + let mut r = req(action, body); + r.account_id = "444455556666".into(); + r + }; + + let resp = svc + .handle(as_other("DescribeStream", json!({"StreamArn": arn}))) + .await + .unwrap(); + let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + assert_eq!(body["StreamDescription"]["StreamArn"], arn.as_str()); + + let resp = svc + .handle(as_other( + "GetShardIterator", + json!({ + "StreamArn": arn, + "ShardId": body["StreamDescription"]["Shards"][0]["ShardId"], + "ShardIteratorType": "TRIM_HORIZON" + }), + )) + .await + .unwrap(); + let it: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + let resp = svc + .handle(as_other( + "GetRecords", + json!({"ShardIterator": it["ShardIterator"]}), + )) + .await + .unwrap(); + let records: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + assert_eq!(records["Records"].as_array().unwrap().len(), 1); + + let resp = svc + .handle(as_other("ListStreams", json!({}))) + .await + .unwrap(); + let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + assert!(body["Streams"].as_array().unwrap().is_empty()); + + let other_region = arn.replace("us-east-1", "eu-west-1"); + let err = svc + .handle(as_other( + "DescribeStream", + json!({"StreamArn": other_region}), + )) + .await + .err() + .expect("another region's stream is not found"); + assert_eq!(err.code(), "ResourceNotFoundException"); + let err = svc + .handle(as_other( + "ListStreams", + json!({"TableName": "arn:aws:dynamodb:us-east-1:123456789012:table/widgets"}), + )) + .await + .err() + .expect("ListStreams has no cross-account support"); + assert_eq!(err.code(), "ResourceNotFoundException"); + } } diff --git a/crates/fakecloud-e2e/tests/dynamodb_cross_account.rs b/crates/fakecloud-e2e/tests/dynamodb_cross_account.rs new file mode 100644 index 000000000..efa71a385 --- /dev/null +++ b/crates/fakecloud-e2e/tests/dynamodb_cross_account.rs @@ -0,0 +1,476 @@ +//! Cross-account DynamoDB access through table and stream ARNs. +//! +//! Account A owns the tables; account B names them by ARN. Under +//! `FAKECLOUD_IAM=strict` a cross-account request needs both B's identity +//! policy and the resource-based policy on A's table (or stream). Operations +//! without cross-account support, and ARNs naming another region, find no +//! table at all. + +mod helpers; + +use aws_credential_types::Credentials; +use aws_sdk_dynamodb::types::{ + AttributeDefinition, AttributeValue, BillingMode, KeySchemaElement, KeyType, KeysAndAttributes, + Put, PutRequest, ScalarAttributeType, StreamSpecification, StreamViewType, TransactWriteItem, + WriteRequest, +}; +use aws_sdk_dynamodb::Client as DynamoClient; +use aws_sdk_iam::Client as IamClient; +use helpers::TestServer; + +const ACCOUNT_A: &str = "123456789012"; +const ACCOUNT_B: &str = "222222222222"; +const ACCOUNT_C: &str = "333333333333"; +const REGION: &str = "us-east-1"; + +async fn start_strict() -> TestServer { + TestServer::start_with_env(&[ + ("FAKECLOUD_IAM", "strict"), + ("FAKECLOUD_VERIFY_SIGV4", "true"), + ]) + .await +} + +async fn config(server: &TestServer, akid: &str, secret: &str) -> aws_config::SdkConfig { + aws_config::defaults(aws_config::BehaviorVersion::latest()) + .endpoint_url(server.endpoint()) + .region(aws_config::Region::new(REGION)) + .credentials_provider(Credentials::new( + akid, + secret, + None, + None, + "dynamodb-x-acct", + )) + .load() + .await +} + +/// An administrator's DynamoDB client in `account`, and its SDK config. +async fn admin_in( + server: &TestServer, + account: &str, + name: &str, +) -> (DynamoClient, aws_config::SdkConfig) { + let (akid, secret) = server.create_admin(account, name).await; + let cfg = config(server, &akid, &secret).await; + (DynamoClient::new(&cfg), cfg) +} + +fn table_arn(name: &str) -> String { + format!("arn:aws:dynamodb:{REGION}:{ACCOUNT_A}:table/{name}") +} + +async fn create_table(client: &DynamoClient, name: &str) -> String { + let out = client + .create_table() + .table_name(name) + .key_schema( + KeySchemaElement::builder() + .attribute_name("pk") + .key_type(KeyType::Hash) + .build() + .unwrap(), + ) + .attribute_definitions( + AttributeDefinition::builder() + .attribute_name("pk") + .attribute_type(ScalarAttributeType::S) + .build() + .unwrap(), + ) + .billing_mode(BillingMode::PayPerRequest) + .stream_specification( + StreamSpecification::builder() + .stream_enabled(true) + .stream_view_type(StreamViewType::NewImage) + .build() + .unwrap(), + ) + .send() + .await + .unwrap(); + out.table_description() + .and_then(|t| t.latest_stream_arn()) + .unwrap_or_default() + .to_string() +} + +async fn put_resource_policy(owner: &DynamoClient, arn: &str, actions: &[&str]) { + let policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": format!("arn:aws:iam::{ACCOUNT_B}:root")}, + "Action": actions, + "Resource": "*" + }] + }); + owner + .put_resource_policy() + .resource_arn(arn) + .policy(policy.to_string()) + .send() + .await + .unwrap(); +} + +fn err_text(result: Result) -> String { + match result { + Ok(_) => "Ok".to_string(), + Err(e) => format!("{e:?}"), + } +} + +fn pk(v: &str) -> AttributeValue { + AttributeValue::S(v.to_string()) +} + +#[tokio::test] +async fn cross_account_table_access_needs_the_table_resource_policy() { + let server = start_strict().await; + let (owner, _) = admin_in(&server, ACCOUNT_A, "admin-a").await; + let (caller, caller_cfg) = admin_in(&server, ACCOUNT_B, "admin-b").await; + let (stranger, _) = admin_in(&server, ACCOUNT_C, "admin-c").await; + create_table(&owner, "Shared").await; + create_table(&caller, "Mine").await; + owner + .put_item() + .table_name("Shared") + .item("pk", pk("seed")) + .send() + .await + .unwrap(); + let shared = table_arn("Shared"); + + // B's identity policy allows everything, but A's table has no policy. + let denied = err_text( + caller + .get_item() + .table_name(&shared) + .key("pk", pk("seed")) + .send() + .await, + ); + assert!(denied.contains("AccessDenied"), "{denied}"); + + put_resource_policy( + &owner, + &shared, + &[ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:DescribeTable", + ], + ) + .await; + + let got = caller + .get_item() + .table_name(&shared) + .key("pk", pk("seed")) + .send() + .await + .unwrap(); + assert!(got.item().is_some(), "B reads A's item"); + + caller + .put_item() + .table_name(&shared) + .item("pk", pk("from-b")) + .send() + .await + .unwrap(); + let seen = owner + .get_item() + .table_name("Shared") + .key("pk", pk("from-b")) + .send() + .await + .unwrap(); + assert!(seen.item().is_some(), "B's write lands in A's table"); + let own = caller + .get_item() + .table_name("Mine") + .key("pk", pk("from-b")) + .send() + .await + .unwrap(); + assert!(own.item().is_none(), "and not in B's own table"); + + let described = caller + .describe_table() + .table_name(&shared) + .send() + .await + .unwrap(); + assert_eq!( + described.table().and_then(|t| t.table_arn()), + Some(shared.as_str()) + ); + + let queried = caller + .query() + .table_name(&shared) + .key_condition_expression("pk = :p") + .expression_attribute_values(":p", pk("seed")) + .send() + .await + .unwrap(); + assert_eq!(queried.count(), 1); + + // A batch spanning B's own table and A's. + caller + .batch_write_item() + .request_items( + "Mine", + vec![WriteRequest::builder() + .put_request(PutRequest::builder().item("pk", pk("k")).build().unwrap()) + .build()], + ) + .request_items( + &shared, + vec![WriteRequest::builder() + .put_request(PutRequest::builder().item("pk", pk("k")).build().unwrap()) + .build()], + ) + .send() + .await + .unwrap(); + let batch = caller + .batch_get_item() + .request_items( + "Mine", + KeysAndAttributes::builder() + .keys([("pk".to_string(), pk("k"))].into()) + .build() + .unwrap(), + ) + .request_items( + &shared, + KeysAndAttributes::builder() + .keys([("pk".to_string(), pk("k"))].into()) + .build() + .unwrap(), + ) + .send() + .await + .unwrap(); + let responses = batch.responses().unwrap(); + assert_eq!(responses["Mine"].len(), 1); + assert_eq!(responses[&shared].len(), 1); + + // A transaction spanning both accounts' tables. + caller + .transact_write_items() + .transact_items( + TransactWriteItem::builder() + .put( + Put::builder() + .table_name("Mine") + .item("pk", pk("t")) + .build() + .unwrap(), + ) + .build(), + ) + .transact_items( + TransactWriteItem::builder() + .put( + Put::builder() + .table_name(&shared) + .item("pk", pk("t")) + .build() + .unwrap(), + ) + .build(), + ) + .send() + .await + .unwrap(); + let committed = owner + .get_item() + .table_name("Shared") + .key("pk", pk("t")) + .send() + .await + .unwrap(); + assert!(committed.item().is_some()); + + // The policy does not grant DeleteItem. + let denied = err_text( + caller + .delete_item() + .table_name(&shared) + .key("pk", pk("seed")) + .send() + .await, + ); + assert!(denied.contains("AccessDenied"), "{denied}"); + + // An account the policy does not name is denied. + let denied = err_text( + stranger + .get_item() + .table_name(&shared) + .key("pk", pk("seed")) + .send() + .await, + ); + assert!(denied.contains("AccessDenied"), "{denied}"); + + // A user in B without an identity policy is denied: cross-account access + // needs both policies. + let iam = IamClient::new(&caller_cfg); + iam.create_user().user_name("bare").send().await.unwrap(); + let key = iam + .create_access_key() + .user_name("bare") + .send() + .await + .unwrap(); + let key = key.access_key().unwrap(); + let bare = + DynamoClient::new(&config(&server, key.access_key_id(), key.secret_access_key()).await); + let denied = err_text( + bare.get_item() + .table_name(&shared) + .key("pk", pk("seed")) + .send() + .await, + ); + assert!(denied.contains("AccessDenied"), "{denied}"); +} + +#[tokio::test] +async fn unsupported_operations_and_other_regions_find_no_table() { + let server = start_strict().await; + let (owner, _) = admin_in(&server, ACCOUNT_A, "admin-a").await; + let (caller, _) = admin_in(&server, ACCOUNT_B, "admin-b").await; + create_table(&owner, "Shared").await; + let shared = table_arn("Shared"); + put_resource_policy(&owner, &shared, &["dynamodb:*"]).await; + + let backup = err_text( + caller + .create_backup() + .table_name(&shared) + .backup_name("b") + .send() + .await, + ); + assert!(backup.contains("TableNotFoundException"), "{backup}"); + + let ttl = err_text( + caller + .describe_time_to_live() + .table_name(&shared) + .send() + .await, + ); + assert!(ttl.contains("ResourceNotFoundException"), "{ttl}"); + + let partiql = err_text( + caller + .execute_statement() + .statement(format!("SELECT * FROM \"{shared}\"")) + .send() + .await, + ); + assert!(partiql.contains("ResourceNotFoundException"), "{partiql}"); + + let other_region = format!("arn:aws:dynamodb:us-west-2:{ACCOUNT_A}:table/Shared"); + let region = err_text( + caller + .get_item() + .table_name(&other_region) + .key("pk", pk("x")) + .send() + .await, + ); + assert!(region.contains("ResourceNotFoundException"), "{region}"); + + // B does not see A's table in its own listing. + let tables = caller.list_tables().send().await.unwrap(); + assert!(tables.table_names().is_empty()); +} + +#[tokio::test] +async fn cross_account_stream_reads_need_the_stream_resource_policy() { + let server = start_strict().await; + let (owner, _) = admin_in(&server, ACCOUNT_A, "admin-a").await; + let (_, caller_cfg) = admin_in(&server, ACCOUNT_B, "admin-b").await; + let stream_arn = create_table(&owner, "Shared").await; + assert!(!stream_arn.is_empty()); + owner + .put_item() + .table_name("Shared") + .item("pk", pk("seed")) + .send() + .await + .unwrap(); + let streams = aws_sdk_dynamodbstreams::Client::new(&caller_cfg); + + let denied = err_text( + streams + .describe_stream() + .stream_arn(&stream_arn) + .send() + .await, + ); + assert!(denied.contains("AccessDenied"), "{denied}"); + + // A table policy does not cover the stream. + put_resource_policy(&owner, &table_arn("Shared"), &["dynamodb:*"]).await; + let denied = err_text( + streams + .describe_stream() + .stream_arn(&stream_arn) + .send() + .await, + ); + assert!(denied.contains("AccessDenied"), "{denied}"); + + put_resource_policy( + &owner, + &stream_arn, + &[ + "dynamodb:DescribeStream", + "dynamodb:GetShardIterator", + "dynamodb:GetRecords", + ], + ) + .await; + let described = streams + .describe_stream() + .stream_arn(&stream_arn) + .send() + .await + .unwrap(); + let shard = described + .stream_description() + .unwrap() + .shards() + .first() + .and_then(|s| s.shard_id()) + .unwrap() + .to_string(); + let iterator = streams + .get_shard_iterator() + .stream_arn(&stream_arn) + .shard_id(shard) + .shard_iterator_type(aws_sdk_dynamodbstreams::types::ShardIteratorType::TrimHorizon) + .send() + .await + .unwrap(); + let records = streams + .get_records() + .shard_iterator(iterator.shard_iterator().unwrap()) + .send() + .await + .unwrap(); + assert_eq!(records.records().len(), 1); +} diff --git a/website/content/docs/reference/limitations.md b/website/content/docs/reference/limitations.md index 85d1d2d9f..2a7dc570f 100644 --- a/website/content/docs/reference/limitations.md +++ b/website/content/docs/reference/limitations.md @@ -55,7 +55,7 @@ FAKECLOUD_VERIFY_SIGV4=true # real cryptographic signature verification FAKECLOUD_IAM=soft|strict # identity + condition + resource-policy evaluation ``` -Evaluation covers `Allow` / `Deny` with Deny precedence, `Action` / `NotAction` / `Resource` / `NotResource` with wildcards, identity policies attached via user/group/role, `Condition` blocks with all 28 AWS operators against global keys plus service-specific keys for S3/SNS/Lambda/SQS, resource-based policies for S3 bucket policies, SNS topic policies, Lambda function policies, and KMS key policies (with AWS's cross-account combining semantics), full `Principal` / `NotPrincipal` matching on resource-based policies, permission boundaries (`PutUserPermissionsBoundary` / `PutRolePermissionsBoundary`), session policies passed to `AssumeRole` / `AssumeRoleWithWebIdentity` / `AssumeRoleWithSAML` / `GetFederationToken`, ABAC tag conditions (`aws:ResourceTag/`, `aws:RequestTag/`, `aws:TagKeys`, `aws:PrincipalTag/`) on S3, SQS, SNS, and IAM resources, and Organizations SCPs (Service Control Policies) as a permissions ceiling across multi-account setups. See [SigV4 verification and IAM enforcement](@/docs/reference/security.md) for the full scope, enforced-service list, and the reserved `test`/`test` root-bypass convention. +Evaluation covers `Allow` / `Deny` with Deny precedence, `Action` / `NotAction` / `Resource` / `NotResource` with wildcards, identity policies attached via user/group/role, `Condition` blocks with all 28 AWS operators against global keys plus service-specific keys for S3/SNS/Lambda/SQS, resource-based policies for S3 bucket policies, SNS topic policies, Lambda function policies, KMS key policies, and DynamoDB table and stream policies (with AWS's cross-account combining semantics), full `Principal` / `NotPrincipal` matching on resource-based policies, permission boundaries (`PutUserPermissionsBoundary` / `PutRolePermissionsBoundary`), session policies passed to `AssumeRole` / `AssumeRoleWithWebIdentity` / `AssumeRoleWithSAML` / `GetFederationToken`, ABAC tag conditions (`aws:ResourceTag/`, `aws:RequestTag/`, `aws:TagKeys`, `aws:PrincipalTag/`) on S3, SQS, SNS, IAM, KMS, and DynamoDB resources, and Organizations SCPs (Service Control Policies) as a permissions ceiling across multi-account setups. See [SigV4 verification and IAM enforcement](@/docs/reference/security.md) for the full scope, enforced-service list, and the reserved `test`/`test` root-bypass convention. ## Everything else is in scope diff --git a/website/content/docs/reference/security.md b/website/content/docs/reference/security.md index f5027955f..6ed50dcc6 100644 --- a/website/content/docs/reference/security.md +++ b/website/content/docs/reference/security.md @@ -161,7 +161,7 @@ The resource's owning account is parsed from the ARN; S3 ARNs have an empty acco - **S3 bucket policies** are stored by `PutBucketPolicy` and updated by `DeleteBucketPolicy`. `GetBucketPolicy` returns the raw JSON. - **SNS topic policies** are stored in the topic's `Policy` attribute by `SetTopicAttributes` (full document) or by `AddPermission` / `RemovePermission` (incremental statements). `GetTopicAttributes` returns them. - **Lambda function policies** are built incrementally by `AddPermission`: fakecloud composes a canonical `{"Version":"2012-10-17","Statement":[...]}` document from `(StatementId, Action, Principal, SourceArn?, SourceAccount?)` so the existing evaluator reads it without a Lambda-specific fork. `SourceArn` becomes an `ArnLike` `Condition` on `aws:SourceArn`, and `SourceAccount` becomes a `StringEquals` `Condition` on `aws:SourceAccount` — both are already in the operator set. `GetPolicy` returns the composed document; `RemovePermission` strips the matching `Sid` and leaves an empty `Statement` array behind, matching AWS. -- **DynamoDB table and stream policies** are attached by `PutResourcePolicy` (or `CreateTable`'s `ResourcePolicy`), read by `GetResourcePolicy` and removed by `DeleteResourcePolicy`, honoring `ExpectedRevisionId` (including `NO_POLICY`). A table's policy also governs its indexes; a stream's policy is its own and belongs to that stream ARN. +- **DynamoDB table and stream policies** are attached by `PutResourcePolicy` (or `CreateTable`'s `ResourcePolicy`), read by `GetResourcePolicy` and removed by `DeleteResourcePolicy`, honoring `ExpectedRevisionId` (including `NO_POLICY`). A table's policy also governs its indexes; a stream's policy is its own and belongs to that stream ARN. A principal in another account uses a table or stream by its ARN, for the data-plane, `DescribeTable` / `UpdateTable` / `DeleteTable`, tagging and stream-read operations AWS allows cross-account; the resource policy must grant it alongside the caller's identity policy. - **IAM role trust policies** (`assume_role_policy_document`) are evaluated on every `AssumeRole`, `AssumeRoleWithSAML`, and `AssumeRoleWithWebIdentity` call before STS issues credentials. The trust policy is the *only* authorization source for role assumption — identity policies do not factor in. Caller principal, action (`sts:AssumeRole*`), `Condition` keys (`sts:ExternalId`, `sts:RoleSessionName`, `sts:SourceIdentity`, `aws:MultiFactorAuthPresent`, `aws:SourceAccount`), and federation-specific keys (`saml:aud`, `saml:iss`, `:aud`, `:sub`) are all populated. `AssumeRoleWithWebIdentity` additionally requires the JWT's `iss` to match a registered `OpenIDConnectProvider` and `aud` to be in its `client_id_list`; service-linked roles (path `/aws-service-role//...`) refuse non-service callers regardless of trust-policy contents. **Principal matching.** Resource policies use `Principal` / `NotPrincipal` keys that identity policies don't. The evaluator supports the shapes resource policies actually use in practice: diff --git a/website/content/docs/services/dynamodb.md b/website/content/docs/services/dynamodb.md index b2d742dea..ccc99021d 100644 --- a/website/content/docs/services/dynamodb.md +++ b/website/content/docs/services/dynamodb.md @@ -22,7 +22,7 @@ fakecloud implements **57 of 57** DynamoDB operations at 100% Smithy conformance - **Exports and imports** — S3 exports (recorded), S3 imports (recorded) - **ConsumedCapacity + ItemCollectionMetrics** — every data-plane op (`GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems`, `TransactWriteItems`, PartiQL variants) returns `ConsumedCapacity` when the caller requests it via `ReturnConsumedCapacity = TOTAL` / `INDEXES`. Capacity units are synthesized from the serialized item byte size using AWS's documented 4 KB read / 1 KB write rounding, broken out per table + per index. `ItemCollectionMetrics` is emitted on writes touching tables that have a local secondary index, with `SizeEstimateRangeGB` rounded to the AWS-documented `[lower, upper]` shape - **IAM enforcement** — with `FAKECLOUD_IAM=strict` (or `soft`), every DynamoDB and DynamoDB Streams operation is authorized against the caller's policies using the actions and resource ARNs AWS uses: the table, index, stream, backup, export or import ARN; the batch action on every table in a batch; the per-item action on each table in a transaction; `PartiQLSelect` / `PartiQLInsert` / `PartiQLUpdate` / `PartiQLDelete` for PartiQL; `aws:ResourceTag` / `aws:RequestTag` / `aws:TagKeys` conditions on table tags; fine-grained access control through `dynamodb:LeadingKeys`, `dynamodb:Attributes`, `dynamodb:Select`, `dynamodb:ReturnValues`, `dynamodb:ReturnConsumedCapacity`, `dynamodb:EnclosingOperation` and `dynamodb:FullTableScan`; and table and stream resource-based policies, whose explicit Deny wins and whose Allow grants same-account principals on its own. See [SigV4 verification and IAM enforcement](@/docs/reference/security.md) -- **`TableName` accepts ARNs** — every operation that takes a `TableName` parameter also accepts the full `arn:aws:dynamodb:::table/` form, and resolves it back to the local table. The same applies to global secondary index identifiers when an ARN form is supplied. Matches the real AWS API change that landed in 2024 so cross-region / cross-account SDK call patterns work without rewriting test fixtures +- **`TableName` accepts ARNs, including other accounts' tables**: every operation that takes a `TableName` (or `ResourceArn`, `StreamArn`) also accepts the full `arn:aws:dynamodb:::table/` form. An ARN naming another account reaches that account's table for the operations AWS supports cross-account: `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems`, `TransactWriteItems` (a batch or transaction can mix tables from several accounts, and a transaction stays atomic across them), `DescribeTable`, `UpdateTable`, `DeleteTable`, `ListTagsOfResource`, `TagResource`, `UntagResource`, and the Streams `DescribeStream`, `GetShardIterator` and `GetRecords`. With IAM enforcement on, such a request needs both the caller's identity policy and the table's (or stream's) resource-based policy. Any other operation (PartiQL, backups, point-in-time recovery, TTL, Kinesis streaming, resource-policy operations, imports and exports) does not find another account's table, and an ARN naming a region other than the request's is not found for any operation ## Protocol From dce6d313ac60b514c9a3c0a7e8981bca4f1d23e7 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Mon, 14 Sep 2026 10:55:05 -0300 Subject: [PATCH 2/5] fix(dynamodb): scope batch and transaction condition keys to the table's account A batch or transaction can name same-named tables in two accounts; each table's dynamodb:LeadingKeys and dynamodb:Attributes now come only from the entries sent to that account's table. --- .../src/service/iam_conditions.rs | 72 +++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/crates/fakecloud-dynamodb/src/service/iam_conditions.rs b/crates/fakecloud-dynamodb/src/service/iam_conditions.rs index fdad4d1b6..a536b6485 100644 --- a/crates/fakecloud-dynamodb/src/service/iam_conditions.rs +++ b/crates/fakecloud-dynamodb/src/service/iam_conditions.rs @@ -142,6 +142,7 @@ impl Keys { /// The table (and index, if any) a resource ARN names, with the partition /// key attribute the ARN's key conditions are about. struct Target { + account: String, table_ref_name: String, partition_key: String, index: Option, @@ -174,19 +175,26 @@ fn target( None => table.hash_key_name().to_string(), }; Some(Target { + account: account.to_string(), table_ref_name: name.to_string(), partition_key, index, }) } -/// Whether a request's `TableName` value names `target`'s table. -fn names_table(target: &Target, table_name: Option<&str>) -> bool { +/// Whether a request's `TableName` value names `target`'s table: the same +/// name in the same account. A batch or transaction may name same-named +/// tables in several accounts (one by name, others by ARN), and each table's +/// authorization sees only the keys and attributes sent to that table. +fn names_table(target: &Target, caller_account: &str, table_name: Option<&str>) -> bool { let Some(name) = table_name else { return false; }; - let resolved = super::resolve_table_name(name); - resolved == target.table_ref_name + let owner = match super::cross_account::arn_scope(name) { + Some((_, account)) if !account.is_empty() => account, + _ => caller_account, + }; + owner == target.account && super::resolve_table_name(name) == target.table_ref_name } /// The string an IAM condition compares for a scalar attribute value: the @@ -213,6 +221,13 @@ pub(crate) fn condition_keys( let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null); let accounts = state.read(); let target = target(&accounts, &action.resource); + // The account a plain table name resolves in, as `iam::actions_for` + // resolves it. + let caller_account = request + .principal + .as_ref() + .map(|p| p.account_id.as_str()) + .unwrap_or(request.account_id.as_str()); let mut keys = Keys::default(); let rcc = || { Some( @@ -263,7 +278,7 @@ pub(crate) fn condition_keys( "BatchGetItem" | "BatchWriteItem" => { if let (Some(t), Some(items)) = (&target, body["RequestItems"].as_object()) { for (table_name, entry) in items { - if !names_table(t, Some(table_name)) { + if !names_table(t, caller_account, Some(table_name)) { continue; } if request.action == "BatchGetItem" { @@ -305,7 +320,7 @@ pub(crate) fn condition_keys( if let Some(t) = &target { for item in body["TransactItems"].as_array().into_iter().flatten() { let op = &item[member]; - if !names_table(t, op["TableName"].as_str()) { + if !names_table(t, caller_account, op["TableName"].as_str()) { continue; } let addressed = if member == "Put" { @@ -1363,6 +1378,51 @@ mod tests { ); } + /// Same-named tables in two accounts named by one batch or transaction + /// each see only the keys sent to them. + #[test] + fn same_named_tables_in_two_accounts_keep_their_own_keys() { + let (_svc, state) = service_with_table(); + let arn = "arn:aws:dynamodb:us-east-1:444455556666:table/Games"; + { + let mut accounts = state.write(); + let mut table = accounts.get("123456789012").unwrap().tables["Games"].clone(); + table.arn = arn.to_string(); + accounts + .get_or_create("444455556666") + .tables + .insert("Games".to_string(), table); + } + let own = "arn:aws:dynamodb:us-east-1:123456789012:table/Games"; + for (action, body) in [ + ( + "TransactWriteItems", + serde_json::json!({"TransactItems": [ + {"Put": {"TableName": "Games", "Item": {"UserId": {"S": "mine"}, "Title": {"S": "a"}}}}, + {"Put": {"TableName": arn, "Item": {"UserId": {"S": "theirs"}, "Title": {"S": "a"}}}} + ]}), + ), + ( + "BatchGetItem", + serde_json::json!({"RequestItems": { + "Games": {"Keys": [{"UserId": {"S": "mine"}, "Title": {"S": "a"}}]}, + arn: {"Keys": [{"UserId": {"S": "theirs"}, "Title": {"S": "a"}}]} + }}), + ), + ] { + let req = request(action, body); + let actions = super::super::iam::actions_for(&state, &req); + let leading = |resource: &str| { + let a = actions.iter().find(|a| a.resource == resource)?; + condition_keys(&state, &req, a) + .get("dynamodb:leadingkeys") + .cloned() + }; + assert_eq!(leading(own), Some(vec!["mine".to_string()]), "{action}"); + assert_eq!(leading(arn), Some(vec!["theirs".to_string()]), "{action}"); + } + } + /// A key condition parenthesized as a whole still yields its partition /// key, a number key is reported canonically, and a condition the /// extraction cannot read leaves the key unknown (omitted) rather than an From c4f837d4ea939ef9c4a2c146ab69cf5c5532735d Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Mon, 14 Sep 2026 11:03:05 -0300 Subject: [PATCH 3/5] fix(dynamodb): let list operations filter by any table ARN ListBackups, ListExports and ListImports take a table only as a filter and model no not-found error, so another account's or region's table matches nothing instead of failing the call. --- .../src/service/cross_account.rs | 13 ++++++++++++ .../fakecloud-dynamodb/src/service/tests.rs | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/fakecloud-dynamodb/src/service/cross_account.rs b/crates/fakecloud-dynamodb/src/service/cross_account.rs index eebc9b971..f0e2ece18 100644 --- a/crates/fakecloud-dynamodb/src/service/cross_account.rs +++ b/crates/fakecloud-dynamodb/src/service/cross_account.rs @@ -160,6 +160,14 @@ pub(crate) fn check_references( body: &Value, cross_account_operations: &[&str], ) -> Result<(), AwsServiceError> { + // These listings take a table only as a filter and model no not-found + // error: a table they cannot see just matches nothing. + if matches!( + req.action.as_str(), + "ListBackups" | "ListExports" | "ListImports" + ) { + return Ok(()); + } let cross_account = cross_account_operations.contains(&req.action.as_str()); for (arn, code) in referenced_arns(&req.action, body) { let Some((region, account)) = arn_scope(&arn) else { @@ -272,6 +280,11 @@ mod tests { ), Err("ResourceNotFoundException".into()) ); + // A listing filtered by another account's or region's table is empty, + // not an error. + assert_eq!(check("ListExports", json!({"TableArn": FOREIGN})), Ok(())); + assert_eq!(check("ListImports", json!({"TableArn": FOREIGN})), Ok(())); + assert_eq!(check("ListBackups", json!({"TableName": FOREIGN})), Ok(())); let other_region = "arn:aws:dynamodb:eu-west-1:111122223333:table/T"; for (action, body) in [ ("GetItem", json!({"TableName": other_region})), diff --git a/crates/fakecloud-dynamodb/src/service/tests.rs b/crates/fakecloud-dynamodb/src/service/tests.rs index 07ba8c175..4dd73d3c0 100644 --- a/crates/fakecloud-dynamodb/src/service/tests.rs +++ b/crates/fakecloud-dynamodb/src/service/tests.rs @@ -7414,6 +7414,27 @@ async fn other_accounts_tables_are_not_found_without_cross_account_support() { assert_eq!(got["__type"], code, "{action}"); } + // A listing filtered by another account's table matches nothing. + for (action, body, field) in [ + ( + "ListExports", + json!({"TableArn": OWNER_ARN}), + "ExportSummaries", + ), + ( + "ListBackups", + json!({"TableName": OWNER_ARN}), + "BackupSummaries", + ), + ] { + let (status, got) = call_as(&svc, "123456789012", action, body).await; + assert_eq!(status, StatusCode::OK, "{action}: {got}"); + assert!( + got[field].as_array().is_none_or(|a| a.is_empty()), + "{action}: {got}" + ); + } + // Another region's table is not found, whoever owns it. for arn in [ "arn:aws:dynamodb:us-west-2:123456789012:table/Shared", From 8430580745a0983acfaf4b55b8c4e001b7408da7 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Mon, 14 Sep 2026 11:06:43 -0300 Subject: [PATCH 4/5] fix(dynamodb): serve only cross-account operations in the owner's account The owner-account switch now applies only to operations with cross-account support. ListBackups's TableName filter naming another account's table no longer switches the listing into that account. --- .../src/service/cross_account.rs | 36 +++++++++++++------ crates/fakecloud-dynamodb/src/service/mod.rs | 6 +++- .../fakecloud-dynamodb/src/service/tests.rs | 11 +++++- .../src/streams_dataplane.rs | 6 +++- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/crates/fakecloud-dynamodb/src/service/cross_account.rs b/crates/fakecloud-dynamodb/src/service/cross_account.rs index f0e2ece18..7476f2bcd 100644 --- a/crates/fakecloud-dynamodb/src/service/cross_account.rs +++ b/crates/fakecloud-dynamodb/src/service/cross_account.rs @@ -186,11 +186,20 @@ pub(crate) fn check_references( Ok(()) } -/// The account that owns the single table (or stream) an operation acts on, -/// when that is not the caller's: the request is then served in that account. -/// `None` for the caller's own resources and for batches and transactions, -/// which resolve each table's account separately. -pub(crate) fn single_resource_owner(req: &AwsRequest, body: &Value) -> Option { +/// The account that owns the single table (or stream) an operation with +/// cross-account support acts on, when that is not the caller's: the request +/// is then served in that account. `None` for the caller's own resources, for +/// batches and transactions (which resolve each table's account separately), +/// and for every operation without cross-account support, which is always +/// served in the caller's account. +pub(crate) fn single_resource_owner( + req: &AwsRequest, + body: &Value, + cross_account_operations: &[&str], +) -> Option { + if !cross_account_operations.contains(&req.action.as_str()) { + return None; + } let reference = match req.action.as_str() { "BatchGetItem" | "BatchWriteItem" | "TransactGetItems" | "TransactWriteItems" => { return None @@ -310,7 +319,7 @@ mod tests { fn a_single_foreign_resource_names_its_owner() { let owner = |action: &str, body: Value| { let req = request(action, body.clone()); - single_resource_owner(&req, &body) + single_resource_owner(&req, &body, CROSS_ACCOUNT_OPERATIONS) }; assert_eq!( owner("GetItem", json!({"TableName": FOREIGN})).as_deref(), @@ -328,17 +337,22 @@ mod tests { owner("TagResource", json!({"ResourceArn": FOREIGN})).as_deref(), Some("444455556666") ); + let body = json!({"ShardIterator": format!("{FOREIGN}/stream/x|shard|0")}); + let req = request("GetRecords", body.clone()); assert_eq!( - owner( - "GetRecords", - json!({"ShardIterator": format!("{FOREIGN}/stream/x|shard|0")}) - ) - .as_deref(), + single_resource_owner(&req, &body, STREAMS_CROSS_ACCOUNT_OPERATIONS).as_deref(), Some("444455556666") ); assert_eq!( owner("BatchGetItem", json!({"RequestItems": {FOREIGN: {}}})), None ); + // An operation without cross-account support stays in the caller's + // account, whatever table its filter names. + assert_eq!(owner("ListBackups", json!({"TableName": FOREIGN})), None); + assert_eq!( + owner("DescribeTimeToLive", json!({"TableName": FOREIGN})), + None + ); } } diff --git a/crates/fakecloud-dynamodb/src/service/mod.rs b/crates/fakecloud-dynamodb/src/service/mod.rs index 72a8e8dab..316a43c5a 100644 --- a/crates/fakecloud-dynamodb/src/service/mod.rs +++ b/crates/fakecloud-dynamodb/src/service/mod.rs @@ -452,7 +452,11 @@ impl AwsService for DynamoDbService { { let body = req.json_body(); cross_account::check_references(&req, &body, cross_account::CROSS_ACCOUNT_OPERATIONS)?; - if let Some(owner) = cross_account::single_resource_owner(&req, &body) { + if let Some(owner) = cross_account::single_resource_owner( + &req, + &body, + cross_account::CROSS_ACCOUNT_OPERATIONS, + ) { req.account_id = owner; } } diff --git a/crates/fakecloud-dynamodb/src/service/tests.rs b/crates/fakecloud-dynamodb/src/service/tests.rs index 4dd73d3c0..53ccdb660 100644 --- a/crates/fakecloud-dynamodb/src/service/tests.rs +++ b/crates/fakecloud-dynamodb/src/service/tests.rs @@ -7414,7 +7414,16 @@ async fn other_accounts_tables_are_not_found_without_cross_account_support() { assert_eq!(got["__type"], code, "{action}"); } - // A listing filtered by another account's table matches nothing. + // A listing filtered by another account's table matches nothing, even + // when that account holds backups and exports of it. + let (status, got) = call_as( + &svc, + OWNER, + "CreateBackup", + json!({"TableName": "Shared", "BackupName": "owners"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{got}"); for (action, body, field) in [ ( "ListExports", diff --git a/crates/fakecloud-dynamodb/src/streams_dataplane.rs b/crates/fakecloud-dynamodb/src/streams_dataplane.rs index 6fc9eca05..cc2732624 100644 --- a/crates/fakecloud-dynamodb/src/streams_dataplane.rs +++ b/crates/fakecloud-dynamodb/src/streams_dataplane.rs @@ -42,7 +42,11 @@ impl AwsService for DynamoDbStreamsService { &body, cross_account::STREAMS_CROSS_ACCOUNT_OPERATIONS, )?; - if let Some(owner) = cross_account::single_resource_owner(&req, &body) { + if let Some(owner) = cross_account::single_resource_owner( + &req, + &body, + cross_account::STREAMS_CROSS_ACCOUNT_OPERATIONS, + ) { req.account_id = owner; } match req.action.as_str() { From 00ad000e112a03bfdfeb626462e20e8b59cf49db Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Mon, 14 Sep 2026 11:16:34 -0300 Subject: [PATCH 5/5] fix(dynamodb): match a ListBackups table ARN filter by the whole ARN A TableName ARN naming another account's or region's table no longer matches the caller's same-named table's backups. --- .../fakecloud-dynamodb/src/service/tables.rs | 9 ++-- .../fakecloud-dynamodb/src/service/tests.rs | 45 ++++++++++++++----- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/fakecloud-dynamodb/src/service/tables.rs b/crates/fakecloud-dynamodb/src/service/tables.rs index 709a0d7cb..9d93e570f 100644 --- a/crates/fakecloud-dynamodb/src/service/tables.rs +++ b/crates/fakecloud-dynamodb/src/service/tables.rs @@ -1082,9 +1082,12 @@ impl DynamoDbService { let matched: Vec<(&str, Value)> = state .backups .values() - .filter(|b| { - table_name.is_none() - || table_name.map(super::resolve_table_name) == Some(b.table_name.as_str()) + // A table ARN filter matches that exact table: another account's + // or region's table of the same name is not this account's. + .filter(|b| match table_name { + None => true, + Some(name) if name.starts_with("arn:") => b.table_arn == name, + Some(name) => b.table_name == name, }) .filter(|b| match start { Some(s) => b.backup_arn.as_str() > s, diff --git a/crates/fakecloud-dynamodb/src/service/tests.rs b/crates/fakecloud-dynamodb/src/service/tests.rs index 53ccdb660..46261fe5c 100644 --- a/crates/fakecloud-dynamodb/src/service/tests.rs +++ b/crates/fakecloud-dynamodb/src/service/tests.rs @@ -7414,16 +7414,36 @@ async fn other_accounts_tables_are_not_found_without_cross_account_support() { assert_eq!(got["__type"], code, "{action}"); } - // A listing filtered by another account's table matches nothing, even - // when that account holds backups and exports of it. - let (status, got) = call_as( - &svc, - OWNER, - "CreateBackup", - json!({"TableName": "Shared", "BackupName": "owners"}), - ) - .await; - assert_eq!(status, StatusCode::OK, "{got}"); + // A listing filtered by another account's or region's table matches + // nothing, even when both accounts hold backups of a table of that name. + for account in [OWNER, "123456789012"] { + let (status, got) = call_as( + &svc, + account, + "CreateBackup", + json!({"TableName": "Shared", "BackupName": "b"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{got}"); + } + // The caller's own table still filters by name and by its own ARN. + for filter in [ + "Shared", + "arn:aws:dynamodb:us-east-1:123456789012:table/Shared", + ] { + let (_, got) = call_as( + &svc, + "123456789012", + "ListBackups", + json!({"TableName": filter}), + ) + .await; + assert_eq!( + got["BackupSummaries"].as_array().map(Vec::len), + Some(1), + "{filter}" + ); + } for (action, body, field) in [ ( "ListExports", @@ -7435,6 +7455,11 @@ async fn other_accounts_tables_are_not_found_without_cross_account_support() { json!({"TableName": OWNER_ARN}), "BackupSummaries", ), + ( + "ListBackups", + json!({"TableName": "arn:aws:dynamodb:us-west-2:123456789012:table/Shared"}), + "BackupSummaries", + ), ] { let (status, got) = call_as(&svc, "123456789012", action, body).await; assert_eq!(status, StatusCode::OK, "{action}: {got}");