Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions crates/server/src/authz_resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
86 changes: 86 additions & 0 deletions crates/server/src/authz_resource/restore_order_tests.rs
Original file line number Diff line number Diff line change
@@ -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<AuthzTarget> = 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:?}"),
}
}
}
9 changes: 6 additions & 3 deletions crates/server/src/authz_resource/supplied_resource_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand Down
3 changes: 2 additions & 1 deletion crates/server/src/request_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
7 changes: 5 additions & 2 deletions docs/manuals/05-admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ DynamoDB. The `Resource` element of a policy is matched field by field, and a
| ExportTableToPointInTime | the source `table/<name>` |
| ImportTable | the new `table/<name>` |
| RestoreTableFromBackup | the backup ARN, plus Scan, Query, GetItem, PutItem, UpdateItem, DeleteItem and BatchWriteItem on the target `table/<name>` |
| RestoreTableToPointInTime | the source `table/<name>`, plus the same item actions on the target `table/<name>` |
| RestoreTableToPointInTime | the same item actions on the target `table/<name>`, then the source `table/<name>` |
| ListTables | `table/*` |
| DescribeLimits | `*` |
| DescribeEndpoints | not authorized (signed request, no IAM permission) |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/manuals/07-upgrade-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
29 changes: 29 additions & 0 deletions tests/test_authz_resource_arn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading