From 59fc1939cc395a17d858bf6787d84957ccfc53d4 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:09:55 +0000 Subject: [PATCH 1/5] fix(server): default dynamodb:Select, ReturnValues and ReturnConsumedCapacity ExtendDB set dynamodb:Select, dynamodb:ReturnValues and dynamodb:ReturnConsumedCapacity only from the request's own parameters, and only on requests with a single authorization target. When a request left the parameter out, the key was absent, and the batch and transaction targets never carried the keys. DynamoDB evaluates the value the operation uses instead (measured 2026-11-01): - dynamodb:Select is SPECIFIC_ATTRIBUTES on a GetItem, Query or Scan with a projection and ALL_ATTRIBUTES on one without, and the same on each table of a BatchGetItem and on each Get of a TransactGetItems. A Query or Scan with a Select holds it as given. - dynamodb:ReturnValues is NONE on a PutItem, UpdateItem or DeleteItem without ReturnValues, and on the Put, Update and Delete items of a TransactWriteItems. A BatchWriteItem leaves the key absent. - dynamodb:ReturnConsumedCapacity is NONE on a GetItem, a BatchWriteItem and the Get of a TransactGetItems without ReturnConsumedCapacity. With the key absent, a Deny on Select ALL_ATTRIBUTES did not refuse a read with no projection, which returns the whole item, and an Allow gated on ReturnValues NONE refused writes that DynamoDB grants. The parameter_keys module holds these values for a single-table request, for each table of a batch and for each transaction item, and the batch and transaction targets carry them, so two Gets of one partition that differ only in their projection are evaluated apart. A Query or Scan of an index with no projection and no Select holds ALL_PROJECTED_ATTRIBUTES, the default the DynamoDB API reference gives for a read of an index; that was not measured. dynamodb:ReturnConsumedCapacity defaults to NONE on the other item operations as on the measured ones. GetItem, BatchGetItem and TransactGetItems take no Select parameter, so a Select member in them no longer decides the key: the engine ignores the member and returns the whole item when nothing is projected. --- crates/server/src/leading_keys/batch.rs | 22 +- crates/server/src/lib.rs | 1 + crates/server/src/parameter_keys.rs | 160 +++++++ crates/server/src/parameter_keys/tests.rs | 491 ++++++++++++++++++++++ crates/server/src/request_helpers.rs | 37 +- 5 files changed, 686 insertions(+), 25 deletions(-) create mode 100644 crates/server/src/parameter_keys.rs create mode 100644 crates/server/src/parameter_keys/tests.rs diff --git a/crates/server/src/leading_keys/batch.rs b/crates/server/src/leading_keys/batch.rs index ca0eacec2..d0fc97403 100644 --- a/crates/server/src/leading_keys/batch.rs +++ b/crates/server/src/leading_keys/batch.rs @@ -11,11 +11,12 @@ use extenddb_core::types::TableKeyInfo; use serde_json::Value; use super::{NumberForm, hash_attr, pk_from_map}; +use crate::parameter_keys::{self, ParameterKeys}; use crate::request_attributes::{batch_table_attributes, transact_item_attributes}; /// A single `(action, table)` authorization to perform for a batch or -/// transaction operation, carrying the LeadingKeys and Attributes the service -/// would evaluate. +/// transaction operation, carrying the LeadingKeys, Attributes, Select, +/// ReturnValues and ReturnConsumedCapacity values the service would evaluate. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct AuthzTarget { /// The bare operation string the action maps to (no `dynamodb:` prefix). @@ -28,11 +29,14 @@ pub(crate) struct AuthzTarget { /// The `dynamodb:Attributes` names for this authorization, or `None` when /// it names no attribute. See `crate::request_attributes`. pub attributes: Option>, + /// The `dynamodb:Select`, `dynamodb:ReturnValues` and + /// `dynamodb:ReturnConsumedCapacity` values. See `crate::parameter_keys`. + pub parameters: ParameterKeys, } /// The per-(action, table) or per-item authorizations for a batch or -/// transaction operation, each carrying the LeadingKeys and Attributes the -/// service evaluates. +/// transaction operation, each carrying the condition key values the service +/// evaluates. /// /// Returns `None` for non-batch/transact operations. Batch operations /// authorize once per table with the set of all keys for that table; @@ -41,9 +45,10 @@ pub(crate) struct AuthzTarget { /// two (a batch of one own and one other key passes `ForAnyValue`; a /// transaction of the same two does not). Transaction items that give the same /// target are returned once, in the order of their first occurrence: the -/// decision for a target depends only on its action, table, LeadingKeys and -/// Attributes, so a hundred identical `ConditionCheck`s on one partition need -/// one evaluation. +/// decision for a target depends only on its action, table and condition key +/// values, so a hundred identical `ConditionCheck`s on one partition need one +/// evaluation. Two `Get`s of one partition that differ only in whether they +/// project give two targets, since their `dynamodb:Select` values differ. /// /// `key_info_by_table` is the authorization snapshot; a `None` entry is a table /// that does not exist, whose targets carry no LeadingKeys and none of the @@ -79,6 +84,7 @@ pub(crate) fn batch_transact_targets( table: table.clone(), leading_keys: keys, attributes: batch_table_attributes(operation, body, key_info_of(table), limits), + parameters: parameter_keys::batch_table(operation, body, input), }); } Some(out) @@ -108,6 +114,7 @@ pub(crate) fn batch_transact_targets( key_info_of(table), limits, ), + parameters: parameter_keys::transact_item("Get", get, input), }, ); } @@ -145,6 +152,7 @@ pub(crate) fn batch_transact_targets( key_info_of(table), limits, ), + parameters: parameter_keys::transact_item(sub, op, input), }, ); } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index a71535ec8..bfec0b28a 100755 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -19,6 +19,7 @@ pub mod key_info_cache; mod leading_keys; pub mod management; mod metrics_endpoint; +mod parameter_keys; pub mod rate_limit; mod request_attributes; mod request_helpers; diff --git a/crates/server/src/parameter_keys.rs b/crates/server/src/parameter_keys.rs new file mode 100644 index 000000000..512604d3f --- /dev/null +++ b/crates/server/src/parameter_keys.rs @@ -0,0 +1,160 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `dynamodb:Select`, `dynamodb:ReturnValues` and +//! `dynamodb:ReturnConsumedCapacity` for request authorization. +//! +//! Each key holds the value of the request parameter it is named after. When a +//! request that takes the parameter leaves it out, the service evaluates the +//! value the operation then uses, so the key is present whether or not the +//! caller spells that value out (measured 2026-11-01): +//! +//! - `dynamodb:Select` on `GetItem`, `Query`, `Scan`, each table of a +//! `BatchGetItem` and each `Get` of a `TransactGetItems`: +//! `SPECIFIC_ATTRIBUTES` for a read with a projection (`ProjectionExpression` +//! or `AttributesToGet`), `ALL_ATTRIBUTES` for a read of a table without one, +//! and the `Select` of a `Query` or `Scan` that has one. `GetItem`, +//! `BatchGetItem` and `TransactGetItems` take no `Select` parameter, so a +//! `Select` member in one of them is not read: the engine ignores it and +//! returns what the projection asks for. +//! - `dynamodb:ReturnValues` on `PutItem`, `UpdateItem` and `DeleteItem`: the +//! request's `ReturnValues`, or `NONE`. The `Put`, `Update` and `Delete` items +//! of a `TransactWriteItems` take no `ReturnValues` and hold `NONE`. A +//! `BatchWriteItem` and a `ConditionCheck` leave the key absent. +//! - `dynamodb:ReturnConsumedCapacity` on the item operations: the request's +//! `ReturnConsumedCapacity`, or `NONE`. A batch or transaction request carries +//! one value, and each of its tables or items is evaluated with it. +//! +//! Measured on 2026-11-01: `dynamodb:Select` on `GetItem` and `Query` with and +//! without a projection, on `Scan` without one and with `Select` `COUNT`, on +//! `Query` with `Select` `COUNT`, and on `BatchGetItem` and a `TransactGetItems` +//! `Get` with and without a projection; `dynamodb:ReturnValues` on `PutItem`, +//! `UpdateItem` and `DeleteItem` with and without the parameter, on a +//! `BatchWriteItem`, and on the `Put`, `Update` and `Delete` items of a +//! `TransactWriteItems`; `dynamodb:ReturnConsumedCapacity` on `GetItem` with and +//! without the parameter, on a `BatchWriteItem` and on a `TransactGetItems` +//! `Get`. `dynamodb:ReturnConsumedCapacity` on the other item operations follows +//! those. A read that projects with `AttributesToGet` is treated as one with a +//! `ProjectionExpression`, which the engine reads it as; that was not measured. +//! A `Query` or `Scan` of an index with no projection and no `Select` holds +//! `ALL_PROJECTED_ATTRIBUTES`, the default the DynamoDB API reference gives for +//! a read of an index; that was not measured either. +//! +//! A key the operation does not take holds the request's parameter as given, +//! as before: a `Select` member in a `PutItem` reaches the policy as written. +//! The batch and transaction targets carry only the keys their operation takes. + +use serde_json::Value; + +/// `dynamodb:Select` for a read with a projection and no `Select`. +const SPECIFIC_ATTRIBUTES: &str = "SPECIFIC_ATTRIBUTES"; +/// `dynamodb:Select` for a read of a table with neither. +const ALL_ATTRIBUTES: &str = "ALL_ATTRIBUTES"; +/// `dynamodb:Select` for a `Query` or `Scan` of an index with neither (the API +/// reference's default, not measured). +const ALL_PROJECTED_ATTRIBUTES: &str = "ALL_PROJECTED_ATTRIBUTES"; +/// `dynamodb:ReturnValues` and `dynamodb:ReturnConsumedCapacity` for a request +/// that takes the parameter and leaves it out. +const NONE: &str = "NONE"; + +/// The `dynamodb:Select`, `dynamodb:ReturnValues` and +/// `dynamodb:ReturnConsumedCapacity` values of one authorization. `None` +/// leaves a key absent. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub(crate) struct ParameterKeys { + pub select: Option, + pub return_values: Option, + pub return_consumed_capacity: Option, +} + +/// The keys of a request authorized once, against one resource. +pub(crate) fn single_table(operation: &str, input: &Value) -> ParameterKeys { + let mut keys = ParameterKeys { + select: given(input, "Select"), + return_values: given(input, "ReturnValues"), + return_consumed_capacity: given(input, "ReturnConsumedCapacity"), + }; + match operation { + "GetItem" => keys.select = Some(projection_select(input).to_owned()), + "Query" | "Scan" => { + keys.select = Some( + keys.select + .unwrap_or_else(|| range_read_select(input).to_owned()), + ); + } + "PutItem" | "UpdateItem" | "DeleteItem" => { + keys.return_values = Some(keys.return_values.unwrap_or_else(|| NONE.to_owned())); + } + _ => return keys, + } + keys.return_consumed_capacity = Some(consumed_capacity(input)); + keys +} + +/// The keys of one table's part of a `BatchGetItem` or `BatchWriteItem`: +/// `body` is that table's `RequestItems` entry and `request` the whole +/// request, which carries `ReturnConsumedCapacity`. +pub(crate) fn batch_table(operation: &str, body: &Value, request: &Value) -> ParameterKeys { + ParameterKeys { + select: (operation == "BatchGetItem").then(|| projection_select(body).to_owned()), + return_values: None, + return_consumed_capacity: Some(consumed_capacity(request)), + } +} + +/// The keys of one transaction item: `sub` is the item's member name (`Get`, +/// `Put`, `Update`, `Delete` or `ConditionCheck`), `item` its body and +/// `request` the whole request, which carries `ReturnConsumedCapacity`. +pub(crate) fn transact_item(sub: &str, item: &Value, request: &Value) -> ParameterKeys { + ParameterKeys { + select: (sub == "Get").then(|| projection_select(item).to_owned()), + return_values: matches!(sub, "Put" | "Update" | "Delete").then(|| NONE.to_owned()), + return_consumed_capacity: Some(consumed_capacity(request)), + } +} + +/// The request's own value of `parameter`, when it is a string. A `null` is +/// absent, as the engine reads it. +fn given(request: &Value, parameter: &str) -> Option { + request + .get(parameter) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +/// Whether a read projects. The engine projects whenever the request carries +/// `ProjectionExpression` or `AttributesToGet`, and refuses an empty one, so a +/// read without either returns every attribute. A `null` member is absent. +fn has_projection(read: &Value) -> bool { + read.get("ProjectionExpression") + .is_some_and(Value::is_string) + || read.get("AttributesToGet").is_some_and(Value::is_array) +} + +/// `dynamodb:Select` of a read that takes no `Select` parameter. +fn projection_select(read: &Value) -> &'static str { + if has_projection(read) { + SPECIFIC_ATTRIBUTES + } else { + ALL_ATTRIBUTES + } +} + +/// `dynamodb:Select` of a `Query` or `Scan` with no `Select`. +fn range_read_select(read: &Value) -> &'static str { + if has_projection(read) { + SPECIFIC_ATTRIBUTES + } else if read.get("IndexName").is_some_and(Value::is_string) { + ALL_PROJECTED_ATTRIBUTES + } else { + ALL_ATTRIBUTES + } +} + +/// `dynamodb:ReturnConsumedCapacity` of a request that takes the parameter. +fn consumed_capacity(request: &Value) -> String { + given(request, "ReturnConsumedCapacity").unwrap_or_else(|| NONE.to_owned()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/server/src/parameter_keys/tests.rs b/crates/server/src/parameter_keys/tests.rs new file mode 100644 index 000000000..74a6baa68 --- /dev/null +++ b/crates/server/src/parameter_keys/tests.rs @@ -0,0 +1,491 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Unit tests for the `dynamodb:Select`, `dynamodb:ReturnValues` and +//! `dynamodb:ReturnConsumedCapacity` values. +//! +//! The defaults mirror DynamoDB's answers of 2026-11-01; the module +//! documentation lists which were measured and which follow them. + +use std::collections::HashMap; + +use extenddb_core::limits::LimitsConfig; +use extenddb_core::types::{KeySchemaElement, KeyType, TableKeyInfo}; +use serde_json::{Value, json}; + +use super::*; + +fn keys(select: Option<&str>, return_values: Option<&str>, rcc: Option<&str>) -> ParameterKeys { + ParameterKeys { + select: select.map(ToOwned::to_owned), + return_values: return_values.map(ToOwned::to_owned), + return_consumed_capacity: rcc.map(ToOwned::to_owned), + } +} + +/// `base` with the members of `extra` added. +fn with(base: &Value, extra: &Value) -> Value { + let mut out = base.clone(); + for (k, v) in extra.as_object().expect("extra is an object") { + out[k] = v.clone(); + } + out +} + +fn read() -> Value { + json!({"TableName": "t", "Key": {"pk": {"S": "a"}}}) +} + +fn projected() -> Value { + json!({"ProjectionExpression": "#n", "ExpressionAttributeNames": {"#n": "name"}}) +} + +fn select_of(operation: &str, input: &Value) -> Option { + single_table(operation, input).select +} + +// -- dynamodb:Select ---------------------------------------------------------- + +#[test] +fn get_item_selects_from_its_projection() { + assert_eq!( + select_of("GetItem", &read()).as_deref(), + Some("ALL_ATTRIBUTES") + ); + assert_eq!( + select_of("GetItem", &with(&read(), &projected())).as_deref(), + Some("SPECIFIC_ATTRIBUTES") + ); + assert_eq!( + select_of( + "GetItem", + &with(&read(), &json!({"AttributesToGet": ["name"]})) + ) + .as_deref(), + Some("SPECIFIC_ATTRIBUTES") + ); +} + +#[test] +fn get_item_does_not_read_a_select_member() { + // GetItem takes no Select: the engine ignores the member and returns the + // whole item, so the member must not decide the key. + let unprojected = with(&read(), &json!({"Select": "SPECIFIC_ATTRIBUTES"})); + assert_eq!( + select_of("GetItem", &unprojected).as_deref(), + Some("ALL_ATTRIBUTES") + ); + let projected = with(&with(&read(), &projected()), &json!({"Select": "COUNT"})); + assert_eq!( + select_of("GetItem", &projected).as_deref(), + Some("SPECIFIC_ATTRIBUTES") + ); +} + +#[test] +fn a_null_projection_is_no_projection() { + // serde reads a null member as absent, so the engine returns every attribute. + let nulls = with( + &read(), + &json!({"ProjectionExpression": null, "AttributesToGet": null, "Select": null}), + ); + for operation in ["GetItem", "Query", "Scan"] { + assert_eq!( + select_of(operation, &nulls).as_deref(), + Some("ALL_ATTRIBUTES"), + "{operation}" + ); + } + let body = json!({"Keys": [{"pk": {"S": "a"}}], "ProjectionExpression": null}); + assert_eq!( + batch_table("BatchGetItem", &body, &json!({})) + .select + .as_deref(), + Some("ALL_ATTRIBUTES") + ); + let get = json!({"TableName": "t", "Key": {"pk": {"S": "a"}}, "ProjectionExpression": null}); + assert_eq!( + transact_item("Get", &get, &json!({})).select.as_deref(), + Some("ALL_ATTRIBUTES") + ); +} + +#[test] +fn query_and_scan_default_select_from_the_projection_and_the_index() { + let index = json!({"IndexName": "gsi"}); + for operation in ["Query", "Scan"] { + let cases = [ + (read(), "ALL_ATTRIBUTES"), + (with(&read(), &projected()), "SPECIFIC_ATTRIBUTES"), + ( + with(&read(), &json!({"AttributesToGet": ["name"]})), + "SPECIFIC_ATTRIBUTES", + ), + (with(&read(), &index), "ALL_PROJECTED_ATTRIBUTES"), + ( + with(&with(&read(), &index), &projected()), + "SPECIFIC_ATTRIBUTES", + ), + (with(&read(), &json!({"IndexName": null})), "ALL_ATTRIBUTES"), + ]; + for (input, expected) in cases { + assert_eq!( + select_of(operation, &input).as_deref(), + Some(expected), + "{operation} {input}" + ); + } + } +} + +#[test] +fn query_and_scan_keep_a_given_select() { + let index_and_projection = with(&with(&read(), &json!({"IndexName": "gsi"})), &projected()); + for operation in ["Query", "Scan"] { + for given in [ + "COUNT", + "ALL_ATTRIBUTES", + "ALL_PROJECTED_ATTRIBUTES", + "SPECIFIC_ATTRIBUTES", + ] { + for base in [read(), index_and_projection.clone()] { + let input = with(&base, &json!({"Select": given})); + assert_eq!( + select_of(operation, &input).as_deref(), + Some(given), + "{operation} {input}" + ); + } + } + } +} + +// -- dynamodb:ReturnValues ---------------------------------------------------- + +#[test] +fn single_item_writes_default_return_values_to_none() { + for operation in ["PutItem", "UpdateItem", "DeleteItem"] { + for input in [read(), with(&read(), &json!({"ReturnValues": null}))] { + assert_eq!( + single_table(operation, &input).return_values.as_deref(), + Some("NONE"), + "{operation} {input}" + ); + } + } +} + +#[test] +fn single_item_writes_keep_given_return_values() { + for operation in ["PutItem", "UpdateItem", "DeleteItem"] { + for given in ["ALL_OLD", "ALL_NEW", "UPDATED_OLD", "UPDATED_NEW", "NONE"] { + let input = with(&read(), &json!({"ReturnValues": given})); + assert_eq!( + single_table(operation, &input).return_values.as_deref(), + Some(given), + "{operation} {given}" + ); + } + } +} + +// -- dynamodb:ReturnConsumedCapacity ----------------------------------------- + +#[test] +fn item_operations_default_return_consumed_capacity_to_none() { + for operation in [ + "GetItem", + "PutItem", + "UpdateItem", + "DeleteItem", + "Query", + "Scan", + ] { + assert_eq!( + single_table(operation, &read()) + .return_consumed_capacity + .as_deref(), + Some("NONE"), + "{operation}" + ); + for given in ["TOTAL", "INDEXES", "NONE"] { + let input = with(&read(), &json!({"ReturnConsumedCapacity": given})); + assert_eq!( + single_table(operation, &input) + .return_consumed_capacity + .as_deref(), + Some(given), + "{operation} {given}" + ); + } + } +} + +// -- keys an operation does not take ------------------------------------------- + +#[test] +fn a_key_the_operation_does_not_take_holds_the_member_as_given() { + let all = json!({"Select": "COUNT", "ReturnValues": "ALL_OLD"}); + for operation in ["PutItem", "UpdateItem", "DeleteItem"] { + assert_eq!(select_of(operation, &read()), None, "{operation}"); + assert_eq!( + select_of(operation, &with(&read(), &all)).as_deref(), + Some("COUNT") + ); + } + for operation in ["GetItem", "Query", "Scan"] { + assert_eq!(single_table(operation, &read()).return_values, None); + assert_eq!( + single_table(operation, &with(&read(), &all)) + .return_values + .as_deref(), + Some("ALL_OLD"), + "{operation}" + ); + } +} + +#[test] +fn other_operations_get_no_default() { + let given = json!({ + "TableName": "t", + "Select": "COUNT", + "ReturnValues": "ALL_OLD", + "ReturnConsumedCapacity": "TOTAL", + }); + for operation in [ + "DescribeTable", + "CreateTable", + "ListTables", + "SearchVectors", + ] { + assert_eq!( + single_table(operation, &json!({"TableName": "t"})), + ParameterKeys::default(), + "{operation}" + ); + assert_eq!( + single_table(operation, &given), + keys(Some("COUNT"), Some("ALL_OLD"), Some("TOTAL")), + "{operation}" + ); + } +} + +// -- batch and transaction parts ------------------------------------------------- + +#[test] +fn batch_get_item_tables_select_from_their_own_projection() { + let keys_only = json!({"Keys": [{"pk": {"S": "a"}}]}); + let request = json!({"RequestItems": {"t": keys_only}}); + assert_eq!( + batch_table("BatchGetItem", &keys_only, &request), + keys(Some("ALL_ATTRIBUTES"), None, Some("NONE")) + ); + for projection in [projected(), json!({"AttributesToGet": ["name"]})] { + assert_eq!( + batch_table("BatchGetItem", &with(&keys_only, &projection), &request).select, + Some("SPECIFIC_ATTRIBUTES".to_owned()), + "{projection}" + ); + } + let with_select = with(&keys_only, &json!({"Select": "SPECIFIC_ATTRIBUTES"})); + assert_eq!( + batch_table("BatchGetItem", &with_select, &request) + .select + .as_deref(), + Some("ALL_ATTRIBUTES"), + "a table part takes no Select" + ); + let total = with(&request, &json!({"ReturnConsumedCapacity": "TOTAL"})); + assert_eq!( + batch_table("BatchGetItem", &keys_only, &total) + .return_consumed_capacity + .as_deref(), + Some("TOTAL") + ); +} + +#[test] +fn batch_write_item_tables_carry_only_return_consumed_capacity() { + let body = json!([{"PutRequest": {"Item": {"pk": {"S": "a"}}}}]); + let request = json!({"RequestItems": {"t": body}}); + assert_eq!( + batch_table("BatchWriteItem", &body, &request), + keys(None, None, Some("NONE")) + ); + let total = with(&request, &json!({"ReturnConsumedCapacity": "TOTAL"})); + assert_eq!( + batch_table("BatchWriteItem", &body, &total), + keys(None, None, Some("TOTAL")) + ); +} + +#[test] +fn transaction_gets_select_from_their_own_projection() { + let get = json!({"TableName": "t", "Key": {"pk": {"S": "a"}}}); + assert_eq!( + transact_item("Get", &get, &json!({})), + keys(Some("ALL_ATTRIBUTES"), None, Some("NONE")) + ); + assert_eq!( + transact_item("Get", &with(&get, &projected()), &json!({})) + .select + .as_deref(), + Some("SPECIFIC_ATTRIBUTES") + ); + let total = json!({"ReturnConsumedCapacity": "TOTAL"}); + assert_eq!( + transact_item("Get", &get, &total) + .return_consumed_capacity + .as_deref(), + Some("TOTAL") + ); +} + +#[test] +fn transaction_writes_hold_return_values_none() { + let item = json!({"TableName": "t", "Key": {"pk": {"S": "a"}}, "ReturnValues": "ALL_OLD"}); + for sub in ["Put", "Update", "Delete"] { + // A transaction item takes no ReturnValues; the engine refuses the member. + assert_eq!( + transact_item(sub, &item, &json!({})), + keys(None, Some("NONE"), Some("NONE")), + "{sub}" + ); + } + assert_eq!( + transact_item("ConditionCheck", &item, &json!({})), + keys(None, None, Some("NONE")) + ); + let total = json!({"ReturnConsumedCapacity": "INDEXES"}); + for sub in ["Put", "Update", "Delete", "ConditionCheck"] { + assert_eq!( + transact_item(sub, &item, &total) + .return_consumed_capacity + .as_deref(), + Some("INDEXES"), + "{sub}" + ); + } +} + +// -- the batch and transaction targets ------------------------------------------ + +fn table(hash: &str, range: Option<&str>) -> TableKeyInfo { + let mut key_schema = vec![KeySchemaElement { + attribute_name: hash.to_owned(), + key_type: KeyType::Hash, + }]; + if let Some(range) = range { + key_schema.push(KeySchemaElement { + attribute_name: range.to_owned(), + key_type: KeyType::Range, + }); + } + TableKeyInfo { + table_name: "t".to_owned(), + account_id: "123456789012".to_owned(), + base_key_schema: key_schema.clone(), + key_schema, + ..Default::default() + } +} + +fn targets(operation: &str, input: &Value, ki: TableKeyInfo) -> Vec<(String, ParameterKeys)> { + let snapshot = HashMap::from([("t".to_owned(), Some(ki))]); + crate::leading_keys::batch_transact_targets( + operation, + input, + &snapshot, + &LimitsConfig::default(), + ) + .expect("a batch or transaction operation") + .into_iter() + .map(|t| (t.action, t.parameters)) + .collect() +} + +#[test] +fn batch_and_transaction_targets_carry_the_keys() { + let key = json!({"pk": {"S": "a"}}); + let bg = json!({ + "RequestItems": {"t": {"Keys": [key], "ProjectionExpression": "pk"}}, + "ReturnConsumedCapacity": "TOTAL", + }); + assert_eq!( + targets("BatchGetItem", &bg, table("pk", None)), + [( + "BatchGetItem".to_owned(), + keys(Some("SPECIFIC_ATTRIBUTES"), None, Some("TOTAL")) + )] + ); + let bw = json!({"RequestItems": {"t": [{"DeleteRequest": {"Key": key}}]}}); + assert_eq!( + targets("BatchWriteItem", &bw, table("pk", None)), + [("BatchWriteItem".to_owned(), keys(None, None, Some("NONE")))] + ); + let tg = json!({"TransactItems": [{"Get": {"TableName": "t", "Key": key}}]}); + assert_eq!( + targets("TransactGetItems", &tg, table("pk", None)), + [( + "GetItem".to_owned(), + keys(Some("ALL_ATTRIBUTES"), None, Some("NONE")) + )] + ); + let tw = json!({"TransactItems": [ + {"Put": {"TableName": "t", "Item": {"pk": {"S": "p"}}}}, + {"Update": {"TableName": "t", "Key": {"pk": {"S": "u"}}, "UpdateExpression": "SET n = :v", + "ExpressionAttributeValues": {":v": {"S": "x"}}}}, + {"Delete": {"TableName": "t", "Key": {"pk": {"S": "d"}}}}, + {"ConditionCheck": {"TableName": "t", "Key": {"pk": {"S": "c"}}, + "ConditionExpression": "attribute_exists(pk)"}}, + ]}); + assert_eq!( + targets("TransactWriteItems", &tw, table("pk", None)), + [ + ("PutItem".to_owned(), keys(None, Some("NONE"), Some("NONE"))), + ( + "UpdateItem".to_owned(), + keys(None, Some("NONE"), Some("NONE")) + ), + ( + "DeleteItem".to_owned(), + keys(None, Some("NONE"), Some("NONE")) + ), + ( + "ConditionCheckItem".to_owned(), + keys(None, None, Some("NONE")) + ), + ] + ); +} + +#[test] +fn transaction_gets_that_differ_only_in_select_are_evaluated_apart() { + // Two Gets of one partition on a table keyed on (pk, sk): the first + // projects pk, the second projects nothing. Both hold LeadingKeys ["a"] + // and Attributes ["pk", "sk"], so only dynamodb:Select tells them apart, + // and a Deny on ALL_ATTRIBUTES must see the second. + let tg = json!({"TransactItems": [ + {"Get": {"TableName": "t", "Key": {"pk": {"S": "a"}, "sk": {"S": "1"}}, + "ProjectionExpression": "pk"}}, + {"Get": {"TableName": "t", "Key": {"pk": {"S": "a"}, "sk": {"S": "2"}}}}, + ]}); + let snapshot = HashMap::from([("t".to_owned(), Some(table("pk", Some("sk"))))]); + let all = crate::leading_keys::batch_transact_targets( + "TransactGetItems", + &tg, + &snapshot, + &LimitsConfig::default(), + ) + .expect("a transaction"); + assert_eq!(all.len(), 2, "{all:?}"); + assert_eq!(all[0].leading_keys, all[1].leading_keys); + assert_eq!(all[0].attributes, all[1].attributes); + assert_eq!( + all[0].parameters.select.as_deref(), + Some("SPECIFIC_ATTRIBUTES") + ); + assert_eq!(all[1].parameters.select.as_deref(), Some("ALL_ATTRIBUTES")); +} diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 590739b46..ec5ab6e3e 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -265,12 +265,15 @@ pub(crate) async fn authorize_request( // the set of keys that table receives; transaction operations authorize // once per item with that item's key. Each target carries the // `dynamodb:LeadingKeys` value(s) the service evaluates, extracted per the - // table's key schema, and the `dynamodb:Attributes` names of that table's - // part of the batch or of that item (see `request_attributes`). The + // table's key schema, the `dynamodb:Attributes` names of that table's + // part of the batch or of that item (see `request_attributes`), and its + // `dynamodb:Select`, `dynamodb:ReturnValues` and + // `dynamodb:ReturnConsumedCapacity` values (see `parameter_keys`). The // resource ARN per target is unchanged: each is still that table's ARN. - // (Measured against live DynamoDB 2026-10-20 and 2026-10-31.) Targets with - // the same (action, table, LeadingKeys, Attributes) are evaluated once, - // since the decision depends only on those four for a given identity. + // (Measured against live DynamoDB 2026-10-20, 2026-10-31 and 2026-11-01.) + // Targets with the same action, table and condition key values are + // evaluated once, since the decision depends only on those for a given + // identity. if let Some(tables) = crate::leading_keys::batch_transact_tables(operation, input) { // A request outside the operation's count limits is refused before any // key info is read or any policy is evaluated, with the engine's own @@ -331,6 +334,9 @@ pub(crate) async fn authorize_request( let params = extenddb_auth::policy::context::RequestParams { leading_keys: target.leading_keys, attributes: target.attributes, + select: target.parameters.select, + return_values: target.parameters.return_values, + return_consumed_capacity: target.parameters.return_consumed_capacity, ..base_params(request_tags.clone(), request_tag_keys.clone()) }; authorization::check_authorization( @@ -357,7 +363,11 @@ pub(crate) async fn authorize_request( if targets.len() == 1 { // Single-resource operations carry the request params the policy engine - // reads for item-level condition keys. + // reads for item-level condition keys. `dynamodb:Select`, + // `dynamodb:ReturnValues` and `dynamodb:ReturnConsumedCapacity` hold + // the value the operation uses when the request leaves the parameter + // out (see `parameter_keys`). + let parameters = crate::parameter_keys::single_table(operation, input); let params = extenddb_auth::policy::context::RequestParams { leading_keys: crate::leading_keys::single_table_leading_keys( input, @@ -371,18 +381,9 @@ pub(crate) async fn authorize_request( key_info.as_ref(), &state.limits, ), - select: input - .get("Select") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned), - return_values: input - .get("ReturnValues") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned), - return_consumed_capacity: input - .get("ReturnConsumedCapacity") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned), + select: parameters.select, + return_values: parameters.return_values, + return_consumed_capacity: parameters.return_consumed_capacity, ..base_params(request_tags, request_tag_keys) }; let (action, resource_arn) = &targets[0]; From 903b433a3481788260b6201eea7926d0e9d8d33d Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:27:22 +0000 Subject: [PATCH 2/5] test: cover the Select, ReturnValues and ReturnConsumedCapacity defaults Wire tests for the rows DynamoDB answered on 2026-11-01 for dynamodb:Select, dynamodb:ReturnValues and dynamodb:ReturnConsumedCapacity on requests that leave the parameter out: - A Deny of GetItem or Query with StringNotEqualsIfExists over SPECIFIC_ATTRIBUTES lets a read with a projection through and refuses one without. - A Deny of GetItem, Query and Scan with StringEquals over ALL_ATTRIBUTES refuses a read with no projection and lets a projected GetItem and a Select COUNT Scan or Query through. The same holds for each table of a BatchGetItem and for the Get of a TransactGetItems, and an Allow of BatchGetItem gated on SPECIFIC_ATTRIBUTES grants a projected one. - An Allow gated on ReturnValues NONE grants a PutItem, UpdateItem or DeleteItem without ReturnValues and the Put, Update and Delete items of a TransactWriteItems, refuses the single-item writes that ask for ALL_OLD or ALL_NEW, and refuses a BatchWriteItem. - An Allow gated on ReturnConsumedCapacity NONE grants a GetItem, a BatchWriteItem and a TransactGetItems without ReturnConsumedCapacity, and refuses a GetItem that asks for TOTAL. Each refused write is checked to leave the table unchanged. Two rows were not measured, and their docstrings say so. A Query of a global secondary index with no projection and no Select holds ALL_PROJECTED_ATTRIBUTES, as the API reference gives for a read of an index, so a Deny on ALL_PROJECTED_ATTRIBUTES refuses it and a Deny on ALL_ATTRIBUTES does not. Two Gets of one partition in a TransactGetItems that differ only in their projection are evaluated apart, following the measured TransactGetItems rows. --- tests/test_dynamodb_condition_key_defaults.py | 487 ++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 tests/test_dynamodb_condition_key_defaults.py diff --git a/tests/test_dynamodb_condition_key_defaults.py b/tests/test_dynamodb_condition_key_defaults.py new file mode 100644 index 000000000..7e3c81821 --- /dev/null +++ b/tests/test_dynamodb_condition_key_defaults.py @@ -0,0 +1,487 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""dynamodb:Select, dynamodb:ReturnValues and dynamodb:ReturnConsumedCapacity +when a request leaves the parameter out. + +DynamoDB evaluates the value the operation uses when the request carries no +parameter, so the key is present whether or not the caller spells it out: + + * dynamodb:Select is SPECIFIC_ATTRIBUTES on a GetItem, Query or Scan with a + projection and ALL_ATTRIBUTES on one without, and the same on each table + of a BatchGetItem and on the Get of a TransactGetItems. A Select the + request gives is used as given. + * dynamodb:ReturnValues is NONE on a PutItem, UpdateItem or DeleteItem + without ReturnValues and on the Put, Update and Delete items of a + TransactWriteItems. A BatchWriteItem leaves the key absent. + * dynamodb:ReturnConsumedCapacity is NONE on a GetItem, a BatchWriteItem and + the Get of a TransactGetItems without ReturnConsumedCapacity. + +Unless its docstring says otherwise, every request shape here was sent to live +DynamoDB on 2026-11-01 under a session policy of the same shape as the user +policy the test attaches, twice, with the same answer both times. The fixtures +are those of test_dynamodb_attributes_condition.py, plus a table keyed on +(pk, sk) with a global secondary index for the two rows that were not +measured. + +Prerequisites and run command: as for test_dynamodb_attributes_condition.py. + +REQ-TEST-001, REQ-AUTH-002 +""" + +from __future__ import annotations + +import json +import uuid +from typing import Any, Callable + +import pytest +from botocore.exceptions import ClientError + +from conftest import wait_for_active + +# The fixtures are imported so this module gets its own account and table. +from test_dynamodb_attributes_condition import ( # noqa: F401 + ITEM_A, + KEY_A, + Env, + _allow_only, + _assert_denied, + _create_user_with_key, + _make_client, + _new_key, + _refusal, + _stored, + account_id, + auth_env, + env, + mgmt, + region, +) + +RV_NONE = {"StringEquals": {"dynamodb:ReturnValues": "NONE"}} +RCC_NONE = {"StringEquals": {"dynamodb:ReturnConsumedCapacity": "NONE"}} +NAME = {"ProjectionExpression": "#n", "ExpressionAttributeNames": {"#n": "name"}} + + +def _deny_select(resources: list[str], actions: list[str], operator: str, + value: str) -> dict: + """Everything on the resources, except `actions` when `operator` holds + over dynamodb:Select and `value`.""" + return { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": resources}, + { + "Effect": "Deny", + "Action": [f"dynamodb:{a}" for a in actions], + "Resource": resources, + "Condition": {operator: {"dynamodb:Select": value}}, + }, + ], + } + + +@pytest.fixture(scope="module") +def table_arn(env: Env, account_id: str, region: str) -> str: + return f"arn:aws:dynamodb:{region}:{account_id}:table/{env.table}" + + +@pytest.fixture(scope="module") +def client_for(auth_env, mgmt, account_id, region) -> Callable[[dict], Any]: + """A client for a new user whose only policy is the one given, made once + per policy.""" + endpoint = auth_env[0] + made: dict[str, Any] = {} + + def make(policy: dict) -> Any: + key = json.dumps(policy, sort_keys=True) + if key not in made: + user = f"u-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(mgmt, account_id, user) + resp = mgmt.put_user_policy(account_id, user, "p", policy) + assert resp.status_code == 204, resp.text + made[key] = _make_client(endpoint, ak, sk, region) + return made[key] + + return make + + +@pytest.fixture(scope="module") +def deny_all_attributes(client_for, table_arn: str) -> Callable[[list[str]], Any]: + """A client denied `actions` with StringEquals {"dynamodb:Select": + "ALL_ATTRIBUTES"} and allowed everything else on the table.""" + return lambda actions: client_for( + _deny_select([table_arn], actions, "StringEquals", "ALL_ATTRIBUTES")) + + +def _assert_not_allowed(call: Callable[[], Any], action: str) -> None: + msg = _refusal(call, action) + assert f"because no identity-based policy allows the dynamodb:{action} action" in msg, msg + + +def _named_item(env: Env) -> dict: + """A new item holding name, stored by the owner.""" + item = {**_new_key(), "name": {"S": "n"}} + env.admin.put_item(TableName=env.table, Item=item) + return item + + +# -- dynamodb:Select ---------------------------------------------------------- + + +@pytest.mark.parametrize("action", ["GetItem", "Query"]) +def test_projected_read_passes_a_deny_unless_specific_attributes( + env: Env, client_for, table_arn: str, action: str +): + """A Deny of the action with StringNotEqualsIfExists {"dynamodb:Select": + "SPECIFIC_ATTRIBUTES"} lets a read with a projection and no Select + through: its key holds SPECIFIC_ATTRIBUTES.""" + client = client_for(_deny_select( + [table_arn], [action], "StringNotEqualsIfExists", "SPECIFIC_ATTRIBUTES")) + if action == "GetItem": + resp = client.get_item(TableName=env.table, Key=KEY_A, **NAME) + assert resp["Item"] == {"name": ITEM_A["name"]} + else: + resp = client.query( + TableName=env.table, KeyConditionExpression="pk = :p", + ExpressionAttributeValues={":p": KEY_A["pk"]}, **NAME) + assert resp["Items"] == [{"name": ITEM_A["name"]}] + + +@pytest.mark.parametrize("action", ["GetItem", "Query"]) +def test_unprojected_read_is_denied_unless_specific_attributes( + env: Env, client_for, table_arn: str, action: str +): + """The same Deny refuses a read with no projection and no Select: its key + holds ALL_ATTRIBUTES.""" + client = client_for(_deny_select( + [table_arn], [action], "StringNotEqualsIfExists", "SPECIFIC_ATTRIBUTES")) + if action == "GetItem": + call = lambda: client.get_item(TableName=env.table, Key=KEY_A) + else: + call = lambda: client.query( + TableName=env.table, KeyConditionExpression="pk = :p", + ExpressionAttributeValues={":p": KEY_A["pk"]}) + _assert_denied(call, action) + + +@pytest.mark.parametrize("action", ["GetItem", "Query", "Scan"]) +def test_unprojected_read_is_denied_on_all_attributes( + env: Env, deny_all_attributes, action: str +): + """A Deny of GetItem, Query and Scan with StringEquals {"dynamodb:Select": + "ALL_ATTRIBUTES"} refuses a read with no projection and no Select.""" + client = deny_all_attributes(["GetItem", "Query", "Scan"]) + calls = { + "GetItem": lambda: client.get_item(TableName=env.table, Key=KEY_A), + "Query": lambda: client.query( + TableName=env.table, KeyConditionExpression="pk = :p", + ExpressionAttributeValues={":p": KEY_A["pk"]}), + "Scan": lambda: client.scan(TableName=env.table), + } + _assert_denied(calls[action], action) + + +def test_projected_get_item_passes_a_deny_on_all_attributes(env: Env, deny_all_attributes): + client = deny_all_attributes(["GetItem", "Query", "Scan"]) + resp = client.get_item(TableName=env.table, Key=KEY_A, **NAME) + assert resp["Item"] == {"name": ITEM_A["name"]} + + +def test_scan_with_select_count_passes_a_deny_on_all_attributes(env: Env, deny_all_attributes): + """A Select the request gives is used as given.""" + client = deny_all_attributes(["GetItem", "Query", "Scan"]) + resp = client.scan(TableName=env.table, Select="COUNT") + assert resp["Count"] >= 1 + assert "Items" not in resp + + +def test_query_with_select_count_passes_a_deny_on_all_attributes(env: Env, deny_all_attributes): + client = deny_all_attributes(["Query"]) + resp = client.query( + TableName=env.table, KeyConditionExpression="pk = :p", + ExpressionAttributeValues={":p": KEY_A["pk"]}, Select="COUNT") + assert resp["Count"] == 1 + + +def test_unprojected_batch_get_item_is_denied_on_all_attributes(env: Env, deny_all_attributes): + client = deny_all_attributes(["BatchGetItem"]) + _assert_denied( + lambda: client.batch_get_item(RequestItems={env.table: {"Keys": [KEY_A]}}), + "BatchGetItem", + ) + + +def test_projected_batch_get_item_passes_a_deny_on_all_attributes(env: Env, deny_all_attributes): + client = deny_all_attributes(["BatchGetItem"]) + resp = client.batch_get_item(RequestItems={env.table: {"Keys": [KEY_A], **NAME}}) + assert resp["Responses"][env.table] == [{"name": ITEM_A["name"]}] + + +def test_projected_batch_get_item_is_granted_specific_attributes( + env: Env, client_for, table_arn: str +): + """An Allow of BatchGetItem alone with StringEquals {"dynamodb:Select": + "SPECIFIC_ATTRIBUTES"} grants a BatchGetItem with a projection.""" + client = client_for(_allow_only( + table_arn, "BatchGetItem", + {"StringEquals": {"dynamodb:Select": "SPECIFIC_ATTRIBUTES"}})) + resp = client.batch_get_item(RequestItems={env.table: {"Keys": [KEY_A], **NAME}}) + assert resp["Responses"][env.table] == [{"name": ITEM_A["name"]}] + + +def test_unprojected_transaction_get_is_denied_on_all_attributes(env: Env, deny_all_attributes): + """The Get of a TransactGetItems is authorized as GetItem, with the key + set as on a GetItem.""" + client = deny_all_attributes(["GetItem"]) + _assert_denied( + lambda: client.transact_get_items( + TransactItems=[{"Get": {"TableName": env.table, "Key": KEY_A}}]), + "GetItem", + ) + + +def test_projected_transaction_get_passes_a_deny_on_all_attributes( + env: Env, deny_all_attributes +): + client = deny_all_attributes(["GetItem"]) + resp = client.transact_get_items( + TransactItems=[{"Get": {"TableName": env.table, "Key": KEY_A, **NAME}}]) + assert resp["Responses"] == [{"Item": {"name": ITEM_A["name"]}}] + + +# -- dynamodb:ReturnValues ---------------------------------------------------- + + +def test_put_item_without_return_values_is_granted_return_values_none( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "PutItem", RV_NONE)) + item = {**_new_key(), "name": {"S": "n"}} + client.put_item(TableName=env.table, Item=item) + assert _stored(env, {"pk": item["pk"]}) == item + + +def test_put_item_with_return_values_all_old_is_not_granted( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "PutItem", RV_NONE)) + item = {**_new_key(), "name": {"S": "n"}} + _assert_not_allowed( + lambda: client.put_item(TableName=env.table, Item=item, ReturnValues="ALL_OLD"), + "PutItem", + ) + assert _stored(env, {"pk": item["pk"]}) is None, "a refused write stores nothing" + + +def test_update_item_without_return_values_is_granted_return_values_none( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "UpdateItem", RV_NONE)) + item = _named_item(env) + key = {"pk": item["pk"]} + client.update_item( + TableName=env.table, Key=key, UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": "name"}, ExpressionAttributeValues={":v": {"S": "u"}}) + assert _stored(env, key) == {**key, "name": {"S": "u"}} + + +def test_update_item_with_return_values_all_new_is_not_granted( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "UpdateItem", RV_NONE)) + item = _named_item(env) + key = {"pk": item["pk"]} + _assert_not_allowed( + lambda: client.update_item( + TableName=env.table, Key=key, UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": "name"}, + ExpressionAttributeValues={":v": {"S": "u"}}, ReturnValues="ALL_NEW"), + "UpdateItem", + ) + assert _stored(env, key) == item, "a refused write changes nothing" + + +def test_delete_item_without_return_values_is_granted_return_values_none( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "DeleteItem", RV_NONE)) + item = _named_item(env) + key = {"pk": item["pk"]} + client.delete_item(TableName=env.table, Key=key) + assert _stored(env, key) is None + + +def test_delete_item_with_return_values_all_old_is_not_granted( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "DeleteItem", RV_NONE)) + item = _named_item(env) + key = {"pk": item["pk"]} + _assert_not_allowed( + lambda: client.delete_item(TableName=env.table, Key=key, ReturnValues="ALL_OLD"), + "DeleteItem", + ) + assert _stored(env, key) == item, "a refused write changes nothing" + + +def test_batch_write_item_is_not_granted_return_values_none( + env: Env, client_for, table_arn: str +): + """A BatchWriteItem leaves dynamodb:ReturnValues absent, so an Allow gated + on StringEquals NONE does not grant it.""" + client = client_for(_allow_only(table_arn, "BatchWriteItem", RV_NONE)) + item = {**_new_key(), "name": {"S": "n"}} + _assert_not_allowed( + lambda: client.batch_write_item( + RequestItems={env.table: [{"PutRequest": {"Item": item}}]}), + "BatchWriteItem", + ) + assert _stored(env, {"pk": item["pk"]}) is None, "a refused write stores nothing" + + +@pytest.mark.parametrize("action", ["PutItem", "UpdateItem", "DeleteItem"]) +def test_transaction_write_is_granted_return_values_none( + env: Env, client_for, table_arn: str, action: str +): + """Each Put, Update and Delete item of a TransactWriteItems is authorized + as PutItem, UpdateItem and DeleteItem with dynamodb:ReturnValues NONE. + The Delete names a key that holds no item, as on the service.""" + client = client_for(_allow_only(table_arn, action, RV_NONE)) + key = _new_key() + item = { + "PutItem": {"Put": {"TableName": env.table, "Item": {**key, "name": {"S": "t"}}}}, + "UpdateItem": {"Update": { + "TableName": env.table, "Key": key, "UpdateExpression": "SET #n = :v", + "ExpressionAttributeNames": {"#n": "name"}, + "ExpressionAttributeValues": {":v": {"S": "t"}}}}, + "DeleteItem": {"Delete": {"TableName": env.table, "Key": key}}, + }[action] + client.transact_write_items(TransactItems=[item]) + expected = None if action == "DeleteItem" else {**key, "name": {"S": "t"}} + assert _stored(env, key) == expected + + +# -- dynamodb:ReturnConsumedCapacity ----------------------------------------- + + +def test_get_item_without_return_consumed_capacity_is_granted_none( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "GetItem", RCC_NONE)) + resp = client.get_item(TableName=env.table, Key=KEY_A) + assert resp["Item"] == ITEM_A + + +def test_get_item_with_return_consumed_capacity_total_is_not_granted( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "GetItem", RCC_NONE)) + _assert_not_allowed( + lambda: client.get_item(TableName=env.table, Key=KEY_A, ReturnConsumedCapacity="TOTAL"), + "GetItem", + ) + + +def test_batch_write_item_without_return_consumed_capacity_is_granted_none( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "BatchWriteItem", RCC_NONE)) + item = {**_new_key(), "name": {"S": "n"}} + resp = client.batch_write_item(RequestItems={env.table: [{"PutRequest": {"Item": item}}]}) + assert resp["UnprocessedItems"] == {} + assert _stored(env, {"pk": item["pk"]}) == item + + +def test_transaction_get_without_return_consumed_capacity_is_granted_none( + env: Env, client_for, table_arn: str +): + client = client_for(_allow_only(table_arn, "GetItem", RCC_NONE)) + resp = client.transact_get_items( + TransactItems=[{"Get": {"TableName": env.table, "Key": KEY_A}}]) + assert resp["Responses"] == [{"Item": ITEM_A}] + + +# -- rows that were not measured --------------------------------------------- + + +@pytest.fixture(scope="module") +def indexed(env: Env, account_id: str, region: str) -> tuple[str, list[str]]: + """A table keyed on (pk, sk), with a global secondary index on gpk that + projects every attribute, holding items (a, 1) and (a, 2). Yields the + table name and the resources a policy on it and its indexes lists.""" + table = f"attrs-ix-{uuid.uuid4().hex[:8]}" + env.admin.create_table( + TableName=table, + AttributeDefinitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + {"AttributeName": "gpk", "AttributeType": "S"}, + ], + KeySchema=[ + {"AttributeName": "pk", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "gsi", + "KeySchema": [{"AttributeName": "gpk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(env.admin, table) + for sk in ["1", "2"]: + env.admin.put_item(TableName=table, Item={ + "pk": {"S": "a"}, "sk": {"S": sk}, "gpk": {"S": "g"}, "ssn": {"S": "1"}}) + arn = f"arn:aws:dynamodb:{region}:{account_id}:table/{table}" + yield table, [arn, f"{arn}/index/*"] + try: + env.admin.delete_table(TableName=table) + except ClientError: + pass + + +def _index_query(client: Any, table: str, **extra: Any) -> Any: + return client.query( + TableName=table, IndexName="gsi", KeyConditionExpression="gpk = :g", + ExpressionAttributeValues={":g": {"S": "g"}}, **extra) + + +def test_unprojected_index_query_holds_all_projected_attributes(indexed, client_for): + """Not measured: follows the API reference, which gives ALL_PROJECTED_ATTRIBUTES + as the default for a Query or Scan of an index with no projection and no + Select. A Deny on ALL_PROJECTED_ATTRIBUTES refuses such a Query, and a + Deny on ALL_ATTRIBUTES does not, though the index projects every + attribute; with Select ALL_ATTRIBUTES given, the Deny on ALL_ATTRIBUTES + refuses it.""" + table, resources = indexed + deny_projected = client_for(_deny_select( + resources, ["Query"], "StringEquals", "ALL_PROJECTED_ATTRIBUTES")) + _assert_denied(lambda: _index_query(deny_projected, table), "Query") + + deny_all = client_for(_deny_select(resources, ["Query"], "StringEquals", "ALL_ATTRIBUTES")) + _index_query(deny_all, table) + _assert_denied(lambda: _index_query(deny_all, table, Select="ALL_ATTRIBUTES"), "Query") + + +def test_transaction_gets_of_one_partition_are_evaluated_apart(indexed, client_for): + """Not measured: follows the TransactGetItems rows. Two Gets of partition a + whose only difference for authorization is that the first projects pk: + both hold the same dynamodb:LeadingKeys and dynamodb:Attributes, and a Deny + on ALL_ATTRIBUTES refuses the transaction because the second Get holds + ALL_ATTRIBUTES.""" + table, resources = indexed + client = client_for(_deny_select(resources, ["GetItem"], "StringEquals", "ALL_ATTRIBUTES")) + + def get(sk: str, **extra: Any) -> dict: + return {"Get": {"TableName": table, "Key": {"pk": {"S": "a"}, "sk": {"S": sk}}, **extra}} + + _assert_denied( + lambda: client.transact_get_items( + TransactItems=[get("1", ProjectionExpression="pk"), get("2")]), + "GetItem", + ) + resp = client.transact_get_items(TransactItems=[ + get("1", ProjectionExpression="pk"), get("2", ProjectionExpression="pk")]) + assert resp["Responses"] == [{"Item": {"pk": {"S": "a"}}}, {"Item": {"pk": {"S": "a"}}}] From f48250fda6bae02ec3a85746b404bbe17e86228d Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:43:36 +0000 Subject: [PATCH 3/5] docs: describe the Select, ReturnValues and ReturnConsumedCapacity defaults The security model's condition key table and a new section say which value dynamodb:Select, dynamodb:ReturnValues and dynamodb:ReturnConsumedCapacity hold when a request leaves the parameter out, on single-table requests and on each table of a batch and each transaction item, and which rows DynamoDB answered on 2026-11-01. The section says that a Query or Scan of an index with no projection holds ALL_PROJECTED_ATTRIBUTES, following the API reference, so a statement meant to refuse whole-item reads of a table and its indexes lists both ALL_ATTRIBUTES and ALL_PROJECTED_ATTRIBUTES. The paragraph on ReturnValues ALL_OLD no longer says that ExtendDB sets dynamodb:ReturnValues only when the request carries ReturnValues. The upgrade manual's new entry lists the policy shapes that now decide differently: a Deny on Select ALL_ATTRIBUTES now refuses reads with no projection; a Deny gated on StringNotEqualsIfExists over Select SPECIFIC_ATTRIBUTES stops refusing projected reads; an Allow gated on Select SPECIFIC_ATTRIBUTES, ReturnValues NONE or ReturnConsumedCapacity NONE now grants the requests that omit the parameter; a Deny gated on StringNotEqualsIfExists over ReturnValues or ReturnConsumedCapacity NONE stops refusing them; and Null on the three keys no longer matches them. --- docs/manuals/07-upgrade-manual.md | 16 ++++++++++++++++ docs/manuals/10-security-model.md | 23 +++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index c54e40be2..23f00077f 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -681,6 +681,22 @@ No catalog migration is required. Before upgrading a deployment that restricts t A `DeleteItem` and a read with no projection hold only the names the request gives, not those of the stored item, so a `Deny` on an attribute blocks neither deleting an item that holds it nor reading the item with no projection, as on DynamoDB (measured 2026-10-31). Nor does it stop an `UpdateItem` from copying the attribute's value into another attribute through a `SET` value such as `SET #n = ssn`, as on DynamoDB (measured 2026-11-01). No catalog migration is required. Before upgrading, check the policies of each account for the statements above. +### v0.1.15: `dynamodb:Select`, `dynamodb:ReturnValues` and `dynamodb:ReturnConsumedCapacity` hold a value when the request omits the parameter + +**What changed.** Before this release the three keys held the request's `Select`, `ReturnValues` and `ReturnConsumedCapacity` parameters only, on requests with a single authorization target. A request that left the parameter out left the key absent, and the tables of a batch and the items of a transaction never carried the keys. Each key now holds the value the operation uses when the request omits the parameter, on single-table requests, on each table of a batch and on each transaction item, as the security model manual lists: `dynamodb:Select` is `SPECIFIC_ATTRIBUTES` on a read with a projection, `ALL_ATTRIBUTES` on a read of a table without one, and `ALL_PROJECTED_ATTRIBUTES` on a `Query` or `Scan` of an index without one; `dynamodb:ReturnValues` is `NONE` on `PutItem`, `UpdateItem`, `DeleteItem` and the `Put`, `Update` and `Delete` items of a `TransactWriteItems`; and `dynamodb:ReturnConsumedCapacity` is `NONE` on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan` and the batch and transaction requests. DynamoDB does the same (measured 2026-11-01 on the requests the security model manual lists; the index value follows the DynamoDB API reference and was not measured). A `Select` member in a `GetItem`, which takes no `Select` parameter, no longer sets `dynamodb:Select`: the key comes from the projection, as it does on a `BatchGetItem` table and a `TransactGetItems` `Get`. + +**Who is affected.** Any deployment with a policy statement that names one of these keys in a condition. Statements of these shapes now decide differently: + +- A `Deny` gated on `StringEquals` with `dynamodb:Select` `ALL_ATTRIBUTES` now refuses a `GetItem`, `Query` or `Scan` with no projection and no `Select`, a `BatchGetItem` table with no projection and a `TransactGetItems` `Get` with no projection. Before, it refused none of them, and each returned the whole item. It does not refuse a `Query` or `Scan` of an index with no projection, which holds `ALL_PROJECTED_ATTRIBUTES`; list that value as well to refuse those reads. +- A `Deny` gated on `StringNotEqualsIfExists` with `dynamodb:Select` `SPECIFIC_ATTRIBUTES` refused every `GetItem`, `BatchGetItem` and `TransactGetItems` its `Action` covers, and every `Query` or `Scan` without `Select`, because the absent key satisfied it. It now stops refusing reads that project, and still refuses reads without a projection. +- An `Allow` gated on `StringEquals` with `dynamodb:Select` `SPECIFIC_ATTRIBUTES` now grants a `GetItem`, `Query`, `Scan`, `BatchGetItem` or `TransactGetItems` that projects without `Select`. Before, it granted only a `Query` or `Scan` that set `Select` to `SPECIFIC_ATTRIBUTES`, and a `GetItem` that carried a `Select` member of that value, whatever it projected. +- An `Allow` gated on `StringEquals` with `dynamodb:ReturnValues` `NONE` now grants a `PutItem`, `UpdateItem` or `DeleteItem` without `ReturnValues`, and the `Put`, `Update` and `Delete` items of a `TransactWriteItems`. Before, it refused them. It still refuses a `BatchWriteItem`, which leaves the key absent, as on DynamoDB (measured 2026-11-01). +- An `Allow` gated on `StringEquals` with `dynamodb:ReturnConsumedCapacity` `NONE` now grants the `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, batch and transaction requests its `Action` covers that carry no `ReturnConsumedCapacity`. Before, it refused them. +- A `Deny` gated on `StringNotEqualsIfExists` with `dynamodb:ReturnValues` or `dynamodb:ReturnConsumedCapacity` `NONE` refused every request without the parameter, because the absent key satisfied it. It now stops refusing the requests that take the parameter and omit it. +- `Null` with value `true` on one of these keys matched every request without the parameter. It now matches none of the requests that take the parameter, so a `Deny` gated on it stops refusing them and an `Allow` gated on it stops granting them. `Null` with value `false` now matches them. + +No catalog migration is required. Before upgrading, check the policies of each account for the statements above. + --- ## License diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index d959f04b8..70110c417 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -111,9 +111,9 @@ Set operators and `IfExists` can be combined: `ForAllValues:StringEqualsIfExists | `aws:ResourceTag/` | String | Tag on the target DynamoDB table | | `dynamodb:LeadingKeys` | Multi-valued string | Partition key values being accessed | | `dynamodb:Attributes` | Multi-valued string | Top-level names of the attributes the request names (see below) | -| `dynamodb:Select` | String | The `Select` parameter value | -| `dynamodb:ReturnValues` | String | The `ReturnValues` parameter value | -| `dynamodb:ReturnConsumedCapacity` | String | The `ReturnConsumedCapacity` parameter value | +| `dynamodb:Select` | String | The `Select` parameter value, or the value the read uses without one (see below) | +| `dynamodb:ReturnValues` | String | The `ReturnValues` parameter value, or `NONE` on a write without one (see below) | +| `dynamodb:ReturnConsumedCapacity` | String | The `ReturnConsumedCapacity` parameter value, or `NONE` on an item request without one (see below) | | `dynamodb:FullTableScan` | Boolean | `true` for Scan operations | | `dynamodb:EnclosingOperation` | String | The enclosing operation for batch/transact sub-operations | @@ -150,10 +150,25 @@ extenddb supports the same access control patterns as real AWS IAM: The names come from the request alone. A `DeleteItem` holds the key attributes of the item it deletes, not the item's other attributes, and a read with no projection holds the key attributes while it returns every attribute of the item, so a `Deny` on `ForAnyValue:StringEquals` over an attribute blocks neither (measured on DynamoDB 2026-10-31). As in DynamoDB, a policy on `dynamodb:Attributes` does not stop an `UpdateItem` from copying a denied attribute's value into another attribute through a `SET` value: under a `Deny` on `ssn`, `SET #n = ssn` with `#n` for `name` is allowed, and a projection of `name` then returns the value (measured on DynamoDB 2026-11-01). A `Scan` holds the table's key attributes when it names no attribute, a `Select COUNT` `Scan` included, so a `Null` condition on the key does not match it: a `Deny` of `Scan` gated on `Null` with value `true` refuses neither a `Scan` with no projection nor a projected one, and an `Allow` of `Scan` gated on it grants neither a `Scan` with no projection nor a `Select COUNT` `Scan` (measured on DynamoDB 2026-11-01). A bare `StringNotEquals` on the key does not match whether the key is present or absent, so an `Allow` gated on it grants nothing; on DynamoDB it granted no `Scan`, whether the `Scan` named an attribute or not (measured 2026-11-01). An allowlist written as an `Allow` on `ForAllValues:StringEquals` must list the key attributes of the table and of every index the statement covers, because every item request other than `SearchVectors` holds them. The key is absent on a `SearchVectors` with no `ProjectionExpression` and, on a table that does not exist, on a `GetItem` or transaction `Get` that names no attribute outside its `Key`, a `Query` that names none outside its key condition and a `Scan` that names none. -A `Deny` on `dynamodb:Attributes` does not stop `ReturnValues` `ALL_OLD` or `ReturnValuesOnConditionCheckFailure` `ALL_OLD` from returning the whole stored item, the denied attribute included. Under a `Deny` on `ssn`, an `UpdateItem` of `name` with `ReturnValues` `ALL_OLD` returns the old item with `ssn` in it, and a `PutItem` whose condition fails and a `TransactWriteItems` `ConditionCheck` that fails, each with `ReturnValuesOnConditionCheckFailure` `ALL_OLD`, return the stored item with `ssn` in the error. DynamoDB does the same (measured 2026-11-01). ExtendDB returns the whole stored item in each of these cases, and on a `PutItem` with `ReturnValues` `ALL_OLD`, whose answer on DynamoDB was not measured. The IAM guide's guard against `ReturnValues` returning attributes is a condition on `dynamodb:ReturnValues`. ExtendDB sets that key only when the request carries `ReturnValues`; DynamoDB sets it to `NONE` on a `PutItem` that carries none (measured 2026-11-01, tracked in #717). +A `Deny` on `dynamodb:Attributes` does not stop `ReturnValues` `ALL_OLD` or `ReturnValuesOnConditionCheckFailure` `ALL_OLD` from returning the whole stored item, the denied attribute included. Under a `Deny` on `ssn`, an `UpdateItem` of `name` with `ReturnValues` `ALL_OLD` returns the old item with `ssn` in it, and a `PutItem` whose condition fails and a `TransactWriteItems` `ConditionCheck` that fails, each with `ReturnValuesOnConditionCheckFailure` `ALL_OLD`, return the stored item with `ssn` in the error. DynamoDB does the same (measured 2026-11-01). ExtendDB returns the whole stored item in each of these cases, and on a `PutItem` with `ReturnValues` `ALL_OLD`, whose answer on DynamoDB was not measured. The IAM guide's guard against `ReturnValues` returning attributes is a condition on `dynamodb:ReturnValues`, which holds `NONE` on a write that carries no `ReturnValues` (see below). These entries were measured on DynamoDB on 2026-10-31 and 2026-11-01: the item attributes of `PutItem`; the target of a `SET`, `REMOVE`, `ADD` or `DELETE` action and the attributes a `SET` value reads; `ConditionExpression` on `PutItem`, `DeleteItem`, a `ConditionCheck` and a transaction `Delete`; `FilterExpression` on `Query` and `Scan`; `Expected` on `PutItem`, `AttributeUpdates`, `ScanFilter` and `QueryFilter`; `AttributesToGet` on `GetItem`, `Query`, `Scan` and `BatchGetItem`; the table's key attributes on `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `DeleteRequest`; projected document paths; reads with no projection; `DeleteItem`; a transaction `Update`; the `BatchGetItem`, `TransactGetItems`, `BatchWriteItem` and `TransactWriteItems` forms of a projection and a put; and the evaluation of a `BatchWriteItem` once per table. `ProjectionExpression` on `Query` and `Scan`, `Expected` and `ConditionExpression` on `UpdateItem`, and `Expected` on `DeleteItem` follow the rule measured for the same parameter on the other operations. The key attributes of the queried or scanned index follow the IAM guide's rule that a policy on `dynamodb:Attributes` lists the primary key and index key attributes of the table and of every secondary index the policy covers; they were not measured. Whether `ExclusiveStartKey` adds names of its own cannot be told on DynamoDB, because a valid start key holds only key attributes, which the key already holds. +`dynamodb:Select`, `dynamodb:ReturnValues` and `dynamodb:ReturnConsumedCapacity` hold the request's `Select`, `ReturnValues` and `ReturnConsumedCapacity`. When a request leaves the parameter out, the key holds the value the operation then uses, as on DynamoDB, so a condition decides the same way whether or not the caller writes that value out: + +| Key | Requests | Value when the request has no such parameter | +|-----|----------|----------------------------------------------| +| `dynamodb:Select` | `GetItem`, `Query` and `Scan`, each table of a `BatchGetItem`, and each `Get` of a `TransactGetItems` | `SPECIFIC_ATTRIBUTES` with a `ProjectionExpression` or `AttributesToGet`, and `ALL_ATTRIBUTES` without one | +| `dynamodb:Select` | A `Query` or `Scan` with `IndexName` and no projection | `ALL_PROJECTED_ATTRIBUTES` | +| `dynamodb:ReturnValues` | `PutItem`, `UpdateItem` and `DeleteItem`, and the `Put`, `Update` and `Delete` items of a `TransactWriteItems` | `NONE` | +| `dynamodb:ReturnConsumedCapacity` | `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query` and `Scan`, and each table or item of a `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` or `TransactWriteItems` | `NONE` | + +A `Query` or `Scan` that carries `Select` holds it as given. `GetItem`, `BatchGetItem` and `TransactGetItems` take no `Select` parameter, so their value comes from the projection alone, and a `Select` member in one of them is not read. The `Put`, `Update` and `Delete` items of a transaction take no `ReturnValues` parameter and always hold `NONE`. A `BatchWriteItem` and a `ConditionCheck` leave `dynamodb:ReturnValues` absent. On a single-table request, a key the operation does not take, such as `dynamodb:ReturnValues` on a `GetItem`, holds the request's member of that name when it has one and is absent otherwise. A batch or transaction request carries one `ReturnConsumedCapacity`, and each of its tables or items is evaluated with it. + +So a `Deny` on `dynamodb:Select` `ALL_ATTRIBUTES` refuses a read with no projection, which returns the whole item, and an `Allow` gated on `StringEquals` with `dynamodb:ReturnValues` `NONE` grants a write that carries no `ReturnValues`. A `Query` or `Scan` of an index with no projection holds `ALL_PROJECTED_ATTRIBUTES`, even when the index projects every attribute, so a statement meant to refuse whole-item reads of a table and its indexes lists both `ALL_ATTRIBUTES` and `ALL_PROJECTED_ATTRIBUTES`. + +These values were measured on DynamoDB on 2026-11-01: `dynamodb:Select` on `GetItem` and `Query` with and without a projection, on `Scan` without one and with `Select` `COUNT`, on `Query` with `Select` `COUNT`, and on a `BatchGetItem` and a `TransactGetItems` `Get` with and without a projection; `dynamodb:ReturnValues` on `PutItem`, `UpdateItem` and `DeleteItem` with and without the parameter, on a `BatchWriteItem`, and on the `Put`, `Update` and `Delete` items of a `TransactWriteItems`; and `dynamodb:ReturnConsumedCapacity` on `GetItem` with and without the parameter, on a `BatchWriteItem` and on a `TransactGetItems` `Get`. `dynamodb:ReturnConsumedCapacity` on the other requests follows those, and a projection written as `AttributesToGet` follows the rule measured for `ProjectionExpression`. `ALL_PROJECTED_ATTRIBUTES` on a read of an index follows the DynamoDB API reference, which gives it as the default when a `Query` or `Scan` of an index has neither `Select` nor `ProjectionExpression`; it was not measured. + ### Resource ARNs Resources are identified by ARN: `arn:aws:dynamodb:::table/`. Wildcard matching (`*`, `?`) in policy Resource fields follows AWS IAM conventions. `NotResource` is also supported. From 8b95c6ac52df9c7dcec6caaae5c14730fac57b49 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:08:36 +0000 Subject: [PATCH 4/5] docs(server): refine condition-key default notes after review Documentation and comment edits for the dynamodb:Select, dynamodb:ReturnValues and dynamodb:ReturnConsumedCapacity defaults. No behaviour changes: single_table, batch_table and transact_item are untouched and no service measurement was added. Security model and upgrade manual: - Label every stated-but-unmeasured row as not measured: a Scan with a projection, a TransactGetItems Get with a projection, a ConditionCheck leaving dynamodb:ReturnValues absent, AttributesToGet read as a projection, and dynamodb:ReturnConsumedCapacity on the operations beyond those measured. - Correct the condition key table so the dynamodb:ReturnValues and dynamodb:ReturnConsumedCapacity rows name the requests that hold each default rather than every write or item request. - Scope the StringNotEquals and Null breaking-change bullets to the requests that now hold each key, and note that GetItem, BatchGetItem and TransactGetItems hold dynamodb:Select though they take no Select parameter, while BatchWriteItem, ConditionCheck and SearchVectors still leave their key absent. - Add the breaking-change shapes the entry had missed: an Allow gated on StringEqualsIfExists over dynamodb:Select SPECIFIC_ATTRIBUTES, which matches the Developer Guide's Example 3; an Allow gated on StringEquals over dynamodb:Select ALL_PROJECTED_ATTRIBUTES, which matches Example 5; and the bare negated operator forms alongside the IfExists forms. State the general rule that a condition the absent key satisfied is now evaluated against the default value. - Note that dynamodb:ReturnValues reflects the ReturnValues parameter alone and not ReturnValuesOnConditionCheckFailure, so an Allow gated on NONE grants a write whose failed condition can still return the stored item. parameter_keys.rs: - The module doc no longer describes a key the operation does not take holding a stray request member. - The given doc states that a null Select or projection member reads as absent, while the engine refuses a null ReturnValues or ReturnConsumedCapacity. - parameter_keys/tests.rs comments the null ReturnValues case as the default path. --- crates/server/src/parameter_keys.rs | 37 ++++++++++++++--------- crates/server/src/parameter_keys/tests.rs | 4 +++ docs/manuals/07-upgrade-manual.md | 14 ++++++--- docs/manuals/10-security-model.md | 10 +++--- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/crates/server/src/parameter_keys.rs b/crates/server/src/parameter_keys.rs index 512604d3f..b3d7333b7 100644 --- a/crates/server/src/parameter_keys.rs +++ b/crates/server/src/parameter_keys.rs @@ -7,7 +7,8 @@ //! Each key holds the value of the request parameter it is named after. When a //! request that takes the parameter leaves it out, the service evaluates the //! value the operation then uses, so the key is present whether or not the -//! caller spells that value out (measured 2026-11-01): +//! caller spells that value out. The list after the bullets says which values +//! were measured on 2026-11-01 and which follow them: //! //! - `dynamodb:Select` on `GetItem`, `Query`, `Scan`, each table of a //! `BatchGetItem` and each `Get` of a `TransactGetItems`: @@ -27,21 +28,26 @@ //! //! Measured on 2026-11-01: `dynamodb:Select` on `GetItem` and `Query` with and //! without a projection, on `Scan` without one and with `Select` `COUNT`, on -//! `Query` with `Select` `COUNT`, and on `BatchGetItem` and a `TransactGetItems` -//! `Get` with and without a projection; `dynamodb:ReturnValues` on `PutItem`, -//! `UpdateItem` and `DeleteItem` with and without the parameter, on a -//! `BatchWriteItem`, and on the `Put`, `Update` and `Delete` items of a +//! `Query` with `Select` `COUNT`, on a `BatchGetItem` with and without a +//! projection, and on a `TransactGetItems` `Get` without one; `dynamodb:ReturnValues` +//! on `PutItem`, `UpdateItem` and `DeleteItem` with and without the parameter, +//! on a `BatchWriteItem`, and on the `Put`, `Update` and `Delete` items of a //! `TransactWriteItems`; `dynamodb:ReturnConsumedCapacity` on `GetItem` with and //! without the parameter, on a `BatchWriteItem` and on a `TransactGetItems` //! `Get`. `dynamodb:ReturnConsumedCapacity` on the other item operations follows -//! those. A read that projects with `AttributesToGet` is treated as one with a -//! `ProjectionExpression`, which the engine reads it as; that was not measured. -//! A `Query` or `Scan` of an index with no projection and no `Select` holds -//! `ALL_PROJECTED_ATTRIBUTES`, the default the DynamoDB API reference gives for -//! a read of an index; that was not measured either. +//! those and was not measured. A read that projects with `AttributesToGet` is +//! treated as one with a `ProjectionExpression`, which the engine reads it as; +//! that was not measured. `SPECIFIC_ATTRIBUTES` on a `Scan` with a projection +//! follows the `Query` and `GetItem` rows and was not measured, and a +//! `TransactGetItems` `Get` with a projection was measured only against a `Deny` +//! on `ALL_ATTRIBUTES`, which it passed, so its `SPECIFIC_ATTRIBUTES` value +//! follows the `BatchGetItem` and `GetItem` rows and was not measured directly. +//! A `ConditionCheck` leaving `dynamodb:ReturnValues` absent follows the +//! transaction write items and was not measured. A `Query` or `Scan` of an index +//! with no projection and no `Select` holds `ALL_PROJECTED_ATTRIBUTES`, the +//! default the DynamoDB API reference gives for a read of an index; that was not +//! measured either. //! -//! A key the operation does not take holds the request's parameter as given, -//! as before: a `Select` member in a `PutItem` reaches the policy as written. //! The batch and transaction targets carry only the keys their operation takes. use serde_json::Value; @@ -113,8 +119,11 @@ pub(crate) fn transact_item(sub: &str, item: &Value, request: &Value) -> Paramet } } -/// The request's own value of `parameter`, when it is a string. A `null` is -/// absent, as the engine reads it. +/// The request's own value of `parameter`, when it is a string; `None` for a +/// missing member or a `null`. The engine reads a `null` `Select` or projection +/// member as absent, and refuses a `null` `ReturnValues` or +/// `ReturnConsumedCapacity` with a `SerializationException`, so such a request +/// is rejected before this value decides anything. fn given(request: &Value, parameter: &str) -> Option { request .get(parameter) diff --git a/crates/server/src/parameter_keys/tests.rs b/crates/server/src/parameter_keys/tests.rs index 74a6baa68..f3f859083 100644 --- a/crates/server/src/parameter_keys/tests.rs +++ b/crates/server/src/parameter_keys/tests.rs @@ -164,6 +164,10 @@ fn query_and_scan_keep_a_given_select() { #[test] fn single_item_writes_default_return_values_to_none() { + // The null case exercises the default path only: `given` treats a null + // member as absent, so the key holds NONE. A real request carrying + // `"ReturnValues": null` is refused by the engine with a + // SerializationException before authorization, so it never reaches here. for operation in ["PutItem", "UpdateItem", "DeleteItem"] { for input in [read(), with(&read(), &json!({"ReturnValues": null}))] { assert_eq!( diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 23f00077f..de946b065 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -683,17 +683,21 @@ A `DeleteItem` and a read with no projection hold only the names the request giv ### v0.1.15: `dynamodb:Select`, `dynamodb:ReturnValues` and `dynamodb:ReturnConsumedCapacity` hold a value when the request omits the parameter -**What changed.** Before this release the three keys held the request's `Select`, `ReturnValues` and `ReturnConsumedCapacity` parameters only, on requests with a single authorization target. A request that left the parameter out left the key absent, and the tables of a batch and the items of a transaction never carried the keys. Each key now holds the value the operation uses when the request omits the parameter, on single-table requests, on each table of a batch and on each transaction item, as the security model manual lists: `dynamodb:Select` is `SPECIFIC_ATTRIBUTES` on a read with a projection, `ALL_ATTRIBUTES` on a read of a table without one, and `ALL_PROJECTED_ATTRIBUTES` on a `Query` or `Scan` of an index without one; `dynamodb:ReturnValues` is `NONE` on `PutItem`, `UpdateItem`, `DeleteItem` and the `Put`, `Update` and `Delete` items of a `TransactWriteItems`; and `dynamodb:ReturnConsumedCapacity` is `NONE` on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan` and the batch and transaction requests. DynamoDB does the same (measured 2026-11-01 on the requests the security model manual lists; the index value follows the DynamoDB API reference and was not measured). A `Select` member in a `GetItem`, which takes no `Select` parameter, no longer sets `dynamodb:Select`: the key comes from the projection, as it does on a `BatchGetItem` table and a `TransactGetItems` `Get`. +**What changed.** Before this release the three keys held the request's `Select`, `ReturnValues` and `ReturnConsumedCapacity` parameters only, on requests with a single authorization target. A request that left the parameter out left the key absent, and the tables of a batch and the items of a transaction never carried the keys. Each key now holds the value the operation uses when the request omits the parameter, on single-table requests, on each table of a batch and on each transaction item, as the security model manual lists: `dynamodb:Select` is `SPECIFIC_ATTRIBUTES` on a read with a projection, `ALL_ATTRIBUTES` on a read of a table without one, and `ALL_PROJECTED_ATTRIBUTES` on a `Query` or `Scan` of an index without one; `dynamodb:ReturnValues` is `NONE` on `PutItem`, `UpdateItem`, `DeleteItem` and the `Put`, `Update` and `Delete` items of a `TransactWriteItems`; and `dynamodb:ReturnConsumedCapacity` is `NONE` on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan` and the batch and transaction requests. DynamoDB does the same on the rows the security model manual marks measured on 2026-11-01; the others listed there (the index default, a projected `Scan`, a projected `TransactGetItems` `Get`, a `ConditionCheck`, `AttributesToGet` as a projection, and `dynamodb:ReturnConsumedCapacity` on the operations beyond those measured) follow the measured rows and the API reference and were not measured. A `Select` member in a `GetItem`, which takes no `Select` parameter, no longer sets `dynamodb:Select`: the key comes from the projection, as it does on a `BatchGetItem` table and a `TransactGetItems` `Get`. **Who is affected.** Any deployment with a policy statement that names one of these keys in a condition. Statements of these shapes now decide differently: - A `Deny` gated on `StringEquals` with `dynamodb:Select` `ALL_ATTRIBUTES` now refuses a `GetItem`, `Query` or `Scan` with no projection and no `Select`, a `BatchGetItem` table with no projection and a `TransactGetItems` `Get` with no projection. Before, it refused none of them, and each returned the whole item. It does not refuse a `Query` or `Scan` of an index with no projection, which holds `ALL_PROJECTED_ATTRIBUTES`; list that value as well to refuse those reads. -- A `Deny` gated on `StringNotEqualsIfExists` with `dynamodb:Select` `SPECIFIC_ATTRIBUTES` refused every `GetItem`, `BatchGetItem` and `TransactGetItems` its `Action` covers, and every `Query` or `Scan` without `Select`, because the absent key satisfied it. It now stops refusing reads that project, and still refuses reads without a projection. +- A `Deny` gated on `StringNotEquals` or `StringNotEqualsIfExists` with `dynamodb:Select` `SPECIFIC_ATTRIBUTES` refused every `GetItem`, `BatchGetItem` and `TransactGetItems` its `Action` covers, and every `Query` or `Scan` without `Select`, because the absent key satisfied it. It now stops refusing reads that project, and still refuses reads without a projection. - An `Allow` gated on `StringEquals` with `dynamodb:Select` `SPECIFIC_ATTRIBUTES` now grants a `GetItem`, `Query`, `Scan`, `BatchGetItem` or `TransactGetItems` that projects without `Select`. Before, it granted only a `Query` or `Scan` that set `Select` to `SPECIFIC_ATTRIBUTES`, and a `GetItem` that carried a `Select` member of that value, whatever it projected. -- An `Allow` gated on `StringEquals` with `dynamodb:ReturnValues` `NONE` now grants a `PutItem`, `UpdateItem` or `DeleteItem` without `ReturnValues`, and the `Put`, `Update` and `Delete` items of a `TransactWriteItems`. Before, it refused them. It still refuses a `BatchWriteItem`, which leaves the key absent, as on DynamoDB (measured 2026-11-01). +- An `Allow` gated on `StringEquals` with `dynamodb:ReturnValues` `NONE` now grants a `PutItem`, `UpdateItem` or `DeleteItem` without `ReturnValues`, and the `Put`, `Update` and `Delete` items of a `TransactWriteItems`. Before, it refused them. It still refuses a `BatchWriteItem`, which leaves the key absent, as on DynamoDB (measured 2026-11-01). `dynamodb:ReturnValues` reflects the `ReturnValues` parameter alone and not `ReturnValuesOnConditionCheckFailure`, so such an `Allow` also grants a write that sets `ReturnValuesOnConditionCheckFailure` `ALL_OLD`, and that write returns the stored item in the error when its condition fails. - An `Allow` gated on `StringEquals` with `dynamodb:ReturnConsumedCapacity` `NONE` now grants the `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, batch and transaction requests its `Action` covers that carry no `ReturnConsumedCapacity`. Before, it refused them. -- A `Deny` gated on `StringNotEqualsIfExists` with `dynamodb:ReturnValues` or `dynamodb:ReturnConsumedCapacity` `NONE` refused every request without the parameter, because the absent key satisfied it. It now stops refusing the requests that take the parameter and omit it. -- `Null` with value `true` on one of these keys matched every request without the parameter. It now matches none of the requests that take the parameter, so a `Deny` gated on it stops refusing them and an `Allow` gated on it stops granting them. `Null` with value `false` now matches them. +- A `Deny` gated on `StringNotEquals` or `StringNotEqualsIfExists` with `dynamodb:ReturnValues` `NONE` refused every request its `Action` covered, because the absent key satisfied it. It now refuses only the requests that leave the key absent: it stops refusing a `PutItem`, `UpdateItem` or `DeleteItem` that omits `ReturnValues` and the `Put`, `Update` and `Delete` items of a `TransactWriteItems`, and still refuses a `BatchWriteItem` and a `ConditionCheck`. The same shape over `dynamodb:ReturnConsumedCapacity` `NONE` stops refusing a `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query` or `Scan` and each table or item of a batch or transaction that omits the parameter, and still refuses a `SearchVectors` that omits it, which leaves the key absent. +- `Null` with value `true` on one of these keys matched every request without the parameter. It now matches only the requests that still leave the key absent: for `dynamodb:Select` it no longer matches a `GetItem`, `Query`, `Scan`, `BatchGetItem` or `TransactGetItems`; for `dynamodb:ReturnValues` it no longer matches a `PutItem`, `UpdateItem`, `DeleteItem` or transaction write item, and still matches a `BatchWriteItem` and a `ConditionCheck`; for `dynamodb:ReturnConsumedCapacity` it no longer matches a `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query` or `Scan` or a table or item of a batch or transaction, and still matches a `SearchVectors` that omits the parameter. A `Deny` gated on it stops refusing the requests that now hold the key, an `Allow` gated on it stops granting them, and `Null` with value `false` now matches them. +- An `Allow` gated on `StringEqualsIfExists` with `dynamodb:Select` `SPECIFIC_ATTRIBUTES` granted every `GetItem`, `BatchGetItem` and `TransactGetItems` its `Action` covered, and every `Query` or `Scan` without `Select`, because the absent key satisfied the `IfExists`. This is the Developer Guide's Example 3, which grants `UpdateItem`, `GetItem`, `Query`, `BatchGetItem` and `Scan` with a `ForAllValues:StringEquals` attribute list and a `StringEqualsIfExists` on `Select`. It now grants only reads that project, and refuses a read with no projection, which returns the whole item and so would return attributes outside the list. Check any policy copied from Example 3. +- An `Allow` gated on `StringEquals` with `dynamodb:Select` `ALL_PROJECTED_ATTRIBUTES`, the Developer Guide's Example 5 second policy, now grants a `Query` or `Scan` of an index with no projection and no `Select`, which before it refused. This depends on the index default, which follows the API reference and was not measured. + +An `IfExists` or negated condition that the absent key satisfied is now evaluated against the default value, and a positive condition on a default value (`ALL_ATTRIBUTES`, `ALL_PROJECTED_ATTRIBUTES` or `NONE`) now matches the requests that hold it. No catalog migration is required. Before upgrading, check the policies of each account for the statements above. diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index 70110c417..632d32e03 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -112,8 +112,8 @@ Set operators and `IfExists` can be combined: `ForAllValues:StringEqualsIfExists | `dynamodb:LeadingKeys` | Multi-valued string | Partition key values being accessed | | `dynamodb:Attributes` | Multi-valued string | Top-level names of the attributes the request names (see below) | | `dynamodb:Select` | String | The `Select` parameter value, or the value the read uses without one (see below) | -| `dynamodb:ReturnValues` | String | The `ReturnValues` parameter value, or `NONE` on a write without one (see below) | -| `dynamodb:ReturnConsumedCapacity` | String | The `ReturnConsumedCapacity` parameter value, or `NONE` on an item request without one (see below) | +| `dynamodb:ReturnValues` | String | The `ReturnValues` parameter value, or `NONE` on a `PutItem`, `UpdateItem`, `DeleteItem` or transaction write item without one (see below) | +| `dynamodb:ReturnConsumedCapacity` | String | The `ReturnConsumedCapacity` parameter value, or `NONE` on the item, batch and transaction requests the section below lists | | `dynamodb:FullTableScan` | Boolean | `true` for Scan operations | | `dynamodb:EnclosingOperation` | String | The enclosing operation for batch/transact sub-operations | @@ -150,7 +150,7 @@ extenddb supports the same access control patterns as real AWS IAM: The names come from the request alone. A `DeleteItem` holds the key attributes of the item it deletes, not the item's other attributes, and a read with no projection holds the key attributes while it returns every attribute of the item, so a `Deny` on `ForAnyValue:StringEquals` over an attribute blocks neither (measured on DynamoDB 2026-10-31). As in DynamoDB, a policy on `dynamodb:Attributes` does not stop an `UpdateItem` from copying a denied attribute's value into another attribute through a `SET` value: under a `Deny` on `ssn`, `SET #n = ssn` with `#n` for `name` is allowed, and a projection of `name` then returns the value (measured on DynamoDB 2026-11-01). A `Scan` holds the table's key attributes when it names no attribute, a `Select COUNT` `Scan` included, so a `Null` condition on the key does not match it: a `Deny` of `Scan` gated on `Null` with value `true` refuses neither a `Scan` with no projection nor a projected one, and an `Allow` of `Scan` gated on it grants neither a `Scan` with no projection nor a `Select COUNT` `Scan` (measured on DynamoDB 2026-11-01). A bare `StringNotEquals` on the key does not match whether the key is present or absent, so an `Allow` gated on it grants nothing; on DynamoDB it granted no `Scan`, whether the `Scan` named an attribute or not (measured 2026-11-01). An allowlist written as an `Allow` on `ForAllValues:StringEquals` must list the key attributes of the table and of every index the statement covers, because every item request other than `SearchVectors` holds them. The key is absent on a `SearchVectors` with no `ProjectionExpression` and, on a table that does not exist, on a `GetItem` or transaction `Get` that names no attribute outside its `Key`, a `Query` that names none outside its key condition and a `Scan` that names none. -A `Deny` on `dynamodb:Attributes` does not stop `ReturnValues` `ALL_OLD` or `ReturnValuesOnConditionCheckFailure` `ALL_OLD` from returning the whole stored item, the denied attribute included. Under a `Deny` on `ssn`, an `UpdateItem` of `name` with `ReturnValues` `ALL_OLD` returns the old item with `ssn` in it, and a `PutItem` whose condition fails and a `TransactWriteItems` `ConditionCheck` that fails, each with `ReturnValuesOnConditionCheckFailure` `ALL_OLD`, return the stored item with `ssn` in the error. DynamoDB does the same (measured 2026-11-01). ExtendDB returns the whole stored item in each of these cases, and on a `PutItem` with `ReturnValues` `ALL_OLD`, whose answer on DynamoDB was not measured. The IAM guide's guard against `ReturnValues` returning attributes is a condition on `dynamodb:ReturnValues`, which holds `NONE` on a write that carries no `ReturnValues` (see below). +A `Deny` on `dynamodb:Attributes` does not stop `ReturnValues` `ALL_OLD` or `ReturnValuesOnConditionCheckFailure` `ALL_OLD` from returning the whole stored item, the denied attribute included. Under a `Deny` on `ssn`, an `UpdateItem` of `name` with `ReturnValues` `ALL_OLD` returns the old item with `ssn` in it, and a `PutItem` whose condition fails and a `TransactWriteItems` `ConditionCheck` that fails, each with `ReturnValuesOnConditionCheckFailure` `ALL_OLD`, return the stored item with `ssn` in the error. DynamoDB does the same (measured 2026-11-01). ExtendDB returns the whole stored item in each of these cases, and on a `PutItem` with `ReturnValues` `ALL_OLD`, whose answer on DynamoDB was not measured. The IAM guide's guard against `ReturnValues` returning attributes is a condition on `dynamodb:ReturnValues`, which holds `NONE` on a write that carries no `ReturnValues` (see below). That key reflects the `ReturnValues` parameter alone, not `ReturnValuesOnConditionCheckFailure`, so an `Allow` gated on `dynamodb:ReturnValues` `NONE` grants a write that carries no `ReturnValues`, and such a write whose condition fails still returns the stored item in the error when it sets `ReturnValuesOnConditionCheckFailure` `ALL_OLD`. These entries were measured on DynamoDB on 2026-10-31 and 2026-11-01: the item attributes of `PutItem`; the target of a `SET`, `REMOVE`, `ADD` or `DELETE` action and the attributes a `SET` value reads; `ConditionExpression` on `PutItem`, `DeleteItem`, a `ConditionCheck` and a transaction `Delete`; `FilterExpression` on `Query` and `Scan`; `Expected` on `PutItem`, `AttributeUpdates`, `ScanFilter` and `QueryFilter`; `AttributesToGet` on `GetItem`, `Query`, `Scan` and `BatchGetItem`; the table's key attributes on `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `DeleteRequest`; projected document paths; reads with no projection; `DeleteItem`; a transaction `Update`; the `BatchGetItem`, `TransactGetItems`, `BatchWriteItem` and `TransactWriteItems` forms of a projection and a put; and the evaluation of a `BatchWriteItem` once per table. `ProjectionExpression` on `Query` and `Scan`, `Expected` and `ConditionExpression` on `UpdateItem`, and `Expected` on `DeleteItem` follow the rule measured for the same parameter on the other operations. The key attributes of the queried or scanned index follow the IAM guide's rule that a policy on `dynamodb:Attributes` lists the primary key and index key attributes of the table and of every secondary index the policy covers; they were not measured. Whether `ExclusiveStartKey` adds names of its own cannot be told on DynamoDB, because a valid start key holds only key attributes, which the key already holds. @@ -163,11 +163,11 @@ These entries were measured on DynamoDB on 2026-10-31 and 2026-11-01: the item a | `dynamodb:ReturnValues` | `PutItem`, `UpdateItem` and `DeleteItem`, and the `Put`, `Update` and `Delete` items of a `TransactWriteItems` | `NONE` | | `dynamodb:ReturnConsumedCapacity` | `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query` and `Scan`, and each table or item of a `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` or `TransactWriteItems` | `NONE` | -A `Query` or `Scan` that carries `Select` holds it as given. `GetItem`, `BatchGetItem` and `TransactGetItems` take no `Select` parameter, so their value comes from the projection alone, and a `Select` member in one of them is not read. The `Put`, `Update` and `Delete` items of a transaction take no `ReturnValues` parameter and always hold `NONE`. A `BatchWriteItem` and a `ConditionCheck` leave `dynamodb:ReturnValues` absent. On a single-table request, a key the operation does not take, such as `dynamodb:ReturnValues` on a `GetItem`, holds the request's member of that name when it has one and is absent otherwise. A batch or transaction request carries one `ReturnConsumedCapacity`, and each of its tables or items is evaluated with it. +A `Query` or `Scan` that carries `Select` holds it as given. `GetItem`, `BatchGetItem` and `TransactGetItems` take no `Select` parameter, so their value comes from the projection alone, and a `Select` member in one of them is not read. The `Put`, `Update` and `Delete` items of a transaction take no `ReturnValues` parameter and always hold `NONE`. A `BatchWriteItem` leaves `dynamodb:ReturnValues` absent; a `ConditionCheck` is treated the same, which was not measured. A batch or transaction request carries one `ReturnConsumedCapacity`, and each of its tables or items is evaluated with it. So a `Deny` on `dynamodb:Select` `ALL_ATTRIBUTES` refuses a read with no projection, which returns the whole item, and an `Allow` gated on `StringEquals` with `dynamodb:ReturnValues` `NONE` grants a write that carries no `ReturnValues`. A `Query` or `Scan` of an index with no projection holds `ALL_PROJECTED_ATTRIBUTES`, even when the index projects every attribute, so a statement meant to refuse whole-item reads of a table and its indexes lists both `ALL_ATTRIBUTES` and `ALL_PROJECTED_ATTRIBUTES`. -These values were measured on DynamoDB on 2026-11-01: `dynamodb:Select` on `GetItem` and `Query` with and without a projection, on `Scan` without one and with `Select` `COUNT`, on `Query` with `Select` `COUNT`, and on a `BatchGetItem` and a `TransactGetItems` `Get` with and without a projection; `dynamodb:ReturnValues` on `PutItem`, `UpdateItem` and `DeleteItem` with and without the parameter, on a `BatchWriteItem`, and on the `Put`, `Update` and `Delete` items of a `TransactWriteItems`; and `dynamodb:ReturnConsumedCapacity` on `GetItem` with and without the parameter, on a `BatchWriteItem` and on a `TransactGetItems` `Get`. `dynamodb:ReturnConsumedCapacity` on the other requests follows those, and a projection written as `AttributesToGet` follows the rule measured for `ProjectionExpression`. `ALL_PROJECTED_ATTRIBUTES` on a read of an index follows the DynamoDB API reference, which gives it as the default when a `Query` or `Scan` of an index has neither `Select` nor `ProjectionExpression`; it was not measured. +These values were measured on DynamoDB on 2026-11-01: `dynamodb:Select` on `GetItem` and `Query` with and without a projection, on `Scan` without one and with `Select` `COUNT`, on `Query` with `Select` `COUNT`, on a `BatchGetItem` with and without a projection, and on a `TransactGetItems` `Get` without one; `dynamodb:ReturnValues` on `PutItem`, `UpdateItem` and `DeleteItem` with and without the parameter, on a `BatchWriteItem`, and on the `Put`, `Update` and `Delete` items of a `TransactWriteItems`; and `dynamodb:ReturnConsumedCapacity` on `GetItem` with and without the parameter, on a `BatchWriteItem` and on a `TransactGetItems` `Get`. `dynamodb:ReturnConsumedCapacity` on the other requests follows those, and a projection written as `AttributesToGet` follows the rule measured for `ProjectionExpression`; neither was measured directly. `SPECIFIC_ATTRIBUTES` on a `Scan` with a projection follows the `Query` and `GetItem` rows and was not measured, and a `TransactGetItems` `Get` with a projection was measured only against a `Deny` on `ALL_ATTRIBUTES`, which it passed, so its `SPECIFIC_ATTRIBUTES` value follows the `BatchGetItem` and `GetItem` rows. A `ConditionCheck` leaving `dynamodb:ReturnValues` absent was not measured. `ALL_PROJECTED_ATTRIBUTES` on a read of an index follows the DynamoDB API reference, which gives it as the default when a `Query` or `Scan` of an index has neither `Select` nor `ProjectionExpression`; it was not measured. ### Resource ARNs From 5c213abf13f65b508b66352400cb52f02f025d1f Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:54:41 +0000 Subject: [PATCH 5/5] fix(server): set condition keys only on operations that take them single_table seeded dynamodb:Select, dynamodb:ReturnValues and dynamodb:ReturnConsumedCapacity from the request body for every operation and then adjusted them per operation, so an operation that does not take a parameter still carried a member of that name from the body. A policy that grants a broad action gated only on such a key could then be satisfied by adding that member to a request the operation does not model: a Select on a write or a control-plane call, a ReturnValues on a read. Build the keys per operation, the rule batch_table and transact_item already follow: Select on GetItem, Query and Scan; ReturnValues on PutItem, UpdateItem and DeleteItem; ReturnConsumedCapacity on the operations that report it; none on a control-plane operation. SearchVectors keeps its ReturnConsumedCapacity, read as given. The measured per-operation defaults do not change; two unit tests that pinned the old copying behaviour now assert the key is absent. --- crates/server/src/parameter_keys.rs | 56 +++++++++++++++-------- crates/server/src/parameter_keys/tests.rs | 47 ++++++++++++------- 2 files changed, 66 insertions(+), 37 deletions(-) diff --git a/crates/server/src/parameter_keys.rs b/crates/server/src/parameter_keys.rs index b3d7333b7..5c7692590 100644 --- a/crates/server/src/parameter_keys.rs +++ b/crates/server/src/parameter_keys.rs @@ -48,7 +48,8 @@ //! default the DynamoDB API reference gives for a read of an index; that was not //! measured either. //! -//! The batch and transaction targets carry only the keys their operation takes. +//! On a single table and in a batch or transaction, an operation carries only +//! the keys it takes; a member an operation does not take is not read. use serde_json::Value; @@ -73,28 +74,43 @@ pub(crate) struct ParameterKeys { pub return_consumed_capacity: Option, } -/// The keys of a request authorized once, against one resource. +/// The keys of a request authorized once, against one resource. Each key is +/// set only on the operations whose request carries that parameter, the rule +/// `batch_table` and `transact_item` follow: a member an operation does not +/// take (a `Select` or `ReturnValues` on a write, any of the three on a +/// control-plane request) is not read, so a condition on that key cannot be +/// satisfied by a member added to a request the operation does not model. pub(crate) fn single_table(operation: &str, input: &Value) -> ParameterKeys { - let mut keys = ParameterKeys { - select: given(input, "Select"), - return_values: given(input, "ReturnValues"), - return_consumed_capacity: given(input, "ReturnConsumedCapacity"), - }; match operation { - "GetItem" => keys.select = Some(projection_select(input).to_owned()), - "Query" | "Scan" => { - keys.select = Some( - keys.select - .unwrap_or_else(|| range_read_select(input).to_owned()), - ); - } - "PutItem" | "UpdateItem" | "DeleteItem" => { - keys.return_values = Some(keys.return_values.unwrap_or_else(|| NONE.to_owned())); - } - _ => return keys, + "GetItem" => ParameterKeys { + select: Some(projection_select(input).to_owned()), + return_values: None, + return_consumed_capacity: Some(consumed_capacity(input)), + }, + "Query" | "Scan" => ParameterKeys { + select: Some( + given(input, "Select").unwrap_or_else(|| range_read_select(input).to_owned()), + ), + return_values: None, + return_consumed_capacity: Some(consumed_capacity(input)), + }, + "PutItem" | "UpdateItem" | "DeleteItem" => ParameterKeys { + select: None, + return_values: Some(given(input, "ReturnValues").unwrap_or_else(|| NONE.to_owned())), + return_consumed_capacity: Some(consumed_capacity(input)), + }, + // SearchVectors takes ReturnConsumedCapacity but no Select or + // ReturnValues. Its value is not measured, so it is read as given and + // left absent when the request omits it, as before. + "SearchVectors" => ParameterKeys { + select: None, + return_values: None, + return_consumed_capacity: given(input, "ReturnConsumedCapacity"), + }, + // Control-plane and every other single-resource operation take none of + // the three. + _ => ParameterKeys::default(), } - keys.return_consumed_capacity = Some(consumed_capacity(input)); - keys } /// The keys of one table's part of a `BatchGetItem` or `BatchWriteItem`: diff --git a/crates/server/src/parameter_keys/tests.rs b/crates/server/src/parameter_keys/tests.rs index f3f859083..e794f6ea6 100644 --- a/crates/server/src/parameter_keys/tests.rs +++ b/crates/server/src/parameter_keys/tests.rs @@ -228,41 +228,44 @@ fn item_operations_default_return_consumed_capacity_to_none() { // -- keys an operation does not take ------------------------------------------- #[test] -fn a_key_the_operation_does_not_take_holds_the_member_as_given() { +fn a_key_the_operation_does_not_take_is_absent() { + // A member an operation does not take must not set its key. Otherwise a + // policy that grants a broad action gated only on, say, dynamodb:Select + // could be satisfied by a Select member added to a write or a DeleteTable. let all = json!({"Select": "COUNT", "ReturnValues": "ALL_OLD"}); for operation in ["PutItem", "UpdateItem", "DeleteItem"] { assert_eq!(select_of(operation, &read()), None, "{operation}"); assert_eq!( - select_of(operation, &with(&read(), &all)).as_deref(), - Some("COUNT") + select_of(operation, &with(&read(), &all)), + None, + "{operation} does not read a Select member" ); } for operation in ["GetItem", "Query", "Scan"] { - assert_eq!(single_table(operation, &read()).return_values, None); assert_eq!( - single_table(operation, &with(&read(), &all)) - .return_values - .as_deref(), - Some("ALL_OLD"), + single_table(operation, &read()).return_values, + None, "{operation}" ); + assert_eq!( + single_table(operation, &with(&read(), &all)).return_values, + None, + "{operation} does not read a ReturnValues member" + ); } } #[test] -fn other_operations_get_no_default() { +fn other_operations_read_only_the_keys_they_take() { let given = json!({ "TableName": "t", "Select": "COUNT", "ReturnValues": "ALL_OLD", "ReturnConsumedCapacity": "TOTAL", }); - for operation in [ - "DescribeTable", - "CreateTable", - "ListTables", - "SearchVectors", - ] { + // Control-plane operations take none of the three keys, so no member of + // those names is read, with or without it in the body. + for operation in ["DescribeTable", "CreateTable", "ListTables", "DeleteTable"] { assert_eq!( single_table(operation, &json!({"TableName": "t"})), ParameterKeys::default(), @@ -270,10 +273,20 @@ fn other_operations_get_no_default() { ); assert_eq!( single_table(operation, &given), - keys(Some("COUNT"), Some("ALL_OLD"), Some("TOTAL")), - "{operation}" + ParameterKeys::default(), + "{operation} reads no member" ); } + // SearchVectors takes ReturnConsumedCapacity, but not Select or ReturnValues. + assert_eq!( + single_table("SearchVectors", &json!({"TableName": "t"})), + ParameterKeys::default() + ); + assert_eq!( + single_table("SearchVectors", &given), + keys(None, None, Some("TOTAL")), + "SearchVectors reads only ReturnConsumedCapacity" + ); } // -- batch and transaction parts -------------------------------------------------