diff --git a/crates/server/src/authz_resource.rs b/crates/server/src/authz_resource.rs index f7e0924ac..71a434cd3 100644 --- a/crates/server/src/authz_resource.rs +++ b/crates/server/src/authz_resource.rs @@ -60,6 +60,9 @@ const SERVICE: &str = "dynamodb"; /// The item actions the service requires on the target table of a restore, in /// addition to the restore action itself (measured live). A Deny on any of /// these for the target table name blocks a restore into it. +/// `RestoreTableToPointInTime` checks them, Scan first, before the restore +/// action: a caller with no policy is denied on `dynamodb:Scan` on the target +/// table, as on the service (measured 2026-10-22, issue #446). const RESTORE_TARGET_ACTIONS: &[&str] = &[ "Scan", "Query", @@ -670,15 +673,19 @@ pub(crate) fn authz_targets( v.extend(restore_target_actions(input, region, account_id)); v } - // RestoreTableToPointInTime requires dynamodb:RestoreTableToPointInTime - // on the source table and the item actions on the target table it - // writes (measured live). + // RestoreTableToPointInTime requires the item actions on the target + // table it writes and dynamodb:RestoreTableToPointInTime on the source + // table (measured live). The service checks the target's item actions + // first: a caller with no policy is denied on dynamodb:Scan on the + // target table, not on the restore action on the source (measured + // 2026-10-22 with an index SourceTableArn). "RestoreTableToPointInTime" => { let source = str_field(input, "SourceTableArn") .or_else(|| str_field(input, "SourceTableName")) .unwrap_or_default(); - let mut v = from(table_resource_arn(&source, region, account_id), &source); - v.extend(restore_target_actions(input, region, account_id)); + let restore = from(table_resource_arn(&source, region, account_id), &source); + let mut v = restore_target_actions(input, region, account_id); + v.extend(restore); v } // Query and Scan on an index authorize the index ARN. With a table ARN @@ -1477,3 +1484,7 @@ mod key_lookup_name_tests { /// The denial names the resource as the caller supplied it (issue #447). #[cfg(test)] mod supplied_resource_tests; + +/// A point-in-time restore authorizes its target before its source (issue #446). +#[cfg(test)] +mod restore_order_tests; diff --git a/crates/server/src/authz_resource/restore_order_tests.rs b/crates/server/src/authz_resource/restore_order_tests.rs new file mode 100644 index 000000000..1af3e6f9e --- /dev/null +++ b/crates/server/src/authz_resource/restore_order_tests.rs @@ -0,0 +1,86 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `RestoreTableToPointInTime` authorizes the target table's item actions +//! before the restore action on the source (issue #446). The service checks +//! them in that order: a caller with no policy is denied on `dynamodb:Scan` +//! on the target table (measured 2026-10-22 with an index `SourceTableArn`). + +use std::sync::Arc; + +use extenddb_auth::AuthIdentity; +use extenddb_auth::policy::context::RequestParams; +use extenddb_core::error::DynamoDbError; +use serde_json::{Value, json}; + +use super::{AuthzTarget, RESTORE_TARGET_ACTIONS, authz_targets}; +use crate::authorization::check_authorization; +use crate::authz_cache::CachedAuthzStore; +use crate::authz_cache::tests::{CountingAuthzStore, fast_authz_cfg}; + +const R: &str = "us-east-1"; +const A: &str = "444455556666"; +const SOURCE: &str = "arn:aws:dynamodb:us-east-1:444455556666:table/bb"; +const TARGET: &str = "arn:aws:dynamodb:us-east-1:444455556666:table/pitr"; + +/// The three ways a restore names its source: by name, by table ARN, and by +/// index ARN, the form the service was measured with. +fn inputs() -> [Value; 3] { + [ + json!({ "SourceTableName": "bb", "TargetTableName": "pitr" }), + json!({ "SourceTableArn": SOURCE, "TargetTableName": "pitr" }), + json!({ "SourceTableArn": format!("{SOURCE}/index/gsi1"), "TargetTableName": "pitr" }), + ] +} + +/// The seven item actions on the target table come first, Scan leading, and +/// the restore action on the source table last, however the source is named. +#[test] +fn the_target_item_actions_are_authorized_before_the_restore_action() { + let expected: Vec = RESTORE_TARGET_ACTIONS + .iter() + .map(|a| AuthzTarget::new(a, TARGET.to_owned())) + .collect(); + for input in inputs() { + let targets = authz_targets("RestoreTableToPointInTime", &input, R, A); + let (restore, items) = targets.split_last().expect("restore targets"); + assert_eq!(items, expected.as_slice(), "{input}"); + assert_eq!(items[0].action, "Scan", "{input}"); + assert_eq!(restore.action, "RestoreTableToPointInTime", "{input}"); + assert_eq!(restore.resource, SOURCE, "{input}"); + let named = input["SourceTableArn"].as_str().unwrap_or(SOURCE); + assert_eq!(restore.supplied, named, "{input}"); + } +} + +/// A caller with no policy is denied on `dynamodb:Scan` on the target table, +/// in the service's text, when the targets are checked in the order +/// `authorize_request` checks them, where the first denial is the answer. +#[tokio::test] +async fn a_caller_with_no_policy_is_denied_on_scan_on_the_target_table() { + let cache = CachedAuthzStore::new(Arc::new(CountingAuthzStore::default()), fast_authz_cfg()); + let identity = AuthIdentity::User { + account_id: A.to_owned(), + user_name: "u".to_owned(), + }; + let expected = format!( + "User: arn:aws:iam::{A}:user/u is not authorized to perform: dynamodb:Scan on \ + resource: {TARGET} because no identity-based policy allows the dynamodb:Scan action" + ); + for input in inputs() { + let mut denial = None; + for target in authz_targets("RestoreTableToPointInTime", &input, R, A) { + let checked = + check_authorization(&cache, &identity, &target, false, RequestParams::default()) + .await; + if let Err(e) = checked { + denial = Some(e); + break; + } + } + match denial { + Some(DynamoDbError::AccessDeniedException(m)) => assert_eq!(m, expected, "{input}"), + other => panic!("expected AccessDeniedException for {input}, got {other:?}"), + } + } +} diff --git a/crates/server/src/authz_resource/supplied_resource_tests.rs b/crates/server/src/authz_resource/supplied_resource_tests.rs index 8b3bfaed4..04e97a5aa 100644 --- a/crates/server/src/authz_resource/supplied_resource_tests.rs +++ b/crates/server/src/authz_resource/supplied_resource_tests.rs @@ -130,11 +130,14 @@ fn other_arn_fields_keep_the_supplied_spelling() { R, A, ); - assert_eq!(v[0].resource, CANON); - assert_eq!(v[0].supplied, respelled_table); + // The item actions on the target come first and the restore action on + // the source last (issue #446). + let (source, target) = v.split_last().expect("restore targets"); + assert_eq!(source.resource, CANON); + assert_eq!(source.supplied, respelled_table); let ptarget = "arn:aws:dynamodb:us-east-1:444455556666:table/pitr"; assert!( - v[1..] + target .iter() .all(|t| t.resource == ptarget && t.supplied == ptarget) ); diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 8c0d6e39c..7c2cd96e4 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -393,7 +393,8 @@ pub(crate) async fn authorize_request( } else { // Multi-permission operations (restore). Each pair is authorized with // the request-level params; a Deny on any required resource fails the - // request. + // request. The pairs are checked in the order `authz_targets` returns + // them, and the first denied pair is the one the denial names. for target in &targets { authorization::check_authorization( state.authz_cache.as_ref(), diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index e51da206c..4d21e63d3 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -579,7 +579,7 @@ DynamoDB. The `Resource` element of a policy is matched field by field, and a | ExportTableToPointInTime | the source `table/` | | ImportTable | the new `table/` | | RestoreTableFromBackup | the backup ARN, plus Scan, Query, GetItem, PutItem, UpdateItem, DeleteItem and BatchWriteItem on the target `table/` | -| RestoreTableToPointInTime | the source `table/`, plus the same item actions on the target `table/` | +| RestoreTableToPointInTime | the same item actions on the target `table/`, then the source `table/` | | ListTables | `table/*` | | DescribeLimits | `*` | | DescribeEndpoints | not authorized (signed request, no IAM permission) | @@ -646,7 +646,10 @@ upgrading: RestoreTableFromBackup and RestoreTableToPointInTime require the item write and read actions on the target table in addition to the restore action. A principal that could previously restore with only the restore permission now - also needs those actions on the target table name. + also needs those actions on the target table name. RestoreTableToPointInTime + checks the item actions on the target before the restore action, as DynamoDB + does, so a principal with no policy is denied naming `dynamodb:Scan` on the + target table. - **Denial messages.** An AccessDeniedException now ends with the clause the service uses: "with an explicit deny in an identity-based policy" when an explicit Deny matched the request, or "because no identity-based policy allows diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 147890c71..aa5762dcf 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -313,7 +313,7 @@ deployment, even when no catalog migration is required. ### v0.1.15: Point-in-time recovery is reported as unsupported (catalog migration required) -**What changed.** `UpdateContinuousBackups` no longer accepts an enable request, and `DescribeContinuousBackups` no longer reports point-in-time recovery `ENABLED` with a 35-day window. Point-in-time recovery is reported as unsupported: describe answers `PointInTimeRecoveryStatus: DISABLED`, an enable request returns `ContinuousBackupsUnavailableException`, and `RestoreTableToPointInTime` checks the source table and then returns `PointInTimeRecoveryUnavailableException`. A table that does not exist is `TableNotFoundException` on all three operations, where `DescribeContinuousBackups` and `UpdateContinuousBackups` answered `ResourceNotFoundException`, and `UpdateContinuousBackups` without `PointInTimeRecoverySpecification` is a `ValidationException`, where it was taken as disabling. See [Differences from DynamoDB](../differences-from-dynamodb.md) for the full surface. The catalog migration removes the storage of a per-table enable flag that only the description read. +**What changed.** `UpdateContinuousBackups` no longer accepts an enable request, and `DescribeContinuousBackups` no longer reports point-in-time recovery `ENABLED` with a 35-day window. Point-in-time recovery is reported as unsupported: describe answers `PointInTimeRecoveryStatus: DISABLED`, an enable request returns `ContinuousBackupsUnavailableException`, and `RestoreTableToPointInTime` checks the source table and then returns `PointInTimeRecoveryUnavailableException`. Authorization of `RestoreTableToPointInTime` checks the item actions on the target table before the restore action on the source, as the service does, so a caller with no policy is denied naming `dynamodb:Scan` on the target table, where earlier builds named `dynamodb:RestoreTableToPointInTime` on the source table. A table that does not exist is `TableNotFoundException` on all three operations, where `DescribeContinuousBackups` and `UpdateContinuousBackups` answered `ResourceNotFoundException`, and `UpdateContinuousBackups` without `PointInTimeRecoverySpecification` is a `ValidationException`, where it was taken as disabling. See [Differences from DynamoDB](../differences-from-dynamodb.md) for the full surface. The catalog migration removes the storage of a per-table enable flag that only the description read. **Who is affected.** Any client or infrastructure template that enables point-in-time recovery (for example a Terraform resource with `point_in_time_recovery = true`) now receives `ContinuousBackupsUnavailableException` and must stop requesting it. A restore attempt now receives `PointInTimeRecoveryUnavailableException` instead of a generic `ValidationException`. diff --git a/tests/test_authz_resource_arn.py b/tests/test_authz_resource_arn.py index 4d81c5955..e3e291643 100644 --- a/tests/test_authz_resource_arn.py +++ b/tests/test_authz_resource_arn.py @@ -609,6 +609,35 @@ def test_restore_to_point_in_time_deny_target_item_action_blocks(self, table): self._assert_denied(lambda: d.restore_table_to_point_in_time( SourceTableName=table["name"], TargetTableName=table["name"] + "-pitr")) + def test_restore_to_point_in_time_noperm_denial_names_scan_on_target(self, table): + # Measured on DynamoDB 2026-10-22 (issue #446): a caller with no policy + # and an index SourceTableArn is denied on dynamodb:Scan on the target + # table, because the service checks the target's item actions before + # the restore action on the source. + target_arn = table["table_arn"] + "-pitr446" + d = self._ddb({"Version": "2012-10-17", "Statement": []}) + msg = self._denial_message(lambda: d.restore_table_to_point_in_time( + SourceTableArn=table["index_arn"], TargetTableName=table["name"] + "-pitr446", + UseLatestRestorableTime=True)) + assert msg.startswith(f"User: arn:aws:iam::{self.account_id}:user/"), msg + assert msg.endswith( + f" is not authorized to perform: dynamodb:Scan on resource: {target_arn} " + "because no identity-based policy allows the dynamodb:Scan action" + ), msg + + def test_restore_to_point_in_time_index_source_is_invalid_after_authorization(self, table): + # Measured on DynamoDB 2026-10-22 (issue #446): the same request from a + # caller allowed dynamodb:* passes authorization and is refused because + # the source ARN names an index, not a table. + d = self._ddb(ALLOW_ALL) + with pytest.raises(ClientError) as exc: + d.restore_table_to_point_in_time( + SourceTableArn=table["index_arn"], TargetTableName=table["name"] + "-pitr446", + UseLatestRestorableTime=True) + err = exc.value.response["Error"] + assert (err["Code"], err["Message"]) == ( + "ValidationException", "Invalid Request: Table ARN is invalid."), err + def test_export_deny_table_blocks(self, table): d = self._ddb(_allow_all_deny("ExportTableToPointInTime", table["table_arn"])) self._assert_denied(lambda: d.export_table_to_point_in_time(