From 992f5503db92c85634700712ccbaa0d71b42fd98 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:19:08 +0000 Subject: [PATCH 01/12] refactor(server): move batch and transaction authorization targets into a submodule leading_keys.rs is 601 lines. Move AuthzTarget, batch_transact_targets, batch_transact_tables and batch_table_keys unchanged into leading_keys/batch.rs, and re-export the two functions that request authorization calls. --- crates/server/src/leading_keys.rs | 199 +--------------------- crates/server/src/leading_keys/batch.rs | 210 ++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 197 deletions(-) create mode 100644 crates/server/src/leading_keys/batch.rs diff --git a/crates/server/src/leading_keys.rs b/crates/server/src/leading_keys.rs index 0f2577985..23fbc5ca2 100644 --- a/crates/server/src/leading_keys.rs +++ b/crates/server/src/leading_keys.rs @@ -69,19 +69,6 @@ use extenddb_core::expression::{ use extenddb_core::types::{KeySchemaElement, KeyType, TableKeyInfo}; use serde_json::Value; -/// A single `(action, table)` authorization to perform for a batch or -/// transaction operation, carrying the LeadingKeys 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). - pub action: String, - /// The table the action is authorized against. - pub table: String, - /// The partition key value(s) for this authorization, or `None` when the - /// key cannot be determined (the engine then rejects the request). - pub leading_keys: Option>, -} - /// Find the HASH (partition) key attribute name of a key schema. fn hash_attr(schema: &[extenddb_core::types::KeySchemaElement]) -> Option<&str> { schema @@ -412,190 +399,8 @@ fn values_map(input: &Value) -> HashMap { out } -/// The per-(action, table) or per-item authorizations for a batch or -/// transaction operation, each carrying the LeadingKeys 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; -/// transaction operations authorize once per item with that item's key, which -/// is how the service evaluates a `ForAnyValue` condition differently for the -/// 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 and LeadingKeys, so -/// a hundred `ConditionCheck`s on one partition need one evaluation. -/// -/// `key_info_by_table` is the authorization snapshot; a `None` entry is a table -/// that does not exist, whose targets carry no LeadingKeys (the engine returns -/// `ResourceNotFoundException`). -pub(crate) fn batch_transact_targets( - operation: &str, - input: &Value, - key_info_by_table: &HashMap>, -) -> Option> { - let pk_attr_of = |table: &str| -> Option { - key_info_by_table - .get(table) - .and_then(Option::as_ref) - .and_then(|ki| hash_attr(&ki.key_schema).map(ToOwned::to_owned)) - }; - let mut seen = std::collections::HashSet::new(); - let mut push_unique = |out: &mut Vec, target: AuthzTarget| { - if seen.insert(target.clone()) { - out.push(target); - } - }; - - match operation { - "BatchGetItem" | "BatchWriteItem" => { - let items = input.get("RequestItems")?.as_object()?; - let mut out = Vec::new(); - for (table, body) in items { - let keys = batch_table_keys(operation, body, pk_attr_of(table).as_deref()); - out.push(AuthzTarget { - action: operation.to_owned(), - table: table.clone(), - leading_keys: keys, - }); - } - Some(out) - } - "TransactGetItems" => { - let items = input.get("TransactItems")?.as_array()?; - let mut out = Vec::new(); - for entry in items { - if let Some(get) = entry.get("Get") - && let Some(table) = get.get("TableName").and_then(Value::as_str) - { - let lk = pk_attr_of(table) - .and_then(|pk| { - get.get("Key") - .and_then(|k| pk_from_map(k, &pk, NumberForm::Decimal)) - }) - .map(|v| vec![v]); - push_unique( - &mut out, - AuthzTarget { - action: "GetItem".to_owned(), - table: table.to_owned(), - leading_keys: lk, - }, - ); - } - } - Some(out) - } - "TransactWriteItems" => { - let items = input.get("TransactItems")?.as_array()?; - let mut out = Vec::new(); - for entry in items { - for (sub, action, key_field) in [ - ("Put", "PutItem", "Item"), - ("Delete", "DeleteItem", "Key"), - ("Update", "UpdateItem", "Key"), - ("ConditionCheck", "ConditionCheckItem", "Key"), - ] { - if let Some(op) = entry.get(sub) - && let Some(table) = op.get("TableName").and_then(Value::as_str) - { - let lk = pk_attr_of(table) - .and_then(|pk| { - op.get(key_field) - .and_then(|m| pk_from_map(m, &pk, NumberForm::Decimal)) - }) - .map(|v| vec![v]); - push_unique( - &mut out, - AuthzTarget { - action: action.to_owned(), - table: table.to_owned(), - leading_keys: lk, - }, - ); - } - } - } - Some(out) - } - _ => None, - } -} - -/// The distinct table names a batch or transaction operation addresses, or -/// `None` for any other operation (and for a batch or transaction request whose -/// items container is absent). The `Some`/`None` result mirrors -/// `batch_transact_targets`, so the caller gates the batch and transaction -/// branch on this and then builds the per-target authorizations once. Walking -/// the body for table names here avoids building a throwaway set of full -/// targets only to discover which tables to fetch key info for. -pub(crate) fn batch_transact_tables( - operation: &str, - input: &Value, -) -> Option> { - let mut tables = std::collections::BTreeSet::new(); - match operation { - "BatchGetItem" | "BatchWriteItem" => { - for table in input.get("RequestItems")?.as_object()?.keys() { - tables.insert(table.clone()); - } - } - "TransactGetItems" => { - for entry in input.get("TransactItems")?.as_array()? { - if let Some(table) = entry - .get("Get") - .and_then(|g| g.get("TableName")) - .and_then(Value::as_str) - { - tables.insert(table.to_owned()); - } - } - } - "TransactWriteItems" => { - for entry in input.get("TransactItems")?.as_array()? { - for sub in ["Put", "Delete", "Update", "ConditionCheck"] { - if let Some(table) = entry - .get(sub) - .and_then(|o| o.get("TableName")) - .and_then(Value::as_str) - { - tables.insert(table.to_owned()); - } - } - } - } - _ => return None, - } - Some(tables) -} - -/// Collect the partition key values a single table receives in a batch request. -/// Returns `None` when the table's key schema is unknown (the engine then -/// rejects), or when no key yields a value. -fn batch_table_keys(operation: &str, body: &Value, pk_attr: Option<&str>) -> Option> { - let pk_attr = pk_attr?; - let mut out = Vec::new(); - if operation == "BatchGetItem" { - for key in body.get("Keys")?.as_array()? { - if let Some(v) = pk_from_map(key, pk_attr, NumberForm::Normalized) { - out.push(v); - } - } - } else { - // BatchWriteItem: a list of {PutRequest:{Item}} / {DeleteRequest:{Key}}. - for req in body.as_array()? { - let map = req - .get("PutRequest") - .and_then(|p| p.get("Item")) - .or_else(|| req.get("DeleteRequest").and_then(|d| d.get("Key"))); - if let Some(m) = map - && let Some(v) = pk_from_map(m, pk_attr, NumberForm::Decimal) - { - out.push(v); - } - } - } - if out.is_empty() { None } else { Some(out) } -} +mod batch; +pub(crate) use batch::{batch_transact_tables, batch_transact_targets}; #[cfg(test)] mod tests; diff --git a/crates/server/src/leading_keys/batch.rs b/crates/server/src/leading_keys/batch.rs new file mode 100644 index 000000000..d18ac7053 --- /dev/null +++ b/crates/server/src/leading_keys/batch.rs @@ -0,0 +1,210 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The per-table and per-item authorizations of a batch or transaction +//! operation. + +use std::collections::HashMap; + +use extenddb_core::types::TableKeyInfo; +use serde_json::Value; + +use super::{NumberForm, hash_attr, pk_from_map}; + +/// A single `(action, table)` authorization to perform for a batch or +/// transaction operation, carrying the LeadingKeys 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). + pub action: String, + /// The table the action is authorized against. + pub table: String, + /// The partition key value(s) for this authorization, or `None` when the + /// key cannot be determined (the engine then rejects the request). + pub leading_keys: Option>, +} + +/// The per-(action, table) or per-item authorizations for a batch or +/// transaction operation, each carrying the LeadingKeys 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; +/// transaction operations authorize once per item with that item's key, which +/// is how the service evaluates a `ForAnyValue` condition differently for the +/// 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 and LeadingKeys, so +/// a hundred `ConditionCheck`s on one partition need one evaluation. +/// +/// `key_info_by_table` is the authorization snapshot; a `None` entry is a table +/// that does not exist, whose targets carry no LeadingKeys (the engine returns +/// `ResourceNotFoundException`). +pub(crate) fn batch_transact_targets( + operation: &str, + input: &Value, + key_info_by_table: &HashMap>, +) -> Option> { + let pk_attr_of = |table: &str| -> Option { + key_info_by_table + .get(table) + .and_then(Option::as_ref) + .and_then(|ki| hash_attr(&ki.key_schema).map(ToOwned::to_owned)) + }; + let mut seen = std::collections::HashSet::new(); + let mut push_unique = |out: &mut Vec, target: AuthzTarget| { + if seen.insert(target.clone()) { + out.push(target); + } + }; + + match operation { + "BatchGetItem" | "BatchWriteItem" => { + let items = input.get("RequestItems")?.as_object()?; + let mut out = Vec::new(); + for (table, body) in items { + let keys = batch_table_keys(operation, body, pk_attr_of(table).as_deref()); + out.push(AuthzTarget { + action: operation.to_owned(), + table: table.clone(), + leading_keys: keys, + }); + } + Some(out) + } + "TransactGetItems" => { + let items = input.get("TransactItems")?.as_array()?; + let mut out = Vec::new(); + for entry in items { + if let Some(get) = entry.get("Get") + && let Some(table) = get.get("TableName").and_then(Value::as_str) + { + let lk = pk_attr_of(table) + .and_then(|pk| { + get.get("Key") + .and_then(|k| pk_from_map(k, &pk, NumberForm::Decimal)) + }) + .map(|v| vec![v]); + push_unique( + &mut out, + AuthzTarget { + action: "GetItem".to_owned(), + table: table.to_owned(), + leading_keys: lk, + }, + ); + } + } + Some(out) + } + "TransactWriteItems" => { + let items = input.get("TransactItems")?.as_array()?; + let mut out = Vec::new(); + for entry in items { + for (sub, action, key_field) in [ + ("Put", "PutItem", "Item"), + ("Delete", "DeleteItem", "Key"), + ("Update", "UpdateItem", "Key"), + ("ConditionCheck", "ConditionCheckItem", "Key"), + ] { + if let Some(op) = entry.get(sub) + && let Some(table) = op.get("TableName").and_then(Value::as_str) + { + let lk = pk_attr_of(table) + .and_then(|pk| { + op.get(key_field) + .and_then(|m| pk_from_map(m, &pk, NumberForm::Decimal)) + }) + .map(|v| vec![v]); + push_unique( + &mut out, + AuthzTarget { + action: action.to_owned(), + table: table.to_owned(), + leading_keys: lk, + }, + ); + } + } + } + Some(out) + } + _ => None, + } +} + +/// The distinct table names a batch or transaction operation addresses, or +/// `None` for any other operation (and for a batch or transaction request whose +/// items container is absent). The `Some`/`None` result mirrors +/// `batch_transact_targets`, so the caller gates the batch and transaction +/// branch on this and then builds the per-target authorizations once. Walking +/// the body for table names here avoids building a throwaway set of full +/// targets only to discover which tables to fetch key info for. +pub(crate) fn batch_transact_tables( + operation: &str, + input: &Value, +) -> Option> { + let mut tables = std::collections::BTreeSet::new(); + match operation { + "BatchGetItem" | "BatchWriteItem" => { + for table in input.get("RequestItems")?.as_object()?.keys() { + tables.insert(table.clone()); + } + } + "TransactGetItems" => { + for entry in input.get("TransactItems")?.as_array()? { + if let Some(table) = entry + .get("Get") + .and_then(|g| g.get("TableName")) + .and_then(Value::as_str) + { + tables.insert(table.to_owned()); + } + } + } + "TransactWriteItems" => { + for entry in input.get("TransactItems")?.as_array()? { + for sub in ["Put", "Delete", "Update", "ConditionCheck"] { + if let Some(table) = entry + .get(sub) + .and_then(|o| o.get("TableName")) + .and_then(Value::as_str) + { + tables.insert(table.to_owned()); + } + } + } + } + _ => return None, + } + Some(tables) +} + +/// Collect the partition key values a single table receives in a batch request. +/// Returns `None` when the table's key schema is unknown (the engine then +/// rejects), or when no key yields a value. +fn batch_table_keys(operation: &str, body: &Value, pk_attr: Option<&str>) -> Option> { + let pk_attr = pk_attr?; + let mut out = Vec::new(); + if operation == "BatchGetItem" { + for key in body.get("Keys")?.as_array()? { + if let Some(v) = pk_from_map(key, pk_attr, NumberForm::Normalized) { + out.push(v); + } + } + } else { + // BatchWriteItem: a list of {PutRequest:{Item}} / {DeleteRequest:{Key}}. + for req in body.as_array()? { + let map = req + .get("PutRequest") + .and_then(|p| p.get("Item")) + .or_else(|| req.get("DeleteRequest").and_then(|d| d.get("Key"))); + if let Some(m) = map + && let Some(v) = pk_from_map(m, pk_attr, NumberForm::Decimal) + { + out.push(v); + } + } + } + if out.is_empty() { None } else { Some(out) } +} From b302f902e11e9b713313188593b52fa1b03eb6aa Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:37:45 +0000 Subject: [PATCH 02/12] fix(server): populate dynamodb:Attributes for every item request The dynamodb:Attributes condition key was read from ProjectionExpression alone, only on single-table requests, and a document path such as secret.a went into it as written. Writes, AttributesToGet reads and every batch and transaction request reached the policy evaluator without the key, so a Deny gated on ForAnyValue:StringEquals over an attribute never applied to them, and an Allow gated on ForAllValues:StringEquals granted them whatever attributes they wrote or read. The key now holds the top-level names a request names: the item or key attributes, AttributesToGet, the paths in the projection, update, condition and filter expressions with ExpressionAttributeNames resolved, the legacy Expected, AttributeUpdates, QueryFilter and ScanFilter maps, ExclusiveStartKey, and the key attributes of the table and of a queried index. A batch request carries the names per table and a transaction per item. A DeleteItem and a read with no projection hold only the names the request gives, not those of the stored item. --- crates/auth/src/policy/condition.rs | 17 +- crates/auth/src/policy/context.rs | 16 +- crates/server/src/leading_keys/batch.rs | 43 +- crates/server/src/leading_keys/tests.rs | 10 + crates/server/src/lib.rs | 1 + crates/server/src/request_attributes.rs | 381 +++++++++++++ crates/server/src/request_attributes/tests.rs | 532 ++++++++++++++++++ crates/server/src/request_helpers.rs | 94 +--- tests/test_tag_request_allow_conditions.py | 9 +- 9 files changed, 999 insertions(+), 104 deletions(-) create mode 100644 crates/server/src/request_attributes.rs create mode 100644 crates/server/src/request_attributes/tests.rs diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 6164cce58..0f7886798 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -129,14 +129,15 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext // `dynamodb:LeadingKeys` and `dynamodb:Attributes`. For a // multivalued key the context decides whether that answer applies: // it is right only where ExtendDB leaves the key absent on the same - // requests the service does. ExtendDB leaves `dynamodb:Attributes` - // absent on writes, on `AttributesToGet` reads and on batch and - // transaction targets, and `dynamodb:LeadingKeys` absent on query - // shapes it cannot pin to a partition value, where the service - // populates them; matching there would grant an Allow the service - // refuses, so those keys keep the no-match rule. `aws:TagKeys` is - // populated on exactly the three operations the service reference - // lists it for, so its absent-key answer is IAM's. + // requests the service does. ExtendDB leaves `dynamodb:LeadingKeys` + // absent on query shapes it cannot pin to a partition value, where + // the service populates it, and `dynamodb:Attributes` absent only + // on a request that names no attribute, where the service's answer + // for a bare negated operator was not measured; matching there + // could grant an Allow the service refuses, so those keys keep the + // no-match rule. `aws:TagKeys` is populated on exactly the three + // operations the service reference lists it for, so its absent-key + // answer is IAM's. None => { is_negative_operator(other) && (!is_multivalued diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index be764624c..ed3372726 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -457,13 +457,15 @@ impl ConditionContext for RequestContext { // ExtendDB applies that only to `aws:TagKeys`, which it populates on // exactly the three operations the AWS service authorization reference // lists it for (TagResource, UntagResource, CreateTable with Tags), so - // its absence here is the service's absence. `dynamodb:Attributes` is - // left absent on writes, on reads that project with `AttributesToGet` - // and on batch and transaction targets, and `dynamodb:LeadingKeys` on - // query shapes the extractor cannot pin to a partition value, where the - // service populates both; for those two an absent key keeps the - // no-match rule, so an Allow gated on a bare negated operator over them - // grants nothing, as the service grants nothing for the present key. + // its absence here is the service's absence. `dynamodb:LeadingKeys` is + // left absent on query shapes the extractor cannot pin to a partition + // value, where the service populates it. `dynamodb:Attributes` is + // absent only on a request that names no attribute (a `Scan` that + // projects and filters nothing, or a request on a table that does not + // exist), where the service's answer for a bare negated operator was + // not measured. For those two an absent key keeps the no-match rule, + // so an Allow gated on a bare negated operator over them grants + // nothing. super::casefold::case_fold(key) == "aws:tagkeys" } } diff --git a/crates/server/src/leading_keys/batch.rs b/crates/server/src/leading_keys/batch.rs index d18ac7053..ca0eacec2 100644 --- a/crates/server/src/leading_keys/batch.rs +++ b/crates/server/src/leading_keys/batch.rs @@ -6,13 +6,16 @@ use std::collections::HashMap; +use extenddb_core::limits::LimitsConfig; use extenddb_core::types::TableKeyInfo; use serde_json::Value; use super::{NumberForm, hash_attr, pk_from_map}; +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 the service would evaluate. +/// transaction operation, carrying the LeadingKeys and Attributes 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). @@ -22,10 +25,14 @@ pub(crate) struct AuthzTarget { /// The partition key value(s) for this authorization, or `None` when the /// key cannot be determined (the engine then rejects the request). pub leading_keys: Option>, + /// The `dynamodb:Attributes` names for this authorization, or `None` when + /// it names no attribute. See `crate::request_attributes`. + pub attributes: Option>, } /// The per-(action, table) or per-item authorizations for a batch or -/// transaction operation, each carrying the LeadingKeys the service evaluates. +/// transaction operation, each carrying the LeadingKeys and Attributes 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; @@ -34,22 +41,25 @@ 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 and LeadingKeys, so -/// a hundred `ConditionCheck`s on one partition need one evaluation. +/// 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. /// /// `key_info_by_table` is the authorization snapshot; a `None` entry is a table -/// that does not exist, whose targets carry no LeadingKeys (the engine returns -/// `ResourceNotFoundException`). +/// that does not exist, whose targets carry no LeadingKeys and none of the +/// table's key attributes in Attributes (the engine returns +/// `ResourceNotFoundException`). `limits` are the engine's expression limits, +/// under which the expressions are read for Attributes. pub(crate) fn batch_transact_targets( operation: &str, input: &Value, key_info_by_table: &HashMap>, + limits: &LimitsConfig, ) -> Option> { + let key_info_of = + |table: &str| -> Option<&TableKeyInfo> { key_info_by_table.get(table)?.as_ref() }; let pk_attr_of = |table: &str| -> Option { - key_info_by_table - .get(table) - .and_then(Option::as_ref) - .and_then(|ki| hash_attr(&ki.key_schema).map(ToOwned::to_owned)) + key_info_of(table).and_then(|ki| hash_attr(&ki.key_schema).map(ToOwned::to_owned)) }; let mut seen = std::collections::HashSet::new(); let mut push_unique = |out: &mut Vec, target: AuthzTarget| { @@ -68,6 +78,7 @@ pub(crate) fn batch_transact_targets( action: operation.to_owned(), table: table.clone(), leading_keys: keys, + attributes: batch_table_attributes(operation, body, key_info_of(table), limits), }); } Some(out) @@ -91,6 +102,12 @@ pub(crate) fn batch_transact_targets( action: "GetItem".to_owned(), table: table.to_owned(), leading_keys: lk, + attributes: transact_item_attributes( + "Get", + get, + key_info_of(table), + limits, + ), }, ); } @@ -122,6 +139,12 @@ pub(crate) fn batch_transact_targets( action: action.to_owned(), table: table.to_owned(), leading_keys: lk, + attributes: transact_item_attributes( + sub, + op, + key_info_of(table), + limits, + ), }, ); } diff --git a/crates/server/src/leading_keys/tests.rs b/crates/server/src/leading_keys/tests.rs index c81e3baaa..51de039d5 100644 --- a/crates/server/src/leading_keys/tests.rs +++ b/crates/server/src/leading_keys/tests.rs @@ -14,6 +14,16 @@ use serde_json::json; const MAX_TOKENS: usize = 4096; +/// The batch and transaction targets under the default expression limits. +fn batch_transact_targets( + operation: &str, + input: &Value, + key_info: &HashMap>, +) -> Option> { + let limits = extenddb_core::limits::LimitsConfig::default(); + super::batch_transact_targets(operation, input, key_info, &limits) +} + fn ks(name: &str, kt: KeyType) -> KeySchemaElement { KeySchemaElement { attribute_name: name.to_owned(), diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 3b1b30309..a71535ec8 100755 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -20,6 +20,7 @@ mod leading_keys; pub mod management; mod metrics_endpoint; pub mod rate_limit; +mod request_attributes; mod request_helpers; mod request_tags; mod response; diff --git a/crates/server/src/request_attributes.rs b/crates/server/src/request_attributes.rs new file mode 100644 index 000000000..3bc74b353 --- /dev/null +++ b/crates/server/src/request_attributes.rs @@ -0,0 +1,381 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `dynamodb:Attributes` extraction for request authorization. +//! +//! IAM evaluates `dynamodb:Attributes` against the top-level attribute names a +//! request accesses. A top-level attribute is accessed when the request names +//! it, or a nested attribute inside it, in a request parameter: a projection of +//! `Name, Address.City` gives `Name` and `Address`. The names come from the +//! request alone. A `DeleteItem` and a read with no projection hold only the +//! names the request gives, not the names of the stored item they delete or +//! return (measured 2026-10-31), and `ReturnValues` and `Select` add no names. +//! +//! What the key holds for each operation: +//! +//! - `GetItem`: the table's key attributes, `AttributesToGet`, and the paths in +//! `ProjectionExpression`. +//! - `PutItem`: the table's key attributes, the item's attributes, `Expected`, +//! and the paths in `ConditionExpression`. +//! - `UpdateItem`: the `Key` attributes, the table's key attributes, +//! `AttributeUpdates`, `Expected`, and the paths in `UpdateExpression` (the +//! attribute each action names and the paths in its value) and in +//! `ConditionExpression`. +//! - `DeleteItem`: the `Key` attributes, the table's key attributes, `Expected`, +//! and the paths in `ConditionExpression`. +//! - `Query`: the key attributes of the table and of the queried index, +//! `AttributesToGet`, `QueryFilter`, `ExclusiveStartKey`, and the paths in +//! `FilterExpression` and `ProjectionExpression`. A key condition can name +//! only key attributes, which the key already holds. +//! - `Scan`: `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the paths +//! in `FilterExpression` and `ProjectionExpression`. A `Scan` names no key, so +//! a `Scan` that gives none of these leaves the key absent. +//! - `SearchVectors`: the paths in `ProjectionExpression`. +//! - `BatchGetItem`: per table, the attributes of every key, the table's key +//! attributes, `AttributesToGet`, and the paths in `ProjectionExpression`. +//! - `BatchWriteItem`: per table, the attributes of every `PutRequest` item and +//! every `DeleteRequest` key, and the table's key attributes. +//! - `TransactGetItems` and `TransactWriteItems`: per item, as for the +//! single-item operation the item maps to. A `ConditionCheck` holds the +//! table's key attributes and the paths in its `ConditionExpression`. +//! +//! The service's answers were measured on 2026-10-31 for the item attributes of +//! `PutItem`, the attribute a `SET` action names (by name and through +//! `ExpressionAttributeNames`), `DeleteItem`, `AttributesToGet` on `GetItem` and +//! `Scan`, a `ProjectionExpression` naming a document path, reads with no +//! projection, and the batch and transaction forms of those requests. The rest +//! follows the IAM documentation for the key: it holds the attributes a request +//! names, including those read in a condition or a filter, and a policy that +//! lists the allowed attributes must list the key attributes of the table and of +//! its indexes. +//! +//! An expression is read with the tokenizer and parser the engine uses, under +//! the same size, token and depth limits, so every expression the engine +//! accepts gives its names here. An expression the engine refuses gives none: +//! the engine then answers `ValidationException` without reading or writing an +//! item. A `#name` with no `ExpressionAttributeNames` entry is skipped for the +//! same reason. + +use std::collections::BTreeSet; + +use extenddb_core::expression::{ + Expr, ExpressionKind, PathElement, UpdateAction, parse_condition_with_depth_limit, + parse_projection, parse_update_from, tokenize_for, tokenize_with_limit, +}; +use extenddb_core::limits::LimitsConfig; +use extenddb_core::types::TableKeyInfo; +use serde_json::{Map, Value}; + +/// The `dynamodb:Attributes` value of a single-table operation: the sorted, +/// distinct top-level names the request accesses. `None` when it names none, +/// and for an operation that reads or writes no item. +pub(crate) fn single_table_attributes( + operation: &str, + input: &Value, + key_info: Option<&TableKeyInfo>, + limits: &LimitsConfig, +) -> Option> { + let mut names = Names::new(input, limits); + match operation { + "GetItem" => names.get_item(input, key_info), + "PutItem" => names.put_item(input, key_info), + "UpdateItem" => names.update_item(input, key_info), + "DeleteItem" => names.delete_item(input, key_info), + "Query" => { + names.key_schema(key_info); + names.index_key_schema(input, key_info); + names.map_keys(input.get("QueryFilter")); + names.range_read(input); + } + "Scan" => { + names.map_keys(input.get("ScanFilter")); + names.range_read(input); + } + "SearchVectors" => names.projection(input.get("ProjectionExpression")), + _ => return None, + } + names.finish() +} + +/// The `dynamodb:Attributes` value of one table's part of a `BatchGetItem` or +/// `BatchWriteItem`: `body` is that table's `RequestItems` entry. `None` for +/// any other operation, and when the part names no attribute. +pub(crate) fn batch_table_attributes( + operation: &str, + body: &Value, + key_info: Option<&TableKeyInfo>, + limits: &LimitsConfig, +) -> Option> { + let mut names = Names::new(body, limits); + match operation { + "BatchGetItem" => { + for key in body + .get("Keys") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + names.map_keys(Some(key)); + } + names.get_item(body, key_info); + } + "BatchWriteItem" => { + for request in body.as_array().into_iter().flatten() { + names.map_keys(request.get("PutRequest").and_then(|p| p.get("Item"))); + names.map_keys(request.get("DeleteRequest").and_then(|d| d.get("Key"))); + } + names.key_schema(key_info); + } + _ => return None, + } + names.finish() +} + +/// The `dynamodb:Attributes` value of one transaction item: `sub` is the +/// item's member name (`Get`, `Put`, `Update`, `Delete` or `ConditionCheck`) +/// and `item` its body. `None` for any other member, and when the item names +/// no attribute. +pub(crate) fn transact_item_attributes( + sub: &str, + item: &Value, + key_info: Option<&TableKeyInfo>, + limits: &LimitsConfig, +) -> Option> { + let mut names = Names::new(item, limits); + match sub { + "Get" => { + names.key_schema(key_info); + names.projection(item.get("ProjectionExpression")); + } + "Put" => names.put_item(item, key_info), + "Update" => names.update_item(item, key_info), + "Delete" => names.delete_item(item, key_info), + "ConditionCheck" => { + names.key_schema(key_info); + names.condition(item.get("ConditionExpression")); + } + _ => return None, + } + names.finish() +} + +/// The top-level names one request (or one batch part or transaction item) +/// accesses, with the `ExpressionAttributeNames` its expressions resolve +/// against. +struct Names<'a> { + found: BTreeSet, + expression_names: Option<&'a Map>, + limits: &'a LimitsConfig, +} + +impl<'a> Names<'a> { + fn new(request: &'a Value, limits: &'a LimitsConfig) -> Self { + Self { + found: BTreeSet::new(), + expression_names: request + .get("ExpressionAttributeNames") + .and_then(Value::as_object), + limits, + } + } + + fn get_item(&mut self, request: &Value, key_info: Option<&TableKeyInfo>) { + self.key_schema(key_info); + self.list(request.get("AttributesToGet")); + self.projection(request.get("ProjectionExpression")); + } + + fn put_item(&mut self, request: &Value, key_info: Option<&TableKeyInfo>) { + self.key_schema(key_info); + self.map_keys(request.get("Item")); + self.map_keys(request.get("Expected")); + self.condition(request.get("ConditionExpression")); + } + + fn update_item(&mut self, request: &Value, key_info: Option<&TableKeyInfo>) { + self.map_keys(request.get("Key")); + self.key_schema(key_info); + self.map_keys(request.get("AttributeUpdates")); + self.map_keys(request.get("Expected")); + self.condition(request.get("ConditionExpression")); + self.update(request.get("UpdateExpression")); + } + + fn delete_item(&mut self, request: &Value, key_info: Option<&TableKeyInfo>) { + self.map_keys(request.get("Key")); + self.key_schema(key_info); + self.map_keys(request.get("Expected")); + self.condition(request.get("ConditionExpression")); + } + + /// The parameters `Query` and `Scan` share. + fn range_read(&mut self, request: &Value) { + self.list(request.get("AttributesToGet")); + self.map_keys(request.get("ExclusiveStartKey")); + self.condition(request.get("FilterExpression")); + self.projection(request.get("ProjectionExpression")); + } + + /// The table's key attributes. A table that does not exist adds none. + fn key_schema(&mut self, key_info: Option<&TableKeyInfo>) { + for element in key_info.into_iter().flat_map(|ki| &ki.key_schema) { + self.found.insert(element.attribute_name.clone()); + } + } + + /// The key attributes of the index a `Query` names. An index the table + /// does not have adds none: the engine refuses that request. + fn index_key_schema(&mut self, request: &Value, key_info: Option<&TableKeyInfo>) { + let (Some(key_info), Some(index)) = + (key_info, request.get("IndexName").and_then(Value::as_str)) + else { + return; + }; + let found = key_info + .global_secondary_indexes + .iter() + .chain(&key_info.local_secondary_indexes) + .find(|i| i.index_name == index); + for element in found.into_iter().flat_map(|i| &i.key_schema) { + self.found.insert(element.attribute_name.clone()); + } + } + + /// The keys of a map of top-level attribute names (`Item`, `Key`, + /// `Expected`, `AttributeUpdates`, `QueryFilter`, `ScanFilter`, + /// `ExclusiveStartKey`), as written. + fn map_keys(&mut self, value: Option<&Value>) { + if let Some(map) = value.and_then(Value::as_object) { + self.found.extend(map.keys().cloned()); + } + } + + /// The entries of `AttributesToGet`, as written: each is a top-level name, + /// so a `.` in one is part of the name, not a path separator. + fn list(&mut self, value: Option<&Value>) { + let entries = value.and_then(Value::as_array).into_iter().flatten(); + self.found + .extend(entries.filter_map(Value::as_str).map(ToOwned::to_owned)); + } + + /// The expression text in `value`, or `None` when there is none or it is + /// over the size limit the engine refuses it under. + fn text<'v>(&self, value: Option<&'v Value>) -> Option<&'v str> { + value + .and_then(Value::as_str) + .filter(|s| !s.is_empty() && s.len() <= self.limits.max_expression_length_bytes) + } + + /// The paths in a `ConditionExpression` or `FilterExpression`. + fn condition(&mut self, value: Option<&Value>) { + let Some(text) = self.text(value) else { + return; + }; + let parsed = + tokenize_with_limit(text, self.limits.max_expression_tokens).and_then(|tokens| { + parse_condition_with_depth_limit(&tokens, self.limits.max_expression_depth) + }); + if let Ok(expr) = parsed { + self.expr(&expr); + } + } + + /// The attribute each `UpdateExpression` action names and the paths in its + /// value. + fn update(&mut self, value: Option<&Value>) { + let Some(text) = self.text(value) else { + return; + }; + let parsed = tokenize_for( + text, + self.limits.max_expression_tokens, + ExpressionKind::Update, + ) + .and_then(|tokens| parse_update_from(&tokens, text)); + for action in parsed.iter().flatten() { + match action { + UpdateAction::Set { path, value } + | UpdateAction::Add { path, value } + | UpdateAction::Delete { path, value } => { + self.path(path); + self.expr(value); + } + UpdateAction::Remove { path } => self.path(path), + } + } + } + + /// The paths in a `ProjectionExpression`. + fn projection(&mut self, value: Option<&Value>) { + let Some(text) = self.text(value) else { + return; + }; + let parsed = tokenize_for( + text, + self.limits.max_expression_tokens, + ExpressionKind::Projection, + ) + .and_then(|tokens| parse_projection(&tokens)); + for path in parsed.iter().flatten() { + self.path(path); + } + } + + fn expr(&mut self, expr: &Expr) { + match expr { + Expr::Path(path) => self.path(path), + Expr::Placeholder(_) => {} + Expr::Compare { left, right, .. } | Expr::Arithmetic { left, right, .. } => { + self.expr(left); + self.expr(right); + } + Expr::And(left, right) | Expr::Or(left, right) => { + self.expr(left); + self.expr(right); + } + Expr::Not(inner) => self.expr(inner), + Expr::Function { args, .. } => args.iter().for_each(|arg| self.expr(arg)), + Expr::Between { operand, low, high } => { + self.expr(operand); + self.expr(low); + self.expr(high); + } + Expr::In { operand, list } => { + self.expr(operand); + list.iter().for_each(|item| self.expr(item)); + } + } + } + + /// The top-level name of a document path: its first element, with a + /// `#name` resolved through `ExpressionAttributeNames`. + fn path(&mut self, path: &[PathElement]) { + let Some(PathElement::Attribute(first)) = path.first() else { + return; + }; + let name = if first.starts_with('#') { + match self + .expression_names + .and_then(|names| names.get(first)) + .and_then(Value::as_str) + { + Some(name) => name, + None => return, + } + } else { + first.as_str() + }; + self.found.insert(name.to_owned()); + } + + fn finish(self) -> Option> { + if self.found.is_empty() { + None + } else { + Some(self.found.into_iter().collect()) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/server/src/request_attributes/tests.rs b/crates/server/src/request_attributes/tests.rs new file mode 100644 index 000000000..4ac14a1cf --- /dev/null +++ b/crates/server/src/request_attributes/tests.rs @@ -0,0 +1,532 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Unit tests for `dynamodb:Attributes` extraction. +//! +//! The rows named "measured" mirror the service's answers of 2026-10-31; the +//! rest follow the IAM documentation for the key. + +use std::collections::HashMap; + +use super::*; +use extenddb_core::types::{ + IndexInfo, IndexType, KeySchemaElement, KeyType, Projection, ProjectionType, +}; +use serde_json::json; + +fn ks(name: &str, key_type: KeyType) -> KeySchemaElement { + KeySchemaElement { + attribute_name: name.to_owned(), + key_type, + } +} + +fn index(name: &str, index_type: IndexType, key_schema: Vec) -> IndexInfo { + IndexInfo { + index_name: name.to_owned(), + index_id: name.to_owned(), + index_type, + key_schema, + projection: Projection { + projection_type: ProjectionType::All, + non_key_attributes: None, + }, + } +} + +/// A table keyed on `pk` alone, as the measured table was. +fn hash_table() -> TableKeyInfo { + TableKeyInfo { + table_name: "t".to_owned(), + key_schema: vec![ks("pk", KeyType::Hash)], + base_key_schema: vec![ks("pk", KeyType::Hash)], + ..Default::default() + } +} + +/// A table keyed on `pk` and `sk`, with a GSI on `gpk`/`gsk` and an LSI on +/// `pk`/`lsk`. +fn indexed_table() -> TableKeyInfo { + TableKeyInfo { + table_name: "i".to_owned(), + key_schema: vec![ks("pk", KeyType::Hash), ks("sk", KeyType::Range)], + base_key_schema: vec![ks("pk", KeyType::Hash), ks("sk", KeyType::Range)], + global_secondary_indexes: vec![index( + "gsi1", + IndexType::Gsi, + vec![ks("gpk", KeyType::Hash), ks("gsk", KeyType::Range)], + )], + local_secondary_indexes: vec![index( + "lsi1", + IndexType::Lsi, + vec![ks("pk", KeyType::Hash), ks("lsk", KeyType::Range)], + )], + ..Default::default() + } +} + +fn single(operation: &str, input: &Value, key_info: Option<&TableKeyInfo>) -> Option> { + single_table_attributes(operation, input, key_info, &LimitsConfig::default()) +} + +fn names(list: &[&str]) -> Option> { + Some(list.iter().map(|s| (*s).to_owned()).collect()) +} + +// ---- writes --------------------------------------------------------------- + +#[test] +fn put_item_holds_the_key_schema_and_every_item_attribute() { + // Measured: PutItem {pk, ssn} matches a ForAnyValue Deny on ssn, and + // {pk, name} does not. + let input = json!({"TableName": "t", "Item": {"pk": {"S": "a"}, "ssn": {"S": "1"}}}); + assert_eq!( + single("PutItem", &input, Some(&hash_table())), + names(&["pk", "ssn"]) + ); + let input = json!({"TableName": "t", "Item": {"pk": {"S": "a"}}}); + assert_eq!( + single("PutItem", &input, Some(&hash_table())), + names(&["pk"]) + ); +} + +#[test] +fn put_item_adds_the_names_its_condition_reads() { + let input = json!({ + "TableName": "t", + "Item": {"pk": {"S": "a"}}, + "ConditionExpression": + "attribute_not_exists(#p) AND (size(secret.a) > :n OR ssn IN (:x, :y))", + "ExpressionAttributeNames": {"#p": "pk"}, + }); + assert_eq!( + single("PutItem", &input, Some(&hash_table())), + names(&["pk", "secret", "ssn"]) + ); + let legacy = json!({ + "TableName": "t", + "Item": {"pk": {"S": "a"}}, + "Expected": {"ssn": {"Exists": false}}, + }); + assert_eq!( + single("PutItem", &legacy, Some(&hash_table())), + names(&["pk", "ssn"]) + ); +} + +#[test] +fn update_item_holds_the_attribute_set_names() { + // Measured: `SET ssn = :v` matches a Deny on ssn; `SET #n = :v` with + // `#n` = `name` does not. + let input = json!({ + "TableName": "t", + "Key": {"pk": {"S": "a"}}, + "UpdateExpression": "SET ssn = :v", + }); + assert_eq!( + single("UpdateItem", &input, Some(&hash_table())), + names(&["pk", "ssn"]) + ); + let input = json!({ + "TableName": "t", + "Key": {"pk": {"S": "a"}}, + "UpdateExpression": "SET #n = :v", + "ExpressionAttributeNames": {"#n": "name"}, + }); + assert_eq!( + single("UpdateItem", &input, Some(&hash_table())), + names(&["name", "pk"]) + ); +} + +#[test] +fn update_item_holds_every_action_target_and_value_path() { + let input = json!({ + "TableName": "t", + "Key": {"pk": {"S": "a"}}, + "UpdateExpression": "SET a = b + :v, c.d = list_append(e, :l), \ + f = if_not_exists(g[0].h, :z) REMOVE i.j[2] ADD k :n DELETE #m :s", + "ConditionExpression": "attribute_exists(x.y)", + "ExpressionAttributeNames": {"#m": "mset"}, + }); + assert_eq!( + single("UpdateItem", &input, Some(&hash_table())), + names(&["a", "b", "c", "e", "f", "g", "i", "k", "mset", "pk", "x"]) + ); +} + +#[test] +fn update_item_holds_the_legacy_attribute_updates_and_expected() { + let input = json!({ + "TableName": "t", + "Key": {"pk": {"S": "a"}}, + "AttributeUpdates": {"ssn": {"Action": "DELETE"}}, + "Expected": {"name": {"Value": {"S": "n"}}}, + }); + assert_eq!( + single("UpdateItem", &input, Some(&hash_table())), + names(&["name", "pk", "ssn"]) + ); +} + +#[test] +fn delete_item_holds_the_key_and_condition_names_only() { + // Measured: DeleteItem of an item that holds ssn is allowed under a Deny + // on ssn, so the stored item's attributes are not in the key. + let input = json!({"TableName": "t", "Key": {"pk": {"S": "a"}}}); + assert_eq!( + single("DeleteItem", &input, Some(&hash_table())), + names(&["pk"]) + ); + let input = json!({ + "TableName": "t", + "Key": {"pk": {"S": "a"}}, + "ConditionExpression": "ssn = :v", + }); + assert_eq!( + single("DeleteItem", &input, Some(&hash_table())), + names(&["pk", "ssn"]) + ); +} + +#[test] +fn return_values_and_select_add_no_names() { + let input = json!({ + "TableName": "t", + "Item": {"pk": {"S": "a"}}, + "ReturnValues": "ALL_OLD", + }); + assert_eq!( + single("PutItem", &input, Some(&hash_table())), + names(&["pk"]) + ); + let input = json!({"TableName": "t", "Select": "ALL_ATTRIBUTES"}); + assert_eq!(single("Scan", &input, Some(&hash_table())), None); +} + +// ---- reads ---------------------------------------------------------------- + +#[test] +fn get_item_holds_attributes_to_get_as_written() { + // Measured: AttributesToGet=[ssn] matches a Deny on ssn. Each entry is a + // top-level name, so `a.b` is one name. + let input = json!({ + "TableName": "t", + "Key": {"pk": {"S": "a"}}, + "AttributesToGet": ["ssn", "a.b"], + }); + assert_eq!( + single("GetItem", &input, Some(&hash_table())), + names(&["a.b", "pk", "ssn"]) + ); +} + +#[test] +fn get_item_holds_the_top_level_name_of_each_projected_path() { + // Measured: `secret.a` and `#s.a` (`#s` = `secret`) both match a Deny on + // secret. + let input = json!({ + "TableName": "t", + "Key": {"pk": {"S": "a"}}, + "ProjectionExpression": "secret.a, #s.b, l[0].c, #n", + "ExpressionAttributeNames": {"#s": "other", "#n": "name"}, + }); + assert_eq!( + single("GetItem", &input, Some(&hash_table())), + names(&["l", "name", "other", "pk", "secret"]) + ); +} + +#[test] +fn get_item_with_no_projection_holds_only_the_key_schema() { + // Measured: GetItem with no projection is allowed under a Deny on ssn and + // returns the whole item. + let input = json!({"TableName": "i", "Key": {"pk": {"S": "a"}, "sk": {"S": "b"}}}); + assert_eq!( + single("GetItem", &input, Some(&indexed_table())), + names(&["pk", "sk"]) + ); +} + +#[test] +fn a_table_that_does_not_exist_adds_no_key_schema() { + let input = json!({"TableName": "gone", "Key": {"pk": {"S": "a"}}}); + assert_eq!(single("GetItem", &input, None), None); + let input = + json!({"TableName": "gone", "Key": {"pk": {"S": "a"}}, "ProjectionExpression": "ssn"}); + assert_eq!(single("GetItem", &input, None), names(&["ssn"])); +} + +#[test] +fn query_holds_the_table_and_index_keys_and_its_read_parameters() { + let input = json!({ + "TableName": "i", + "IndexName": "gsi1", + "KeyConditionExpression": "gpk = :g", + "FilterExpression": "ssn = :s", + "ProjectionExpression": "#n.first", + "ExpressionAttributeNames": {"#n": "name"}, + "ExclusiveStartKey": {"pk": {"S": "a"}, "sk": {"S": "b"}, "gpk": {"S": "g"}, "gsk": {"S": "h"}}, + }); + assert_eq!( + single("Query", &input, Some(&indexed_table())), + names(&["gpk", "gsk", "name", "pk", "sk", "ssn"]) + ); + let lsi = json!({"TableName": "i", "IndexName": "lsi1", "KeyConditionExpression": "pk = :p"}); + assert_eq!( + single("Query", &lsi, Some(&indexed_table())), + names(&["lsk", "pk", "sk"]) + ); +} + +#[test] +fn query_holds_the_legacy_filter_and_attributes_to_get() { + let input = json!({ + "TableName": "i", + "KeyConditions": {"pk": {"ComparisonOperator": "EQ", "AttributeValueList": [{"S": "a"}]}}, + "QueryFilter": {"ssn": {"ComparisonOperator": "NOT_NULL"}}, + "AttributesToGet": ["name"], + }); + assert_eq!( + single("Query", &input, Some(&indexed_table())), + names(&["name", "pk", "sk", "ssn"]) + ); +} + +#[test] +fn scan_with_no_named_attribute_leaves_the_key_absent() { + // Measured: Scan with Limit=2 and no projection is allowed under a Deny on + // ssn and returns whole items. + let input = json!({"TableName": "t", "Limit": 2}); + assert_eq!(single("Scan", &input, Some(&hash_table())), None); +} + +#[test] +fn scan_holds_its_filter_projection_and_start_key_names() { + // Measured: Scan with AttributesToGet=[ssn] matches a Deny on ssn. + let input = json!({"TableName": "t", "AttributesToGet": ["ssn"]}); + assert_eq!(single("Scan", &input, Some(&hash_table())), names(&["ssn"])); + let input = json!({ + "TableName": "t", + "FilterExpression": "begins_with(#c, :p)", + "ProjectionExpression": "name", + "ExpressionAttributeNames": {"#c": "code"}, + "ExclusiveStartKey": {"pk": {"S": "a"}}, + "ScanFilter": {"age": {"ComparisonOperator": "NULL"}}, + }); + assert_eq!( + single("Scan", &input, Some(&hash_table())), + names(&["age", "code", "name", "pk"]) + ); +} + +#[test] +fn search_vectors_holds_its_projected_paths() { + let input = json!({ + "TableName": "t", + "IndexName": "vi", + "ProjectionExpression": "#v.x, name", + "ExpressionAttributeNames": {"#v": "vec"}, + }); + assert_eq!( + single("SearchVectors", &input, Some(&hash_table())), + names(&["name", "vec"]) + ); +} + +#[test] +fn an_operation_that_touches_no_item_has_no_attributes() { + let input = json!({"TableName": "t", "ProjectionExpression": "ssn"}); + assert_eq!(single("DescribeTable", &input, Some(&hash_table())), None); + assert_eq!(single("ListTables", &json!({}), None), None); +} + +// ---- expressions the engine refuses --------------------------------------- + +#[test] +fn an_unresolved_name_placeholder_is_skipped() { + let input = json!({ + "TableName": "t", + "Key": {"pk": {"S": "a"}}, + "ProjectionExpression": "#missing, ssn", + }); + assert_eq!( + single("GetItem", &input, Some(&hash_table())), + names(&["pk", "ssn"]) + ); +} + +#[test] +fn an_expression_the_engine_refuses_gives_no_names() { + let input = json!({ + "TableName": "t", + "Item": {"pk": {"S": "a"}}, + "ConditionExpression": "ssn = = :v", + }); + assert_eq!( + single("PutItem", &input, Some(&hash_table())), + names(&["pk"]) + ); + + let tight = LimitsConfig { + max_expression_tokens: 3, + ..LimitsConfig::default() + }; + let input = json!({"TableName": "gone", "ProjectionExpression": "a, b, c"}); + assert_eq!( + single_table_attributes("GetItem", &input, None, &tight), + None + ); + + let small = LimitsConfig { + max_expression_length_bytes: 4, + ..LimitsConfig::default() + }; + let input = json!({"TableName": "gone", "ProjectionExpression": "abcde"}); + assert_eq!( + single_table_attributes("GetItem", &input, None, &small), + None + ); + let input = json!({"TableName": "gone", "ProjectionExpression": "abcd"}); + assert_eq!( + single_table_attributes("GetItem", &input, None, &small), + names(&["abcd"]) + ); +} + +// ---- batch and transaction requests --------------------------------------- + +#[test] +fn batch_get_item_holds_every_key_and_the_projection_per_table() { + // Measured: BatchGetItem with ProjectionExpression=ssn matches a Deny on + // ssn; with no projection it does not. + let body = json!({ + "Keys": [{"pk": {"S": "a"}}, {"pk": {"S": "b"}}], + "ProjectionExpression": "ssn", + }); + let limits = LimitsConfig::default(); + assert_eq!( + batch_table_attributes("BatchGetItem", &body, Some(&hash_table()), &limits), + names(&["pk", "ssn"]) + ); + let body = json!({"Keys": [{"pk": {"S": "a"}}], "AttributesToGet": ["name"]}); + assert_eq!( + batch_table_attributes("BatchGetItem", &body, Some(&hash_table()), &limits), + names(&["name", "pk"]) + ); + let body = json!({"Keys": [{"pk": {"S": "a"}}]}); + assert_eq!( + batch_table_attributes("BatchGetItem", &body, Some(&hash_table()), &limits), + names(&["pk"]) + ); +} + +#[test] +fn batch_write_item_holds_every_put_item_and_delete_key() { + // Measured: a PutRequest of {pk, ssn} matches a Deny on ssn. + let body = json!([ + {"PutRequest": {"Item": {"pk": {"S": "a"}, "ssn": {"S": "1"}}}}, + {"PutRequest": {"Item": {"pk": {"S": "b"}, "name": {"S": "n"}}}}, + {"DeleteRequest": {"Key": {"pk": {"S": "c"}}}}, + ]); + let limits = LimitsConfig::default(); + assert_eq!( + batch_table_attributes("BatchWriteItem", &body, Some(&hash_table()), &limits), + names(&["name", "pk", "ssn"]) + ); + assert_eq!( + batch_table_attributes("Scan", &body, Some(&hash_table()), &limits), + None + ); +} + +#[test] +fn transaction_items_hold_what_their_single_item_operation_holds() { + // Measured: a TransactGetItems Get with ProjectionExpression=ssn and a + // TransactWriteItems Put of {pk, ssn} match a Deny on ssn. + let ki = Some(hash_table()); + let limits = LimitsConfig::default(); + let item = |sub: &str, body: Value| transact_item_attributes(sub, &body, ki.as_ref(), &limits); + assert_eq!( + item( + "Get", + json!({"Key": {"pk": {"S": "a"}}, "ProjectionExpression": "ssn"}) + ), + names(&["pk", "ssn"]) + ); + assert_eq!( + item("Get", json!({"Key": {"pk": {"S": "a"}}})), + names(&["pk"]) + ); + assert_eq!( + item( + "Put", + json!({"Item": {"pk": {"S": "a"}, "ssn": {"S": "1"}}}) + ), + names(&["pk", "ssn"]) + ); + assert_eq!( + item( + "Update", + json!({"Key": {"pk": {"S": "a"}}, "UpdateExpression": "REMOVE ssn"}) + ), + names(&["pk", "ssn"]) + ); + assert_eq!( + item( + "Delete", + json!({"Key": {"pk": {"S": "a"}}, "ConditionExpression": "attribute_exists(ssn)"}) + ), + names(&["pk", "ssn"]) + ); + assert_eq!( + item( + "ConditionCheck", + json!({"Key": {"pk": {"S": "a"}}, "ConditionExpression": "#s = :v", + "ExpressionAttributeNames": {"#s": "ssn"}}) + ), + names(&["pk", "ssn"]) + ); + assert_eq!(item("Other", json!({"Key": {"pk": {"S": "a"}}})), None); +} + +#[test] +fn batch_and_transaction_targets_carry_the_attributes() { + let key_info: HashMap> = + HashMap::from([("t".to_owned(), Some(hash_table()))]); + let limits = LimitsConfig::default(); + + let batch = json!({"RequestItems": {"t": [ + {"PutRequest": {"Item": {"pk": {"S": "a"}, "ssn": {"S": "1"}}}}, + ]}}); + let targets = + crate::leading_keys::batch_transact_targets("BatchWriteItem", &batch, &key_info, &limits) + .expect("a batch request has targets"); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].attributes, names(&["pk", "ssn"])); + + // Two checks on one key with different conditions are two evaluations; + // a repeat of the first is not a third. + let check = |expr: &str| { + json!({"ConditionCheck": {"TableName": "t", "Key": {"pk": {"S": "a"}}, + "ConditionExpression": expr}}) + }; + let transact = json!({"TransactItems": [ + check("attribute_exists(ssn)"), + check("attribute_exists(name)"), + check("attribute_exists(ssn)"), + ]}); + let targets = crate::leading_keys::batch_transact_targets( + "TransactWriteItems", + &transact, + &key_info, + &limits, + ) + .expect("a transaction has targets"); + let attributes: Vec<_> = targets.iter().map(|t| t.attributes.clone()).collect(); + assert_eq!( + attributes, + vec![names(&["pk", "ssn"]), names(&["name", "pk"])] + ); +} diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 9ca4d7dce..590739b46 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -265,10 +265,12 @@ 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. The resource ARN per target is unchanged: each is - // still that table's ARN. (Measured against live DynamoDB 2026-10-20.) - // Targets with the same (action, table, LeadingKeys) are evaluated once, - // since the decision depends only on those three for a given identity. + // 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 + // 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. 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 @@ -295,9 +297,13 @@ pub(crate) async fn authorize_request( let ki = key_info_for_authz(state.storage.table_key_info(account_id, &table).await)?; authorized_key_info.insert(table, ki); } - let targets = - crate::leading_keys::batch_transact_targets(operation, input, &authorized_key_info) - .unwrap_or_default(); + let targets = crate::leading_keys::batch_transact_targets( + operation, + input, + &authorized_key_info, + &state.limits, + ) + .unwrap_or_default(); if targets.is_empty() { // Malformed body (the count checks above have already refused an @@ -324,6 +330,7 @@ pub(crate) async fn authorize_request( authz_resource::table_resource_arn(&target.table, &state.region, account_id); let params = extenddb_auth::policy::context::RequestParams { leading_keys: target.leading_keys, + attributes: target.attributes, ..base_params(request_tags.clone(), request_tag_keys.clone()) }; authorization::check_authorization( @@ -358,7 +365,12 @@ pub(crate) async fn authorize_request( key_info.as_ref(), state.limits.max_expression_tokens, ), - attributes: extract_attributes(input), + attributes: crate::request_attributes::single_table_attributes( + operation, + input, + key_info.as_ref(), + &state.limits, + ), select: input .get("Select") .and_then(|v| v.as_str()) @@ -403,36 +415,10 @@ pub(crate) async fn authorize_request( Ok(authorized_key_info) } -/// Extract attribute names from the request for `dynamodb:Attributes`. -/// -/// Collects attribute names from `ProjectionExpression` (comma-separated list -/// of top-level names). Resolves `ExpressionAttributeNames` placeholders -/// (e.g. `#n` → `name`) when present. -/// Returns `None` when no projection is specified. -pub(crate) fn extract_attributes(input: &Value) -> Option> { - let proj = input.get("ProjectionExpression")?.as_str()?; - let ean = input - .get("ExpressionAttributeNames") - .and_then(|v| v.as_object()); - let names: Vec = proj - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| { - ean.and_then(|m| m.get(s)) - .and_then(|v| v.as_str()) - .unwrap_or(s) - .to_owned() - }) - .collect(); - if names.is_empty() { None } else { Some(names) } -} - #[cfg(test)] mod tests { use super::*; use extenddb_core::types::TableKeyInfo; - use serde_json::json; fn sample_key_info() -> TableKeyInfo { TableKeyInfo { @@ -510,46 +496,6 @@ mod tests { ); } - #[test] - fn extract_attributes_resolves_expression_attribute_names() { - let input = json!({ - "ProjectionExpression": "#n, #v", - "ExpressionAttributeNames": { - "#n": "name", - "#v": "value" - } - }); - let result = extract_attributes(&input); - assert_eq!(result, Some(vec!["name".to_owned(), "value".to_owned()])); - } - - #[test] - fn extract_attributes_mixed_placeholders_and_literals() { - let input = json!({ - "ProjectionExpression": "#n, age", - "ExpressionAttributeNames": { - "#n": "name" - } - }); - let result = extract_attributes(&input); - assert_eq!(result, Some(vec!["name".to_owned(), "age".to_owned()])); - } - - #[test] - fn extract_attributes_no_expression_attribute_names() { - let input = json!({ - "ProjectionExpression": "name, age" - }); - let result = extract_attributes(&input); - assert_eq!(result, Some(vec!["name".to_owned(), "age".to_owned()])); - } - - #[test] - fn extract_attributes_no_projection() { - let input = json!({"TableName": "test"}); - assert_eq!(extract_attributes(&input), None); - } - #[test] fn missing_target_no_auth_returns_missing_auth_token() { // S-7: No Authorization header + no X-Amz-Target → MissingAuthenticationToken diff --git a/tests/test_tag_request_allow_conditions.py b/tests/test_tag_request_allow_conditions.py index 313b16313..ceb364a56 100644 --- a/tests/test_tag_request_allow_conditions.py +++ b/tests/test_tag_request_allow_conditions.py @@ -7,11 +7,10 @@ `StringNotEqualsIgnoreCase`) on an absent multivalued key and never on a present one (measured with `iam simulate-custom-policy` on 2026-10-24). ExtendDB applies the absent-key answer to `aws:TagKeys` alone, which it populates on -exactly the operations the AWS service authorization reference lists it for. It -leaves `dynamodb:Attributes` absent on writes, on `AttributesToGet` reads and on -batch and transaction targets, where the service populates the key and answers -implicitDeny for a bare operator, so for that key an absent key does not match -and such an Allow grants nothing on those operations. +exactly the operations the AWS service authorization reference lists it for. +`dynamodb:Attributes` is present on every operation below, as on the service, so +a bare negated operator over it does not match and such an Allow grants nothing +on those operations. The same statement is checked as a user policy, a role policy, a permissions boundary and a session policy, and as the only statement of a trust policy. From 7827a5c5087875bc91e462142bd7a8191549f29f Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:44:14 +0000 Subject: [PATCH 03/12] test: cover dynamodb:Attributes on writes, legacy reads, batch and transaction requests One test per request shape measured on DynamoDB on 2026-10-31, under a user policy of the shape of the session policy each was measured with: a Deny gated on ForAnyValue:StringEquals over one attribute, and an Allow of PutItem gated on ForAllValues:StringEquals over pk. Each asserts the service's answer, the action the refusal names, and that a refused write stores nothing. --- tests/test_dynamodb_attributes_condition.py | 434 ++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 tests/test_dynamodb_attributes_condition.py diff --git a/tests/test_dynamodb_attributes_condition.py b/tests/test_dynamodb_attributes_condition.py new file mode 100644 index 000000000..c1597a47b --- /dev/null +++ b/tests/test_dynamodb_attributes_condition.py @@ -0,0 +1,434 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""dynamodb:Attributes on writes, AttributesToGet reads, document paths, and +batch and transaction requests. + +Every request shape here was sent to live DynamoDB on 2026-10-31 under a +session policy of the same shape as the user policy the test attaches, twice, +with the same answer both times. The policies: + + * deny(X): Allow dynamodb:* on the table, and Deny dynamodb:* on the table + with ForAnyValue:StringEquals {"dynamodb:Attributes": [X]}. + * allow-put for-all(pk): the only statement allows dynamodb:PutItem on the + table with ForAllValues:StringEquals {"dynamodb:Attributes": ["pk"]}. + +Under deny(ssn) the service refused a PutItem, BatchWriteItem or +TransactWriteItems Put of an item holding ssn, an UpdateItem that sets ssn, +and a GetItem, Scan, BatchGetItem or TransactGetItems read that names ssn in +AttributesToGet or ProjectionExpression. Under deny(secret) it refused the +projections `secret.a` and `#s.a`, so a document path is matched by its +top-level name. It allowed a DeleteItem of an item holding ssn, an UpdateItem +that sets another attribute through a name placeholder, and reads with no +projection, which returned whole items. allow-put for-all(pk) admitted a +PutItem of {pk} and refused one of {pk, ssn}. + +Prerequisites: + - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD env vars + +Run: + EXTENDDB_TEST_ENDPOINT=https://localhost:18443 \\ + EXTENDDB_ADMIN_USER=admin \\ + EXTENDDB_ADMIN_PASSWORD= \\ + pytest tests/test_dynamodb_attributes_condition.py -v + +REQ-TEST-001, REQ-AUTH-002 +""" + +from __future__ import annotations + +import os +import uuid +from dataclasses import dataclass +from typing import Any, Callable + +import boto3 +import pytest +from botocore.config import Config as BotoConfig +from botocore.exceptions import ClientError + +from conftest import wait_for_active +from management_helpers import ManagementClient + +# The measured item, and four items holding ssn for the DeleteItem shape. +ITEM_A = { + "pk": {"S": "a"}, + "ssn": {"S": "123-45-6789"}, + "name": {"S": "alice"}, + "secret": {"M": {"a": {"S": "1"}, "b": {"S": "2"}}}, +} +DELETABLE = ["d1", "d2", "d3", "d4"] +KEY_A = {"pk": {"S": "a"}} + +ALLOW_ALL = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}], +} + + +def _require_auth_env() -> tuple[str, str, str]: + endpoint = os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip() + admin_user = os.environ.get("EXTENDDB_ADMIN_USER", "").strip() + admin_pass = os.environ.get("EXTENDDB_ADMIN_PASSWORD", "").strip() + if not endpoint or not admin_user or not admin_pass: + pytest.fail( + "MISCONFIGURED: dynamodb:Attributes tests require EXTENDDB_TEST_ENDPOINT, " + "EXTENDDB_ADMIN_USER, and EXTENDDB_ADMIN_PASSWORD. " + "These must be set by devtools/run-tests before test execution." + ) + return endpoint, admin_user, admin_pass + + +def _make_client(endpoint_url: str, access_key: str, secret_key: str, + region: str) -> Any: + kwargs: dict = dict( + service_name="dynamodb", + endpoint_url=endpoint_url, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + config=BotoConfig(retries={"max_attempts": 0}), + ) + # Self-signed certs from ``extenddb init``: disable SSL verification. + if endpoint_url.startswith("https://"): + kwargs["verify"] = False + return boto3.client(**kwargs) + + +def _create_user_with_key(mgmt: ManagementClient, account_id: str, + user_name: str) -> tuple[str, str]: + resp = mgmt.create_user(account_id, user_name, "TestPass123!") + assert resp.status_code == 201, resp.text + resp = mgmt.create_access_key(account_id, user_name) + assert resp.status_code == 201, resp.text + creds = resp.json() + return creds["access_key_id"], creds["secret_access_key"] + + +def _deny(arn: str, attribute: str) -> dict: + """deny(X): everything on the table except a request naming `attribute`.""" + return { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": arn}, + { + "Effect": "Deny", + "Action": "dynamodb:*", + "Resource": arn, + "Condition": { + "ForAnyValue:StringEquals": {"dynamodb:Attributes": [attribute]} + }, + }, + ], + } + + +def _allow_put_for_all(arn: str, attributes: list[str]) -> dict: + """allow-put for-all: PutItem only, and only of the listed attributes.""" + return { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "dynamodb:PutItem", + "Resource": arn, + "Condition": { + "ForAllValues:StringEquals": {"dynamodb:Attributes": attributes} + }, + } + ], + } + + +@dataclass +class Env: + admin: Any + table: str + deny_ssn: Any + deny_secret: Any + allow_put_pk: Any + + +@pytest.fixture(scope="module") +def auth_env(): + return _require_auth_env() + + +@pytest.fixture(scope="module") +def mgmt(auth_env) -> ManagementClient: + endpoint, admin_user, admin_pass = auth_env + return ManagementClient(endpoint, admin_user, admin_pass) + + +@pytest.fixture(scope="module") +def region() -> str: + return os.environ.get("AWS_DEFAULT_REGION", "us-east-1") + + +@pytest.fixture(scope="module") +def account_id(mgmt) -> str: + acct_id = f"{uuid.uuid4().int % 10**12:012d}" + resp = mgmt.create_account(acct_id, f"attrs-test-{acct_id}") + assert resp.status_code == 201, resp.text + yield acct_id + mgmt.delete_account(acct_id) + + +@pytest.fixture(scope="module") +def env(auth_env, mgmt, account_id, region) -> Env: + endpoint = auth_env[0] + owner = f"owner-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(mgmt, account_id, owner) + resp = mgmt.put_user_policy(account_id, owner, "full", ALLOW_ALL) + assert resp.status_code == 204, resp.text + admin = _make_client(endpoint, ak, sk, region) + + table = f"attrs-{uuid.uuid4().hex[:8]}" + admin.create_table( + TableName=table, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(admin, table) + admin.put_item(TableName=table, Item=ITEM_A) + for pk in DELETABLE: + admin.put_item( + TableName=table, + Item={"pk": {"S": pk}, "name": {"S": "x"}, "ssn": {"S": "1"}}, + ) + arn = f"arn:aws:dynamodb:{region}:{account_id}:table/{table}" + + def client_with(policy: dict) -> Any: + user = f"u-{uuid.uuid4().hex[:8]}" + uak, usk = _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 + return _make_client(endpoint, uak, usk, region) + + yield Env( + admin=admin, + table=table, + deny_ssn=client_with(_deny(arn, "ssn")), + deny_secret=client_with(_deny(arn, "secret")), + allow_put_pk=client_with(_allow_put_for_all(arn, ["pk"])), + ) + try: + admin.delete_table(TableName=table) + except ClientError: + pass + + +def _refusal(call: Callable[[], Any], action: str) -> str: + """The message of the AccessDeniedException `call` raises for `action`.""" + with pytest.raises(ClientError) as exc: + call() + err = exc.value.response["Error"] + assert err["Code"] == "AccessDeniedException", exc.value.response + assert f"perform: dynamodb:{action} on resource:" in err["Message"], err + return err["Message"] + + +def _assert_denied(call: Callable[[], Any], action: str) -> None: + msg = _refusal(call, action) + assert "with an explicit deny in an identity-based policy" in msg, msg + + +def _new_key() -> dict: + return {"pk": {"S": f"k-{uuid.uuid4().hex[:8]}"}} + + +def _stored(env: Env, key: dict) -> dict | None: + return env.admin.get_item(TableName=env.table, Key=key, ConsistentRead=True).get("Item") + + +# -- writes ------------------------------------------------------------------ + + +def test_put_item_of_an_item_holding_a_denied_attribute_is_denied(env: Env): + key = _new_key() + _assert_denied( + lambda: env.deny_ssn.put_item(TableName=env.table, Item={**key, "ssn": {"S": "1"}}), + "PutItem", + ) + assert _stored(env, key) is None, "a refused write stores nothing" + + +def test_update_item_setting_a_denied_attribute_is_denied(env: Env): + key = _new_key() + _assert_denied( + lambda: env.deny_ssn.update_item( + TableName=env.table, + Key=key, + UpdateExpression="SET ssn = :v", + ExpressionAttributeValues={":v": {"S": "1"}}, + ), + "UpdateItem", + ) + assert _stored(env, key) is None, "a refused write stores nothing" + + +def test_put_item_without_the_denied_attribute_is_allowed(env: Env): + key = _new_key() + env.deny_ssn.put_item(TableName=env.table, Item={**key, "name": {"S": "n"}}) + assert _stored(env, key) == {**key, "name": {"S": "n"}} + + +def test_update_item_setting_another_attribute_through_a_placeholder_is_allowed(env: Env): + key = _new_key() + env.deny_ssn.update_item( + TableName=env.table, + Key=key, + UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": "name"}, + ExpressionAttributeValues={":v": {"S": "n"}}, + ) + assert _stored(env, key) == {**key, "name": {"S": "n"}} + + +def test_delete_item_of_an_item_holding_the_denied_attribute_is_allowed(env: Env): + key = {"pk": {"S": DELETABLE[0]}} + assert "ssn" in _stored(env, key) + env.deny_ssn.delete_item(TableName=env.table, Key=key) + assert _stored(env, key) is None + + +def test_for_all_values_allow_refuses_a_put_of_an_unlisted_attribute(env: Env): + key = _new_key() + msg = _refusal( + lambda: env.allow_put_pk.put_item( + TableName=env.table, Item={**key, "ssn": {"S": "1"}} + ), + "PutItem", + ) + assert "because no identity-based policy allows the dynamodb:PutItem action" in msg, msg + assert _stored(env, key) is None, "a refused write stores nothing" + + +def test_for_all_values_allow_admits_a_put_of_listed_attributes_only(env: Env): + key = _new_key() + env.allow_put_pk.put_item(TableName=env.table, Item=key) + assert _stored(env, key) == key + + +# -- reads ------------------------------------------------------------------- + + +def test_get_item_attributes_to_get_naming_a_denied_attribute_is_denied(env: Env): + _assert_denied( + lambda: env.deny_ssn.get_item( + TableName=env.table, Key=KEY_A, AttributesToGet=["ssn"] + ), + "GetItem", + ) + + +def test_scan_attributes_to_get_naming_a_denied_attribute_is_denied(env: Env): + _assert_denied( + lambda: env.deny_ssn.scan(TableName=env.table, AttributesToGet=["ssn"]), + "Scan", + ) + + +def test_get_item_projection_naming_a_denied_attribute_is_denied(env: Env): + _assert_denied( + lambda: env.deny_ssn.get_item( + TableName=env.table, Key=KEY_A, ProjectionExpression="ssn" + ), + "GetItem", + ) + + +def test_get_item_attributes_to_get_of_another_attribute_returns_only_it(env: Env): + resp = env.deny_ssn.get_item(TableName=env.table, Key=KEY_A, AttributesToGet=["name"]) + assert resp["Item"] == {"name": {"S": "alice"}} + + +def test_get_item_projection_of_a_nested_path_is_matched_by_its_top_level_name(env: Env): + _assert_denied( + lambda: env.deny_secret.get_item( + TableName=env.table, Key=KEY_A, ProjectionExpression="secret.a" + ), + "GetItem", + ) + + +def test_get_item_projection_of_a_placeholder_path_is_matched_by_its_top_level_name( + env: Env, +): + _assert_denied( + lambda: env.deny_secret.get_item( + TableName=env.table, + Key=KEY_A, + ProjectionExpression="#s.a", + ExpressionAttributeNames={"#s": "secret"}, + ), + "GetItem", + ) + + +def test_get_item_with_no_projection_returns_the_whole_item(env: Env): + resp = env.deny_ssn.get_item(TableName=env.table, Key=KEY_A) + assert resp["Item"] == ITEM_A + + +def test_scan_with_no_projection_returns_whole_items(env: Env): + resp = env.deny_ssn.scan(TableName=env.table, Limit=2) + assert resp["Count"] == 2 + for item in resp["Items"]: + assert item == _stored(env, {"pk": item["pk"]}) + + +# -- batch and transaction requests ------------------------------------------ + + +def test_batch_get_item_projection_naming_a_denied_attribute_is_denied(env: Env): + _assert_denied( + lambda: env.deny_ssn.batch_get_item( + RequestItems={env.table: {"Keys": [KEY_A], "ProjectionExpression": "ssn"}} + ), + "BatchGetItem", + ) + + +def test_transact_get_items_projection_naming_a_denied_attribute_is_denied(env: Env): + _assert_denied( + lambda: env.deny_ssn.transact_get_items( + TransactItems=[ + {"Get": {"TableName": env.table, "Key": KEY_A, "ProjectionExpression": "ssn"}} + ] + ), + "GetItem", + ) + + +def test_batch_write_item_put_of_an_item_holding_a_denied_attribute_is_denied(env: Env): + key = _new_key() + _assert_denied( + lambda: env.deny_ssn.batch_write_item( + RequestItems={env.table: [{"PutRequest": {"Item": {**key, "ssn": {"S": "1"}}}}]} + ), + "BatchWriteItem", + ) + assert _stored(env, key) is None, "a refused write stores nothing" + + +def test_transact_write_items_put_of_an_item_holding_a_denied_attribute_is_denied( + env: Env, +): + key = _new_key() + _assert_denied( + lambda: env.deny_ssn.transact_write_items( + TransactItems=[ + {"Put": {"TableName": env.table, "Item": {**key, "ssn": {"S": "1"}}}} + ] + ), + "PutItem", + ) + assert _stored(env, key) is None, "a refused write stores nothing" + + +def test_batch_get_item_with_no_projection_returns_the_whole_item(env: Env): + resp = env.deny_ssn.batch_get_item(RequestItems={env.table: {"Keys": [KEY_A]}}) + assert resp["Responses"][env.table] == [ITEM_A] From d95c2cd7093d2c9cea8ea3333d00f90c2429d61e Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:01:37 +0000 Subject: [PATCH 04/12] docs: describe what dynamodb:Attributes holds for each operation The security model manual lists the names the key holds for each item, batch and transaction operation, and which entries were measured on DynamoDB. The troubleshooting entry for an Attributes condition covers a Deny that does not block a DeleteItem or a read with no projection, and an allowlist that omits the key attributes. The upgrade manual records the change for v0.1.15 and the policy statements it affects. --- docs/manuals/07-upgrade-manual.md | 12 ++++++++++++ docs/manuals/10-security-model.md | 19 ++++++++++++++++++- docs/troubleshooting.md | 5 +++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index f4451a0f5..9d812d043 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -665,6 +665,18 @@ A policy whose Arn values are literal ARNs, applied to a context value that is a No catalog migration is required. Before upgrading a deployment that restricts tagging, check the policies of each account for the statements above. +### v0.1.15: `dynamodb:Attributes` holds the attributes every item request names + +**What changed.** `dynamodb:Attributes` now holds the top-level names of the attributes a request names on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, `SearchVectors`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` and `TransactWriteItems`, as the security model manual lists them per operation. Before this release the key was read from `ProjectionExpression` alone, on single-table requests only, and a projected document path was compared as the whole path: the key was absent on `PutItem`, `UpdateItem` and `DeleteItem`, on reads that use `AttributesToGet`, and on every batch and transaction request, and a projection of `secret.a` held `secret.a` instead of `secret`. The key now also holds the attributes a condition, filter or update expression reads, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, `ExclusiveStartKey`, and on every item operation other than `Scan` and `SearchVectors` the key attributes of the table (and of a queried index). + +**Who is affected.** Any deployment with a policy statement that names `dynamodb:Attributes` in a condition: + +- A `Deny` gated on `ForAnyValue:StringEquals` over an attribute now applies to writes that name it, to `AttributesToGet` reads and projections of it or of a path inside it, to conditions and filters that read it, and to the batch and transaction forms of those requests. It did not apply to them before. +- An `Allow` gated on `ForAllValues:StringEquals` used to grant those requests whatever attributes they named, because the absent key satisfied it. It now grants only when every name the request holds is in the list. A list that omits the table's key attributes stops granting `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query` and the batch and transaction requests; add the key attributes of the table and of every index the statement covers. +- `Null` with value `true` on `dynamodb:Attributes` matched those requests before, and matched a `GetItem`, `Query`, `BatchGetItem` or `TransactGetItems` read with no projection. It no longer matches any of them, because each holds at least the table's key attributes; it still matches a `Scan` that names no attribute. A statement that used it to refuse reads with no projection no longer refuses them. + +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). 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 bb7a1c75d..88d5958d3 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -110,7 +110,7 @@ Set operators and `IfExists` can be combined: `ForAllValues:StringEqualsIfExists | `aws:PrincipalTag/` | String | Tag on the authenticated principal (user or role) | | `aws:ResourceTag/` | String | Tag on the target DynamoDB table | | `dynamodb:LeadingKeys` | Multi-valued string | Partition key values being accessed | -| `dynamodb:Attributes` | Multi-valued string | Attribute names being read or written | +| `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 | @@ -133,6 +133,23 @@ extenddb supports the same access control patterns as real AWS IAM: `dynamodb:LeadingKeys` is evaluated on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` and `TransactWriteItems`. A `Query` on a global secondary index binds the key to the index's partition key value (its first HASH attribute). `Scan` and `SearchVectors` target no single partition and carry no `dynamodb:LeadingKeys` value, so a `ForAllValues` condition does not fence them; fence a full-table read with `ForAnyValue:StringEquals` or a `Null` guard on the statement. A numeric partition key is compared as the normalized number on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query` and `BatchGetItem`, and as Java's `BigDecimal` prints it (`10.0` stays `10.0`, `1E1` is `1E+1`) on `BatchWriteItem`, `TransactGetItems` and `TransactWriteItems`, which is what DynamoDB does. +`dynamodb:Attributes` holds the top-level names of the attributes a request names, with `ExpressionAttributeNames` placeholders resolved. A document path contributes its first name: a projection of `address.city` or `#a.city` gives `address`, and `tags[0]` gives `tags`. An entry of `AttributesToGet` is one name as written, dot included. The key holds: + +| Operation | `dynamodb:Attributes` holds | +|-----------|-----------------------------| +| `GetItem` | The table's key attributes, `AttributesToGet`, and the attributes in `ProjectionExpression` | +| `PutItem` | The table's key attributes, the item's attributes, `Expected`, and the attributes in `ConditionExpression` | +| `UpdateItem` | The `Key` attributes, the table's key attributes, `AttributeUpdates`, `Expected`, the attributes in `ConditionExpression`, and the attributes in `UpdateExpression`: the target of every `SET`, `REMOVE`, `ADD` and `DELETE` action and the attributes its value reads | +| `DeleteItem` | The `Key` attributes, the table's key attributes, `Expected`, and the attributes in `ConditionExpression` | +| `Query` | The key attributes of the table and of the queried index, `AttributesToGet`, `QueryFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | +| `Scan` | `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | +| `SearchVectors` | The attributes in `ProjectionExpression` | +| `BatchGetItem` | Per table: the attributes of every key, the table's key attributes, `AttributesToGet`, and the attributes in `ProjectionExpression` | +| `BatchWriteItem` | Per table: the attributes of every `PutRequest` item and `DeleteRequest` key, and the table's key attributes | +| `TransactGetItems`, `TransactWriteItems` | Per item, what the single-item operation holds: `Get` as `GetItem`, `Put` as `PutItem`, `Update` as `UpdateItem`, `Delete` as `DeleteItem`; a `ConditionCheck` holds the table's key attributes and the attributes in its `ConditionExpression` | + +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). A `Scan` that names no attribute is evaluated with the key absent. 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 `Scan` and `SearchVectors` holds them. The entries for the item attributes of `PutItem`, the target of a `SET`, `DeleteItem`, `AttributesToGet` on `GetItem` and `Scan`, projected document paths, reads with no projection, and the batch and transaction forms of those requests were measured on DynamoDB on 2026-10-31. The other entries follow the IAM documentation for the key, which counts the attributes read in a condition or a filter and requires the key attributes in an allowlist. + ### 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 11a35c1b5..f49de30bc 100755 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -331,6 +331,11 @@ These errors occur when `auth.provider = "builtin"` is enabled. To allowlist attributes (allow only when *every* requested attribute is in the set), use `ForAllValues:` on an `Allow` statement. The same rule applies to `dynamodb:LeadingKeys`. +`dynamodb:Attributes` holds the top-level names of the attributes the request names: the attributes of a `PutItem` item and of the items and keys in a `BatchWriteItem` request, the targets and operands of an `UpdateExpression`, `AttributesToGet`, the first name of each projected document path (`secret` for `secret.a`), the attributes a `ConditionExpression` or `FilterExpression` reads, and on every item operation other than `Scan` and `SearchVectors` the key attributes of the table (and of a queried index). Batch requests are evaluated per table and transactions per item. The [security model manual](manuals/10-security-model.md) lists what the key holds for each operation. Two cases follow from this: + +- A `Deny` on `ForAnyValue:StringEquals` over `ssn` does not block a `DeleteItem` of an item that holds `ssn`, or a read with no projection, which returns `ssn` with the rest of the item: neither request names `ssn`. DynamoDB answers the same way (measured 2026-10-31). +- An `Allow` on `ForAllValues:StringEquals` that does not list the table's key attributes grants no `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, batch or transaction request, because each of them holds the key attributes. List the key attributes of the table and of every index the statement covers. + > Note: prior to v0.1.6, extenddb incorrectly treated a bare operator on these keys as an implicit AND across the requested values, so a single-attribute request appeared to be denied while a multi-attribute request leaked. That behavior diverged from AWS and gave a false sense of security. See the upgrade manual for migration guidance. ## Connection Issues From cd5c766e2ac23c4abd224141df6a19c0ed06529c Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:14:58 +0000 Subject: [PATCH 05/12] refactor(server): merge identical match arms in the Attributes extractor Rename the ExpressionAttributeNames field so it no longer repeats the struct name, merge the match arms that walk two operands, and replace for_each with loops. Behaviour is unchanged. --- crates/server/src/request_attributes.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/server/src/request_attributes.rs b/crates/server/src/request_attributes.rs index 3bc74b353..78080b2b4 100644 --- a/crates/server/src/request_attributes.rs +++ b/crates/server/src/request_attributes.rs @@ -164,7 +164,7 @@ pub(crate) fn transact_item_attributes( /// against. struct Names<'a> { found: BTreeSet, - expression_names: Option<&'a Map>, + placeholders: Option<&'a Map>, limits: &'a LimitsConfig, } @@ -172,7 +172,7 @@ impl<'a> Names<'a> { fn new(request: &'a Value, limits: &'a LimitsConfig) -> Self { Self { found: BTreeSet::new(), - expression_names: request + placeholders: request .get("ExpressionAttributeNames") .and_then(Value::as_object), limits, @@ -325,16 +325,19 @@ impl<'a> Names<'a> { match expr { Expr::Path(path) => self.path(path), Expr::Placeholder(_) => {} - Expr::Compare { left, right, .. } | Expr::Arithmetic { left, right, .. } => { - self.expr(left); - self.expr(right); - } - Expr::And(left, right) | Expr::Or(left, right) => { + Expr::Compare { left, right, .. } + | Expr::Arithmetic { left, right, .. } + | Expr::And(left, right) + | Expr::Or(left, right) => { self.expr(left); self.expr(right); } Expr::Not(inner) => self.expr(inner), - Expr::Function { args, .. } => args.iter().for_each(|arg| self.expr(arg)), + Expr::Function { args, .. } => { + for arg in args { + self.expr(arg); + } + } Expr::Between { operand, low, high } => { self.expr(operand); self.expr(low); @@ -342,7 +345,9 @@ impl<'a> Names<'a> { } Expr::In { operand, list } => { self.expr(operand); - list.iter().for_each(|item| self.expr(item)); + for item in list { + self.expr(item); + } } } } @@ -355,7 +360,7 @@ impl<'a> Names<'a> { }; let name = if first.starts_with('#') { match self - .expression_names + .placeholders .and_then(|names| names.get(first)) .and_then(Value::as_str) { From da1247044163e67a5ac483343447d10f43ecf522 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:48:20 +0000 Subject: [PATCH 06/12] fix(server): leave the paths a SET value reads out of dynamodb:Attributes DynamoDB authorizes an UpdateItem whose only mention of an attribute is in a SET value. Under a Deny gated on ForAnyValue:StringEquals over ssn, it authorized SET #n = ssn, SET #n = if_not_exists(ssn, :d), list_append(ssn, :l) and ssn + :one, and refused REMOVE ssn, ADD ssn :one and DELETE ssn :s (measured 2026-11-01). The key for an UpdateItem and for a transaction's Update item now holds the attribute each action names, and no longer the paths its value reads. --- crates/server/src/request_attributes.rs | 43 ++++++++++--------- crates/server/src/request_attributes/tests.rs | 17 +++++++- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/crates/server/src/request_attributes.rs b/crates/server/src/request_attributes.rs index 78080b2b4..ea14a7d0c 100644 --- a/crates/server/src/request_attributes.rs +++ b/crates/server/src/request_attributes.rs @@ -18,9 +18,11 @@ //! - `PutItem`: the table's key attributes, the item's attributes, `Expected`, //! and the paths in `ConditionExpression`. //! - `UpdateItem`: the `Key` attributes, the table's key attributes, -//! `AttributeUpdates`, `Expected`, and the paths in `UpdateExpression` (the -//! attribute each action names and the paths in its value) and in -//! `ConditionExpression`. +//! `AttributeUpdates`, `Expected`, the paths in `ConditionExpression`, and +//! the attribute each `SET`, `REMOVE`, `ADD` and `DELETE` action in +//! `UpdateExpression` names. The paths a `SET` value reads are not in the +//! key: the service authorizes `SET #n = ssn`, which copies `ssn` into +//! `name`, under a Deny on `ssn` (measured 2026-11-01). //! - `DeleteItem`: the `Key` attributes, the table's key attributes, `Expected`, //! and the paths in `ConditionExpression`. //! - `Query`: the key attributes of the table and of the queried index, @@ -39,15 +41,17 @@ //! single-item operation the item maps to. A `ConditionCheck` holds the //! table's key attributes and the paths in its `ConditionExpression`. //! -//! The service's answers were measured on 2026-10-31 for the item attributes of -//! `PutItem`, the attribute a `SET` action names (by name and through -//! `ExpressionAttributeNames`), `DeleteItem`, `AttributesToGet` on `GetItem` and -//! `Scan`, a `ProjectionExpression` naming a document path, reads with no -//! projection, and the batch and transaction forms of those requests. The rest -//! follows the IAM documentation for the key: it holds the attributes a request -//! names, including those read in a condition or a filter, and a policy that -//! lists the allowed attributes must list the key attributes of the table and of -//! its indexes. +//! The service's answers were measured on 2026-10-31 and 2026-11-01 for the +//! item attributes of `PutItem`, every kind of `UpdateExpression` action and +//! `SET` value, `DeleteItem`, `ConditionExpression` on `PutItem` and +//! `DeleteItem`, `FilterExpression` on `Query` and `Scan`, `Expected`, +//! `AttributeUpdates`, `ScanFilter`, `AttributesToGet` on `GetItem` and `Scan`, +//! the table's key attributes on `GetItem`, a `ProjectionExpression` naming a +//! document path, reads with no projection, a `ConditionCheck`, and the batch +//! and transaction forms of `GetItem` and `PutItem`. The rest follows the same +//! rules: `ExclusiveStartKey`, `QueryFilter`, `AttributesToGet` on `Query` and +//! `BatchGetItem`, the queried index's key attributes, `DeleteRequest` keys, +//! and the `Update` and `Delete` items of a transaction. //! //! An expression is read with the tokenizer and parser the engine uses, under //! the same size, token and depth limits, so every expression the engine @@ -280,8 +284,8 @@ impl<'a> Names<'a> { } } - /// The attribute each `UpdateExpression` action names and the paths in its - /// value. + /// The attribute each `UpdateExpression` action names. The paths a value + /// reads are left out, as the service leaves them out. fn update(&mut self, value: Option<&Value>) { let Some(text) = self.text(value) else { return; @@ -294,13 +298,10 @@ impl<'a> Names<'a> { .and_then(|tokens| parse_update_from(&tokens, text)); for action in parsed.iter().flatten() { match action { - UpdateAction::Set { path, value } - | UpdateAction::Add { path, value } - | UpdateAction::Delete { path, value } => { - self.path(path); - self.expr(value); - } - UpdateAction::Remove { path } => self.path(path), + UpdateAction::Set { path, .. } + | UpdateAction::Remove { path } + | UpdateAction::Add { path, .. } + | UpdateAction::Delete { path, .. } => self.path(path), } } } diff --git a/crates/server/src/request_attributes/tests.rs b/crates/server/src/request_attributes/tests.rs index 4ac14a1cf..ebbdd8269 100644 --- a/crates/server/src/request_attributes/tests.rs +++ b/crates/server/src/request_attributes/tests.rs @@ -141,7 +141,10 @@ fn update_item_holds_the_attribute_set_names() { } #[test] -fn update_item_holds_every_action_target_and_value_path() { +fn update_item_holds_every_action_target_but_not_the_paths_a_value_reads() { + // Measured: REMOVE, ADD and DELETE of ssn match a Deny on ssn, and a SET + // value that reads ssn (`ssn`, `#s`, `if_not_exists`, `list_append`, + // `ssn + :one`) does not. let input = json!({ "TableName": "t", "Key": {"pk": {"S": "a"}}, @@ -152,7 +155,17 @@ fn update_item_holds_every_action_target_and_value_path() { }); assert_eq!( single("UpdateItem", &input, Some(&hash_table())), - names(&["a", "b", "c", "e", "f", "g", "i", "k", "mset", "pk", "x"]) + names(&["a", "c", "f", "i", "k", "mset", "pk", "x"]) + ); + let copy = json!({ + "TableName": "t", + "Key": {"pk": {"S": "a"}}, + "UpdateExpression": "SET #n = #s, o = if_not_exists(ssn, :d), p = :one + ssn", + "ExpressionAttributeNames": {"#n": "name", "#s": "ssn"}, + }); + assert_eq!( + single("UpdateItem", ©, Some(&hash_table())), + names(&["name", "o", "p", "pk"]) ); } From ee8541082c6742082695e0f30da3bbe8d4cb865b Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:57:43 +0000 Subject: [PATCH 07/12] test: cover dynamodb:Attributes for conditions, filters, update actions and key attributes One test per request shape measured on DynamoDB on 2026-11-01: a condition, filter, Expected, AttributeUpdates or ScanFilter that reads a denied attribute, a REMOVE, ADD or DELETE of it, and a ConditionCheck on it are refused; a SET value that reads it is authorized and copies it; a bare StringNotEquals grants no Scan; a Null condition does not refuse a GetItem with no projection; and an allowlist without the table's key attribute refuses a projected GetItem. The new module reuses the fixtures of test_dynamodb_attributes_condition.py. --- tests/test_dynamodb_attributes_condition.py | 82 +++++-- tests/test_dynamodb_attributes_expressions.py | 232 ++++++++++++++++++ 2 files changed, 290 insertions(+), 24 deletions(-) create mode 100644 tests/test_dynamodb_attributes_expressions.py diff --git a/tests/test_dynamodb_attributes_condition.py b/tests/test_dynamodb_attributes_condition.py index c1597a47b..34f57dcb6 100644 --- a/tests/test_dynamodb_attributes_condition.py +++ b/tests/test_dynamodb_attributes_condition.py @@ -1,27 +1,33 @@ # Copyright 2026 ExtendDB contributors # SPDX-License-Identifier: Apache-2.0 -"""dynamodb:Attributes on writes, AttributesToGet reads, document paths, and -batch and transaction requests. +"""dynamodb:Attributes on writes, reads, document paths, conditions, filters, +legacy parameters, and batch and transaction requests. -Every request shape here was sent to live DynamoDB on 2026-10-31 under a -session policy of the same shape as the user policy the test attaches, twice, -with the same answer both times. The policies: +Every request shape here was sent to live DynamoDB on 2026-10-31 or +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 policies: * deny(X): Allow dynamodb:* on the table, and Deny dynamodb:* on the table with ForAnyValue:StringEquals {"dynamodb:Attributes": [X]}. - * allow-put for-all(pk): the only statement allows dynamodb:PutItem on the - table with ForAllValues:StringEquals {"dynamodb:Attributes": ["pk"]}. - -Under deny(ssn) the service refused a PutItem, BatchWriteItem or -TransactWriteItems Put of an item holding ssn, an UpdateItem that sets ssn, -and a GetItem, Scan, BatchGetItem or TransactGetItems read that names ssn in -AttributesToGet or ProjectionExpression. Under deny(secret) it refused the -projections `secret.a` and `#s.a`, so a document path is matched by its -top-level name. It allowed a DeleteItem of an item holding ssn, an UpdateItem -that sets another attribute through a name placeholder, and reads with no -projection, which returned whole items. allow-put for-all(pk) admitted a -PutItem of {pk} and refused one of {pk, ssn}. + * allow-only(action, condition): the only statement allows the action on + the table under the condition: ForAllValues:StringEquals over a list of + names, or a bare StringNotEquals {"dynamodb:Attributes": "ssn"}. + * deny-get-when-absent: Allow dynamodb:* on the table, and Deny + dynamodb:GetItem with Null {"dynamodb:Attributes": "true"}. + +Under deny(ssn) the service refused every request that names ssn as an item +attribute, the target of a SET, REMOVE, ADD or DELETE action, an attribute a +condition, filter, Expected, AttributeUpdates or ScanFilter reads, or an entry +of AttributesToGet or ProjectionExpression, in the single, batch and +transaction forms measured. A document path is matched by its top-level name. +It authorized an UpdateItem whose only mention of ssn is in a SET value, which +copies ssn into another attribute. It allowed a DeleteItem of an item holding +ssn and reads with no projection, which returned whole items: the key holds +the names the request gives, and on GetItem the table's key attribute too, so +an Allow for all of ["name"] refused a GetItem of name and deny-get-when-absent +did not refuse a GetItem with no projection. A bare StringNotEquals granted no +Scan, whether the Scan named an attribute or not. Prerequisites: - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT @@ -124,23 +130,41 @@ def _deny(arn: str, attribute: str) -> dict: } -def _allow_put_for_all(arn: str, attributes: list[str]) -> dict: - """allow-put for-all: PutItem only, and only of the listed attributes.""" +def _allow_only(arn: str, action: str, condition: dict) -> dict: + """A policy whose only statement allows `action` on the table under `condition`.""" return { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", - "Action": "dynamodb:PutItem", + "Action": f"dynamodb:{action}", "Resource": arn, - "Condition": { - "ForAllValues:StringEquals": {"dynamodb:Attributes": attributes} - }, + "Condition": condition, } ], } +def _for_all(attributes: list[str]) -> dict: + return {"ForAllValues:StringEquals": {"dynamodb:Attributes": attributes}} + + +def _deny_get_when_absent(arn: str) -> dict: + """Everything on the table, except a GetItem whose key is absent.""" + return { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": arn}, + { + "Effect": "Deny", + "Action": "dynamodb:GetItem", + "Resource": arn, + "Condition": {"Null": {"dynamodb:Attributes": "true"}}, + }, + ], + } + + @dataclass class Env: admin: Any @@ -148,6 +172,10 @@ class Env: deny_ssn: Any deny_secret: Any allow_put_pk: Any + allow_scan_unless_ssn: Any + deny_get_when_absent: Any + allow_get_name: Any + allow_get_pk_name: Any @pytest.fixture(scope="module") @@ -212,7 +240,13 @@ def client_with(policy: dict) -> Any: table=table, deny_ssn=client_with(_deny(arn, "ssn")), deny_secret=client_with(_deny(arn, "secret")), - allow_put_pk=client_with(_allow_put_for_all(arn, ["pk"])), + allow_put_pk=client_with(_allow_only(arn, "PutItem", _for_all(["pk"]))), + allow_scan_unless_ssn=client_with( + _allow_only(arn, "Scan", {"StringNotEquals": {"dynamodb:Attributes": "ssn"}}) + ), + deny_get_when_absent=client_with(_deny_get_when_absent(arn)), + allow_get_name=client_with(_allow_only(arn, "GetItem", _for_all(["name"]))), + allow_get_pk_name=client_with(_allow_only(arn, "GetItem", _for_all(["pk", "name"]))), ) try: admin.delete_table(TableName=table) diff --git a/tests/test_dynamodb_attributes_expressions.py b/tests/test_dynamodb_attributes_expressions.py new file mode 100644 index 000000000..2018ca77a --- /dev/null +++ b/tests/test_dynamodb_attributes_expressions.py @@ -0,0 +1,232 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""dynamodb:Attributes for conditions, filters, legacy parameters, update +actions, key attributes, and bare and Null conditions. + +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 policies and the fixtures are those of +test_dynamodb_attributes_condition.py; its docstring describes them. + +Prerequisites and run command: as for test_dynamodb_attributes_condition.py. + +REQ-TEST-001, REQ-AUTH-002 +""" + +from __future__ import annotations + +import re +import uuid +from typing import Any, Callable + +import pytest +from botocore.exceptions import ClientError + +# 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, + _assert_denied, + _new_key, + _refusal, + _stored, + account_id, + auth_env, + env, + mgmt, + region, +) + +MISSING_ATTRIBUTE = "The provided expression refers to an attribute that does not exist in the item" + + +def _shapes_naming_ssn(table: str, key: dict) -> dict[str, tuple[str, Callable[[Any], Any]]]: + """Requests that name ssn only in a condition, a filter, a legacy parameter + or an update action target, each with the action its refusal names.""" + one = {":one": {"N": "1"}} + return { + "put_condition": ("PutItem", lambda c: c.put_item( + TableName=table, Item=key, ConditionExpression="attribute_not_exists(ssn)")), + "put_expected": ("PutItem", lambda c: c.put_item( + TableName=table, Item=key, Expected={"ssn": {"Exists": False}})), + "delete_condition": ("DeleteItem", lambda c: c.delete_item( + TableName=table, Key=key, ConditionExpression="attribute_not_exists(ssn)")), + "update_remove": ("UpdateItem", lambda c: c.update_item( + TableName=table, Key=key, UpdateExpression="REMOVE ssn")), + "update_add": ("UpdateItem", lambda c: c.update_item( + TableName=table, Key=key, UpdateExpression="ADD ssn :one", + ExpressionAttributeValues=one)), + "update_delete": ("UpdateItem", lambda c: c.update_item( + TableName=table, Key=key, UpdateExpression="DELETE ssn :s", + ExpressionAttributeValues={":s": {"SS": ["x"]}})), + "update_attribute_updates": ("UpdateItem", lambda c: c.update_item( + TableName=table, Key=key, + AttributeUpdates={"ssn": {"Value": {"S": "1"}, "Action": "PUT"}})), + "query_filter": ("Query", lambda c: c.query( + TableName=table, KeyConditionExpression="pk = :p", FilterExpression="ssn = :v", + ExpressionAttributeValues={":p": KEY_A["pk"], ":v": {"S": "1"}})), + "scan_filter": ("Scan", lambda c: c.scan( + TableName=table, FilterExpression="ssn = :v", + ExpressionAttributeValues={":v": {"S": "1"}})), + "scan_legacy_filter": ("Scan", lambda c: c.scan( + TableName=table, ScanFilter={"ssn": {"ComparisonOperator": "NOT_NULL"}})), + "condition_check": ("ConditionCheckItem", lambda c: c.transact_write_items( + TransactItems=[{"ConditionCheck": { + "TableName": table, "Key": KEY_A, + "ConditionExpression": "attribute_exists(ssn)"}}])), + } + + +@pytest.mark.parametrize("shape", sorted(_shapes_naming_ssn("t", {}))) +def test_a_request_naming_a_denied_attribute_outside_its_item_is_denied(env: Env, shape: str): + key = _new_key() + seeded = {**key, "ssn": {"S": "1"}} + env.admin.put_item(TableName=env.table, Item=seeded) + action, call = _shapes_naming_ssn(env.table, key)[shape] + _assert_denied(lambda: call(env.deny_ssn), action) + assert _stored(env, key) == seeded, "a refused request changes nothing" + + +# -- SET values ------------------------------------------------------------ + +# Each SET value reads ssn, on an item that does not hold it, with the answer +# the service gave: authorized, then refused by evaluation (None means the +# update succeeds). +SET_VALUES_READING_SSN = { + "SET #n = ssn": MISSING_ATTRIBUTE, + "SET #n = #s": MISSING_ATTRIBUTE, + "SET #n = if_not_exists(ssn, :d)": None, + "SET #n = if_not_exists(#n, ssn)": MISSING_ATTRIBUTE, + "SET #n = list_append(ssn, :l)": MISSING_ATTRIBUTE, + "SET #n = list_append(:l, ssn)": MISSING_ATTRIBUTE, + "SET #n = ssn + :one": MISSING_ATTRIBUTE, + "SET #n = :one + ssn": MISSING_ATTRIBUTE, +} +_VALUES = {":d": {"S": "d"}, ":l": {"L": [{"S": "x"}]}, ":one": {"N": "1"}} + + +@pytest.mark.parametrize("expression", list(SET_VALUES_READING_SSN)) +def test_a_set_value_reading_a_denied_attribute_is_authorized(env: Env, expression: str): + key = _new_key() + env.admin.put_item(TableName=env.table, Item=key) + names = {"#n": "name"} + if "#s" in expression: + names["#s"] = "ssn" + kwargs: dict = {"ExpressionAttributeNames": names} + values = {v: _VALUES[v] for v in sorted(set(re.findall(r":\w+", expression)))} + if values: + kwargs["ExpressionAttributeValues"] = values + expected = SET_VALUES_READING_SSN[expression] + + def update() -> Any: + return env.deny_ssn.update_item( + TableName=env.table, Key=key, UpdateExpression=expression, **kwargs + ) + + if expected is None: + update() + assert _stored(env, key) == {**key, "name": {"S": "d"}} + return + with pytest.raises(ClientError) as exc: + update() + err = exc.value.response["Error"] + assert err["Code"] == "ValidationException", exc.value.response + assert expected in err["Message"], err + + +def test_a_set_value_copies_a_denied_attribute_into_another_one(env: Env): + key = _new_key() + env.admin.put_item(TableName=env.table, Item={**key, "ssn": {"S": "999-00-1111"}}) + resp = env.deny_ssn.update_item( + TableName=env.table, + Key=key, + UpdateExpression="SET #n = ssn", + ExpressionAttributeNames={"#n": "name"}, + ReturnValues="UPDATED_NEW", + ) + assert resp["Attributes"] == {"name": {"S": "999-00-1111"}} + read = env.deny_ssn.get_item( + TableName=env.table, Key=key, ProjectionExpression="#n", + ExpressionAttributeNames={"#n": "name"}, + ) + assert read["Item"] == {"name": {"S": "999-00-1111"}} + + +# -- reads the key does not stop ------------------------------------------- + + +def test_query_with_no_filter_or_projection_returns_the_whole_item(env: Env): + resp = env.deny_ssn.query( + TableName=env.table, + KeyConditionExpression="pk = :p", + ExpressionAttributeValues={":p": KEY_A["pk"]}, + ) + assert resp["Items"] == [ITEM_A] + + +def test_scan_from_an_exclusive_start_key_is_allowed(env: Env): + resp = env.deny_ssn.scan(TableName=env.table, ExclusiveStartKey=KEY_A) + assert "Items" in resp + + +# -- bare and Null conditions ---------------------------------------------- + + +def test_a_bare_negated_operator_grants_no_scan_naming_no_attribute(env: Env): + msg = _refusal(lambda: env.allow_scan_unless_ssn.scan(TableName=env.table), "Scan") + assert "because no identity-based policy allows the dynamodb:Scan action" in msg, msg + + +def test_a_bare_negated_operator_grants_no_scan_naming_another_attribute(env: Env): + msg = _refusal( + lambda: env.allow_scan_unless_ssn.scan( + TableName=env.table, ProjectionExpression="#n", + ExpressionAttributeNames={"#n": "name"}, + ), + "Scan", + ) + assert "because no identity-based policy allows the dynamodb:Scan action" in msg, msg + + +def test_a_null_deny_does_not_refuse_a_get_item_with_no_projection(env: Env): + resp = env.deny_get_when_absent.get_item(TableName=env.table, Key=KEY_A) + assert resp["Item"] == ITEM_A + + +def test_a_null_deny_does_not_refuse_a_projected_get_item(env: Env): + resp = env.deny_get_when_absent.get_item( + TableName=env.table, Key=KEY_A, ProjectionExpression="#n", + ExpressionAttributeNames={"#n": "name"}, + ) + assert resp["Item"] == {"name": {"S": "alice"}} + + +# -- the table's key attribute is in the key on GetItem ---------------------- + + +def test_for_all_values_allow_without_the_key_attribute_refuses_a_projected_get(env: Env): + msg = _refusal( + lambda: env.allow_get_name.get_item( + TableName=env.table, Key=KEY_A, ProjectionExpression="#n", + ExpressionAttributeNames={"#n": "name"}, + ), + "GetItem", + ) + assert "because no identity-based policy allows the dynamodb:GetItem action" in msg, msg + + +def test_for_all_values_allow_with_the_key_attribute_admits_a_projected_get(env: Env): + resp = env.allow_get_pk_name.get_item( + TableName=env.table, Key=KEY_A, ProjectionExpression="#n", + ExpressionAttributeNames={"#n": "name"}, + ) + assert resp["Item"] == {"name": {"S": "alice"}} + + +def test_for_all_values_allow_with_the_key_attribute_admits_a_get_with_no_projection( + env: Env, +): + resp = env.allow_get_pk_name.get_item(TableName=env.table, Key=KEY_A) + assert resp["Item"] == ITEM_A From c5b49ee16824ed7d51c1a7b4c0fe6078f7f57834 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:29:46 +0000 Subject: [PATCH 08/12] docs: describe the update action rule for dynamodb:Attributes The security model manual, the troubleshooting guide and the upgrade manual now say that the key holds the target of each UpdateExpression action and not the attributes a SET value reads, and that, 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. The security model manual lists the entries measured on DynamoDB on 2026-10-31 and 2026-11-01, and records that a bare StringNotEquals on the key matches neither a present nor an absent key. --- docs/manuals/07-upgrade-manual.md | 4 ++-- docs/manuals/10-security-model.md | 6 ++++-- docs/troubleshooting.md | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 9d812d043..c0d9c3d69 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -667,7 +667,7 @@ No catalog migration is required. Before upgrading a deployment that restricts t ### v0.1.15: `dynamodb:Attributes` holds the attributes every item request names -**What changed.** `dynamodb:Attributes` now holds the top-level names of the attributes a request names on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, `SearchVectors`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` and `TransactWriteItems`, as the security model manual lists them per operation. Before this release the key was read from `ProjectionExpression` alone, on single-table requests only, and a projected document path was compared as the whole path: the key was absent on `PutItem`, `UpdateItem` and `DeleteItem`, on reads that use `AttributesToGet`, and on every batch and transaction request, and a projection of `secret.a` held `secret.a` instead of `secret`. The key now also holds the attributes a condition, filter or update expression reads, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, `ExclusiveStartKey`, and on every item operation other than `Scan` and `SearchVectors` the key attributes of the table (and of a queried index). +**What changed.** `dynamodb:Attributes` now holds the top-level names of the attributes a request names on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, `SearchVectors`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` and `TransactWriteItems`, as the security model manual lists them per operation. Before this release the key was read from `ProjectionExpression` alone, on single-table requests only, and a projected document path was compared as the whole path: the key was absent on `PutItem`, `UpdateItem` and `DeleteItem`, on reads that use `AttributesToGet`, and on every batch and transaction request, and a projection of `secret.a` held `secret.a` instead of `secret`. The key now also holds the attributes a condition or filter expression reads, the target of each update expression action, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, `ExclusiveStartKey`, and on every item operation other than `Scan` and `SearchVectors` the key attributes of the table (and of a queried index). It does not hold the attributes a `SET` value reads. **Who is affected.** Any deployment with a policy statement that names `dynamodb:Attributes` in a condition: @@ -675,7 +675,7 @@ No catalog migration is required. Before upgrading a deployment that restricts t - An `Allow` gated on `ForAllValues:StringEquals` used to grant those requests whatever attributes they named, because the absent key satisfied it. It now grants only when every name the request holds is in the list. A list that omits the table's key attributes stops granting `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query` and the batch and transaction requests; add the key attributes of the table and of every index the statement covers. - `Null` with value `true` on `dynamodb:Attributes` matched those requests before, and matched a `GetItem`, `Query`, `BatchGetItem` or `TransactGetItems` read with no projection. It no longer matches any of them, because each holds at least the table's key attributes; it still matches a `Scan` that names no attribute. A statement that used it to refuse reads with no projection no longer refuses them. -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). No catalog migration is required. Before upgrading, check the policies of each account for the statements above. +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. --- diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index 88d5958d3..d93f5ae6b 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -139,7 +139,7 @@ extenddb supports the same access control patterns as real AWS IAM: |-----------|-----------------------------| | `GetItem` | The table's key attributes, `AttributesToGet`, and the attributes in `ProjectionExpression` | | `PutItem` | The table's key attributes, the item's attributes, `Expected`, and the attributes in `ConditionExpression` | -| `UpdateItem` | The `Key` attributes, the table's key attributes, `AttributeUpdates`, `Expected`, the attributes in `ConditionExpression`, and the attributes in `UpdateExpression`: the target of every `SET`, `REMOVE`, `ADD` and `DELETE` action and the attributes its value reads | +| `UpdateItem` | The `Key` attributes, the table's key attributes, `AttributeUpdates`, `Expected`, the attributes in `ConditionExpression`, and the target of every `SET`, `REMOVE`, `ADD` and `DELETE` action in `UpdateExpression`; not the attributes a `SET` value reads | | `DeleteItem` | The `Key` attributes, the table's key attributes, `Expected`, and the attributes in `ConditionExpression` | | `Query` | The key attributes of the table and of the queried index, `AttributesToGet`, `QueryFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | | `Scan` | `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | @@ -148,7 +148,9 @@ extenddb supports the same access control patterns as real AWS IAM: | `BatchWriteItem` | Per table: the attributes of every `PutRequest` item and `DeleteRequest` key, and the table's key attributes | | `TransactGetItems`, `TransactWriteItems` | Per item, what the single-item operation holds: `Get` as `GetItem`, `Put` as `PutItem`, `Update` as `UpdateItem`, `Delete` as `DeleteItem`; a `ConditionCheck` holds the table's key attributes and the attributes in its `ConditionExpression` | -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). A `Scan` that names no attribute is evaluated with the key absent. 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 `Scan` and `SearchVectors` holds them. The entries for the item attributes of `PutItem`, the target of a `SET`, `DeleteItem`, `AttributesToGet` on `GetItem` and `Scan`, projected document paths, reads with no projection, and the batch and transaction forms of those requests were measured on DynamoDB on 2026-10-31. The other entries follow the IAM documentation for the key, which counts the attributes read in a condition or a filter and requires the key attributes in an allowlist. +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` that names no attribute is evaluated with the key absent. A bare `StringNotEquals` on the key does not match whether the key is present or absent, so an `Allow` gated on it grants nothing (measured on DynamoDB 2026-11-01 on `Scan`). 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 `Scan` and `SearchVectors` holds them. + +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` and a `ConditionCheck`; `FilterExpression` on `Query` and `Scan`; `Expected`, `AttributeUpdates` and `ScanFilter`; `AttributesToGet` on `GetItem` and `Scan`; the table's key attribute on `GetItem`; projected document paths; reads with no projection; `DeleteItem`; and the `BatchGetItem`, `TransactGetItems`, `BatchWriteItem` and `TransactWriteItems` forms of a projection and a put. The other entries (`ExclusiveStartKey`, `QueryFilter`, `AttributesToGet` on `Query` and `BatchGetItem`, the queried index's key attributes, `DeleteRequest` keys, and the `Update` and `Delete` items of a transaction) follow the same rules. ### Resource ARNs diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f49de30bc..059f442ab 100755 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -331,10 +331,11 @@ These errors occur when `auth.provider = "builtin"` is enabled. To allowlist attributes (allow only when *every* requested attribute is in the set), use `ForAllValues:` on an `Allow` statement. The same rule applies to `dynamodb:LeadingKeys`. -`dynamodb:Attributes` holds the top-level names of the attributes the request names: the attributes of a `PutItem` item and of the items and keys in a `BatchWriteItem` request, the targets and operands of an `UpdateExpression`, `AttributesToGet`, the first name of each projected document path (`secret` for `secret.a`), the attributes a `ConditionExpression` or `FilterExpression` reads, and on every item operation other than `Scan` and `SearchVectors` the key attributes of the table (and of a queried index). Batch requests are evaluated per table and transactions per item. The [security model manual](manuals/10-security-model.md) lists what the key holds for each operation. Two cases follow from this: +`dynamodb:Attributes` holds the top-level names of the attributes the request names: the attributes of a `PutItem` item and of the items and keys in a `BatchWriteItem` request, the target of each `UpdateExpression` action, `AttributesToGet`, the first name of each projected document path (`secret` for `secret.a`), the attributes a `ConditionExpression` or `FilterExpression` reads, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, and on every item operation other than `Scan` and `SearchVectors` the key attributes of the table (and of a queried index). The attributes a `SET` value reads are not in it. Batch requests are evaluated per table and transactions per item. The [security model manual](manuals/10-security-model.md) lists what the key holds for each operation. It follows that: - A `Deny` on `ForAnyValue:StringEquals` over `ssn` does not block a `DeleteItem` of an item that holds `ssn`, or a read with no projection, which returns `ssn` with the rest of the item: neither request names `ssn`. DynamoDB answers the same way (measured 2026-10-31). -- An `Allow` on `ForAllValues:StringEquals` that does not list the table's key attributes grants no `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, batch or transaction request, because each of them holds the key attributes. List the key attributes of the table and of every index the statement covers. +- The same `Deny` does not block an `UpdateItem` that reads `ssn` only in a `SET` value. `SET #n = ssn` copies the value of `ssn` into `name`, and a projection of `name` then returns it. DynamoDB answers the same way (measured 2026-11-01). +- An `Allow` on `ForAllValues:StringEquals` that does not list the table's key attributes grants no `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, batch or transaction request, because each of them holds the key attributes (measured on DynamoDB 2026-11-01 for `GetItem`). List the key attributes of the table and of every index the statement covers. > Note: prior to v0.1.6, extenddb incorrectly treated a bare operator on these keys as an implicit AND across the requested values, so a single-attribute request appeared to be denied while a multi-attribute request leaked. That behavior diverged from AWS and gave a false sense of security. See the upgrade manual for migration guidance. From 5ab9c262cc428753737e1668c36dacb8b7de71a4 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:21:19 +0000 Subject: [PATCH 09/12] fix(server): hold the table's key attributes in dynamodb:Attributes on Scan A Scan now puts the key attributes of the table, and with IndexName the key attributes of that index, in dynamodb:Attributes, as GetItem and Query do. Before this change a Scan that named no attribute left the key absent. DynamoDB, measured on 2026-11-01: a Deny over pk refuses a Scan with no projection. An Allow of Scan alone with ForAllValues:StringEquals over ["name"] refuses a plain, a projected and a Select COUNT Scan; the same Allow over ["pk", "name"] admits the projected Scan, and over ["pk"] it admits a plain Scan. A Deny of Scan gated on Null {"dynamodb:Attributes": "true"} refuses neither a plain nor a projected Scan, and an Allow of Scan alone on the same Null grants neither a plain nor a Select COUNT Scan. With the key absent, ExtendDB used to give the opposite answer to each plain and Select COUNT Scan here except the one under ["pk"]; it also granted the projected Scan under ["name"], whose key held name alone. The index key attributes follow the IAM guide's rule that a policy on dynamodb:Attributes lists the key attributes of the table and of every index it covers. They were not measured. --- crates/server/src/request_attributes.rs | 66 +++++++++++------- crates/server/src/request_attributes/tests.rs | 68 ++++++++++++++++--- 2 files changed, 103 insertions(+), 31 deletions(-) diff --git a/crates/server/src/request_attributes.rs b/crates/server/src/request_attributes.rs index ea14a7d0c..a7d9bfaef 100644 --- a/crates/server/src/request_attributes.rs +++ b/crates/server/src/request_attributes.rs @@ -9,7 +9,9 @@ //! `Name, Address.City` gives `Name` and `Address`. The names come from the //! request alone. A `DeleteItem` and a read with no projection hold only the //! names the request gives, not the names of the stored item they delete or -//! return (measured 2026-10-31), and `ReturnValues` and `Select` add no names. +//! return (measured 2026-10-31). `ReturnValues`, +//! `ReturnValuesOnConditionCheckFailure` and `Select` add no names +//! (`ReturnValuesOnConditionCheckFailure` measured 2026-11-01). //! //! What the key holds for each operation: //! @@ -29,36 +31,52 @@ //! `AttributesToGet`, `QueryFilter`, `ExclusiveStartKey`, and the paths in //! `FilterExpression` and `ProjectionExpression`. A key condition can name //! only key attributes, which the key already holds. -//! - `Scan`: `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the paths -//! in `FilterExpression` and `ProjectionExpression`. A `Scan` names no key, so -//! a `Scan` that gives none of these leaves the key absent. -//! - `SearchVectors`: the paths in `ProjectionExpression`. +//! - `Scan`: the key attributes of the table and of the scanned index, +//! `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the paths in +//! `FilterExpression` and `ProjectionExpression`. A `Scan` that names no +//! attribute, a `Select COUNT` `Scan` included, holds the table's key +//! attributes (measured 2026-11-01). +//! - `SearchVectors`: the paths in `ProjectionExpression`. A `SearchVectors` +//! with no `ProjectionExpression` leaves the key absent. //! - `BatchGetItem`: per table, the attributes of every key, the table's key //! attributes, `AttributesToGet`, and the paths in `ProjectionExpression`. //! - `BatchWriteItem`: per table, the attributes of every `PutRequest` item and -//! every `DeleteRequest` key, and the table's key attributes. +//! every `DeleteRequest` key, and the table's key attributes. Each table's +//! part is evaluated once, with the union of its items' names, as on the +//! service (measured 2026-11-01). //! - `TransactGetItems` and `TransactWriteItems`: per item, as for the //! single-item operation the item maps to. A `ConditionCheck` holds the //! table's key attributes and the paths in its `ConditionExpression`. //! //! The service's answers were measured on 2026-10-31 and 2026-11-01 for the -//! item attributes of `PutItem`, every kind of `UpdateExpression` action and -//! `SET` value, `DeleteItem`, `ConditionExpression` on `PutItem` and -//! `DeleteItem`, `FilterExpression` on `Query` and `Scan`, `Expected`, -//! `AttributeUpdates`, `ScanFilter`, `AttributesToGet` on `GetItem` and `Scan`, -//! the table's key attributes on `GetItem`, a `ProjectionExpression` naming a -//! document path, reads with no projection, a `ConditionCheck`, and the batch -//! and transaction forms of `GetItem` and `PutItem`. The rest follows the same -//! rules: `ExclusiveStartKey`, `QueryFilter`, `AttributesToGet` on `Query` and -//! `BatchGetItem`, the queried index's key attributes, `DeleteRequest` keys, -//! and the `Update` and `Delete` items of a transaction. +//! item attributes of `PutItem`; every kind of `UpdateExpression` action and +//! `SET` value; `DeleteItem`; `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`; a `ProjectionExpression` +//! naming a document path; reads with no projection; a transaction `Update`; +//! and the batch and transaction forms of `GetItem` and `PutItem`. The rest +//! follows the rules measured for the same parameter on other operations: +//! `ProjectionExpression` on `Query` and `Scan`, `Expected` and +//! `ConditionExpression` on `UpdateItem`, and `Expected` on `DeleteItem`. The +//! key attributes of the queried or scanned index follow the IAM guide's rule +//! that a policy on the key 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 the service: a valid start key holds only key attributes, which the key +//! already holds. //! //! An expression is read with the tokenizer and parser the engine uses, under -//! the same size, token and depth limits, so every expression the engine -//! accepts gives its names here. An expression the engine refuses gives none: -//! the engine then answers `ValidationException` without reading or writing an -//! item. A `#name` with no `ExpressionAttributeNames` entry is skipped for the -//! same reason. +//! the same size, token and depth limits, so on every operation other than +//! `SearchVectors` an expression the engine accepts gives all its names here. +//! `SearchVectors` matches a `#name` against `ExpressionAttributeNames` keys +//! with their leading `#` removed, so it accepts keys this module does not +//! resolve, and its `SearchConditionExpression` is not read here (both #729). +//! An expression the engine refuses gives none: the engine then answers +//! `ValidationException` without reading or writing an item. A `#name` with no +//! `ExpressionAttributeNames` entry is skipped for the same reason. use std::collections::BTreeSet; @@ -92,6 +110,8 @@ pub(crate) fn single_table_attributes( names.range_read(input); } "Scan" => { + names.key_schema(key_info); + names.index_key_schema(input, key_info); names.map_keys(input.get("ScanFilter")); names.range_read(input); } @@ -227,8 +247,8 @@ impl<'a> Names<'a> { } } - /// The key attributes of the index a `Query` names. An index the table - /// does not have adds none: the engine refuses that request. + /// The key attributes of the index a `Query` or `Scan` names. An index the + /// table does not have adds none: the engine refuses that request. fn index_key_schema(&mut self, request: &Value, key_info: Option<&TableKeyInfo>) { let (Some(key_info), Some(index)) = (key_info, request.get("IndexName").and_then(Value::as_str)) diff --git a/crates/server/src/request_attributes/tests.rs b/crates/server/src/request_attributes/tests.rs index ebbdd8269..722b2670d 100644 --- a/crates/server/src/request_attributes/tests.rs +++ b/crates/server/src/request_attributes/tests.rs @@ -3,8 +3,8 @@ //! Unit tests for `dynamodb:Attributes` extraction. //! -//! The rows named "measured" mirror the service's answers of 2026-10-31; the -//! rest follow the IAM documentation for the key. +//! The rows named "measured" mirror the service's answers of 2026-10-31 and +//! 2026-11-01; the rest follow the IAM documentation for the key. use std::collections::HashMap; @@ -215,7 +215,7 @@ fn return_values_and_select_add_no_names() { names(&["pk"]) ); let input = json!({"TableName": "t", "Select": "ALL_ATTRIBUTES"}); - assert_eq!(single("Scan", &input, Some(&hash_table())), None); + assert_eq!(single("Scan", &input, Some(&hash_table())), names(&["pk"])); } // ---- reads ---------------------------------------------------------------- @@ -308,18 +308,70 @@ fn query_holds_the_legacy_filter_and_attributes_to_get() { } #[test] -fn scan_with_no_named_attribute_leaves_the_key_absent() { - // Measured: Scan with Limit=2 and no projection is allowed under a Deny on - // ssn and returns whole items. +fn scan_with_no_named_attribute_holds_the_table_key_attributes() { + // Measured: a Deny on pk refuses a Scan with no projection, and a Deny + // gated on Null true refuses none, so the key holds pk. A Scan with + // Limit=2 and no projection is allowed under a Deny on ssn and returns + // whole items. + let input = json!({"TableName": "t"}); + assert_eq!(single("Scan", &input, Some(&hash_table())), names(&["pk"])); let input = json!({"TableName": "t", "Limit": 2}); - assert_eq!(single("Scan", &input, Some(&hash_table())), None); + assert_eq!(single("Scan", &input, Some(&hash_table())), names(&["pk"])); +} + +#[test] +fn a_select_count_scan_holds_the_table_key_attributes() { + // Measured: an Allow of Scan gated on ForAllValues ["name"], or on Null + // true, grants no Select COUNT Scan. + let input = json!({"TableName": "t", "Select": "COUNT"}); + assert_eq!(single("Scan", &input, Some(&hash_table())), names(&["pk"])); +} + +#[test] +fn scan_holds_the_sort_key_of_the_table() { + let input = json!({"TableName": "i", "Limit": 1}); + assert_eq!( + single("Scan", &input, Some(&indexed_table())), + names(&["pk", "sk"]) + ); +} + +#[test] +fn scan_on_an_index_holds_the_index_key_attributes() { + // Follows the IAM guide's rule for the key attributes of an index; not + // measured. + let gsi = json!({"TableName": "i", "IndexName": "gsi1"}); + assert_eq!( + single("Scan", &gsi, Some(&indexed_table())), + names(&["gpk", "gsk", "pk", "sk"]) + ); + let lsi = json!({"TableName": "i", "IndexName": "lsi1"}); + assert_eq!( + single("Scan", &lsi, Some(&indexed_table())), + names(&["lsk", "pk", "sk"]) + ); + let unknown = json!({"TableName": "i", "IndexName": "nope"}); + assert_eq!( + single("Scan", &unknown, Some(&indexed_table())), + names(&["pk", "sk"]) + ); +} + +#[test] +fn a_scan_of_a_table_that_does_not_exist_names_only_what_it_gives() { + assert_eq!(single("Scan", &json!({"TableName": "gone"}), None), None); + let input = json!({"TableName": "gone", "ExclusiveStartKey": {"pk": {"S": "a"}}}); + assert_eq!(single("Scan", &input, None), names(&["pk"])); } #[test] fn scan_holds_its_filter_projection_and_start_key_names() { // Measured: Scan with AttributesToGet=[ssn] matches a Deny on ssn. let input = json!({"TableName": "t", "AttributesToGet": ["ssn"]}); - assert_eq!(single("Scan", &input, Some(&hash_table())), names(&["ssn"])); + assert_eq!( + single("Scan", &input, Some(&hash_table())), + names(&["pk", "ssn"]) + ); let input = json!({ "TableName": "t", "FilterExpression": "begins_with(#c, :p)", From 0f14e8a574318952ed2f955fc848d1eb2136e142 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:21:19 +0000 Subject: [PATCH 10/12] test: cover dynamodb:Attributes rows DynamoDB answered on 2026-11-01 Wire tests for rows DynamoDB answered on 2026-11-01 that had none: a Deny over pk, Allows over ForAllValues lists, and Null in a Deny and an Allow, on Scan; the table's key attribute on Query, UpdateItem, DeleteItem and a BatchWriteItem DeleteRequest; QueryFilter, AttributesToGet on Query and BatchGetItem, and the Update and Delete items of TransactWriteItems under a Deny on ssn; and ReturnValues ALL_OLD and ReturnValuesOnConditionCheckFailure ALL_OLD, which return the stored item with the denied attribute in it. An Allow of BatchWriteItem alone with ForAnyValue:StringEquals over ["name"] admits a batch that puts {pk, name} and {pk, ssn} on one table and writes both items, admits {pk, name} alone, and refuses {pk, ssn} alone. The service evaluates each table's part of the batch once, with the names of all its items, and ExtendDB does the same. Two rows were not measured, and their docstrings say so: a PutItem with ReturnValues ALL_OLD follows the UpdateItem row, and a Query on a global secondary index follows the IAM guide's rule that a policy on the key lists the key attributes of the table and of every index it covers. --- tests/test_dynamodb_attributes_expressions.py | 167 +++++++- ...test_dynamodb_attributes_key_attributes.py | 363 ++++++++++++++++++ 2 files changed, 525 insertions(+), 5 deletions(-) create mode 100644 tests/test_dynamodb_attributes_key_attributes.py diff --git a/tests/test_dynamodb_attributes_expressions.py b/tests/test_dynamodb_attributes_expressions.py index 2018ca77a..47c2a0927 100644 --- a/tests/test_dynamodb_attributes_expressions.py +++ b/tests/test_dynamodb_attributes_expressions.py @@ -2,12 +2,14 @@ # SPDX-License-Identifier: Apache-2.0 """dynamodb:Attributes for conditions, filters, legacy parameters, update -actions, key attributes, and bare and Null conditions. +actions, transaction items, key attributes, returned items, and bare and Null +conditions. -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 policies and the fixtures are those of -test_dynamodb_attributes_condition.py; its docstring describes them. +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 policies +and the fixtures are those of test_dynamodb_attributes_condition.py; its +docstring describes them. Prerequisites and run command: as for test_dynamodb_attributes_condition.py. @@ -230,3 +232,158 @@ def test_for_all_values_allow_with_the_key_attribute_admits_a_get_with_no_projec ): resp = env.allow_get_pk_name.get_item(TableName=env.table, Key=KEY_A) assert resp["Item"] == ITEM_A + + +# -- legacy reads and transaction items -------------------------------------- + +KEY_CONDITIONS_A = {"pk": {"ComparisonOperator": "EQ", "AttributeValueList": [KEY_A["pk"]]}} + + +def test_query_legacy_filter_naming_a_denied_attribute_is_denied(env: Env): + """A Query whose QueryFilter reads ssn is refused under deny(ssn).""" + _assert_denied( + lambda: env.deny_ssn.query( + TableName=env.table, KeyConditions=KEY_CONDITIONS_A, + QueryFilter={"ssn": {"ComparisonOperator": "NOT_NULL"}}, + ), + "Query", + ) + + +def test_query_attributes_to_get_naming_a_denied_attribute_is_denied(env: Env): + """A Query with AttributesToGet ["ssn"] is refused under deny(ssn).""" + _assert_denied( + lambda: env.deny_ssn.query( + TableName=env.table, KeyConditions=KEY_CONDITIONS_A, AttributesToGet=["ssn"] + ), + "Query", + ) + + +def test_query_attributes_to_get_of_another_attribute_returns_only_it(env: Env): + """A Query with AttributesToGet ["name"] returns name only.""" + resp = env.deny_ssn.query( + TableName=env.table, KeyConditions=KEY_CONDITIONS_A, AttributesToGet=["name"] + ) + assert resp["Items"] == [{"name": {"S": "alice"}}] + + +def test_batch_get_item_attributes_to_get_naming_a_denied_attribute_is_denied(env: Env): + """A BatchGetItem with AttributesToGet ["ssn"] is refused under + deny(ssn).""" + _assert_denied( + lambda: env.deny_ssn.batch_get_item( + RequestItems={env.table: {"Keys": [KEY_A], "AttributesToGet": ["ssn"]}} + ), + "BatchGetItem", + ) + + +def test_a_transaction_update_setting_a_denied_attribute_is_denied(env: Env): + """A TransactWriteItems Update that sets ssn is refused under + deny(ssn), and the refusal names dynamodb:UpdateItem.""" + key = _new_key() + env.admin.put_item(TableName=env.table, Item=key) + _assert_denied( + lambda: env.deny_ssn.transact_write_items(TransactItems=[{"Update": { + "TableName": env.table, "Key": key, "UpdateExpression": "SET ssn = :v", + "ExpressionAttributeValues": {":v": {"S": "1"}}, + }}]), + "UpdateItem", + ) + assert _stored(env, key) == key, "a refused write changes nothing" + + +def test_a_transaction_delete_whose_condition_reads_a_denied_attribute_is_denied(env: Env): + """A TransactWriteItems Delete whose condition reads ssn is refused + under deny(ssn), and the refusal names dynamodb:DeleteItem.""" + key = _new_key() + seeded = {**key, "ssn": {"S": "1"}} + env.admin.put_item(TableName=env.table, Item=seeded) + _assert_denied( + lambda: env.deny_ssn.transact_write_items(TransactItems=[{"Delete": { + "TableName": env.table, "Key": key, + "ConditionExpression": "attribute_not_exists(ssn)", + }}]), + "DeleteItem", + ) + assert _stored(env, key) == seeded, "a refused write changes nothing" + + +def test_a_transaction_delete_with_no_condition_is_allowed(env: Env): + """A TransactWriteItems Delete with no condition of an item holding + ssn is allowed under deny(ssn).""" + key = _new_key() + env.admin.put_item(TableName=env.table, Item={**key, "ssn": {"S": "1"}}) + env.deny_ssn.transact_write_items( + TransactItems=[{"Delete": {"TableName": env.table, "Key": key}}] + ) + assert _stored(env, key) is None + + +# -- returned items the key does not stop ------------------------------------ + + +def _seed_with_ssn(env: Env) -> tuple[dict, dict]: + key = _new_key() + seeded = {**key, "ssn": {"S": "1"}, "name": {"S": "old"}} + env.admin.put_item(TableName=env.table, Item=seeded) + return key, seeded + + +def test_return_values_all_old_on_an_update_returns_the_denied_attribute(env: Env): + """Under deny(ssn), an UpdateItem that sets name with ReturnValues + ALL_OLD on an item holding ssn is allowed and returns the old item, ssn + included.""" + key, seeded = _seed_with_ssn(env) + resp = env.deny_ssn.update_item( + TableName=env.table, Key=key, UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": "name"}, + ExpressionAttributeValues={":v": {"S": "new"}}, + ReturnValues="ALL_OLD", + ) + assert resp["Attributes"] == seeded + + +def test_return_values_all_old_on_a_put_returns_the_denied_attribute(env: Env): + """Follows the UpdateItem test above, not measured on PutItem: under + deny(ssn), a PutItem of pk and name with ReturnValues ALL_OLD on an item + holding ssn is allowed and returns the old item, ssn included.""" + key, seeded = _seed_with_ssn(env) + resp = env.deny_ssn.put_item( + TableName=env.table, Item={**key, "name": {"S": "new"}}, ReturnValues="ALL_OLD" + ) + assert resp["Attributes"] == seeded + + +def test_a_failed_put_condition_returns_the_whole_stored_item(env: Env): + """Under deny(ssn), a PutItem of pk whose condition fails, with + ReturnValuesOnConditionCheckFailure ALL_OLD, answers + ConditionalCheckFailedException carrying the stored item, ssn included.""" + key, seeded = _seed_with_ssn(env) + with pytest.raises(ClientError) as exc: + env.deny_ssn.put_item( + TableName=env.table, Item=key, ConditionExpression="attribute_not_exists(pk)", + ReturnValuesOnConditionCheckFailure="ALL_OLD", + ) + assert exc.value.response["Error"]["Code"] == "ConditionalCheckFailedException" + assert exc.value.response.get("Item") == seeded, exc.value.response + assert _stored(env, key) == seeded + + +def test_a_failed_condition_check_returns_the_whole_stored_item(env: Env): + """Under deny(ssn), a TransactWriteItems ConditionCheck that fails, + with ReturnValuesOnConditionCheckFailure ALL_OLD, answers + TransactionCanceledException whose cancellation reason carries the stored + item, ssn included.""" + key, seeded = _seed_with_ssn(env) + with pytest.raises(ClientError) as exc: + env.deny_ssn.transact_write_items(TransactItems=[{"ConditionCheck": { + "TableName": env.table, "Key": key, + "ConditionExpression": "attribute_not_exists(pk)", + "ReturnValuesOnConditionCheckFailure": "ALL_OLD", + }}]) + assert exc.value.response["Error"]["Code"] == "TransactionCanceledException" + reasons = exc.value.response.get("CancellationReasons") + assert reasons and reasons[0]["Code"] == "ConditionalCheckFailed", exc.value.response + assert reasons[0].get("Item") == seeded, reasons diff --git a/tests/test_dynamodb_attributes_key_attributes.py b/tests/test_dynamodb_attributes_key_attributes.py new file mode 100644 index 000000000..292479340 --- /dev/null +++ b/tests/test_dynamodb_attributes_key_attributes.py @@ -0,0 +1,363 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""dynamodb:Attributes and the key attributes of the table and of a queried +index: Allows over ForAllValues lists on Query, UpdateItem, DeleteItem, +BatchWriteItem and Scan, a Deny over the key attribute on Scan, Null in a +Deny and an Allow on Scan, and the evaluation of a BatchWriteItem once per +table. + +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; its docstring describes the +policies. + +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 + KEY_A, + Env, + _allow_only, + _assert_denied, + _create_user_with_key, + _deny, + _for_all, + _make_client, + _new_key, + _refusal, + _stored, + account_id, + auth_env, + env, + mgmt, + region, +) + +NULL_KEY = {"Null": {"dynamodb:Attributes": "true"}} + + +def _arn(region: str, account_id: str, table: str) -> str: + return f"arn:aws:dynamodb:{region}:{account_id}:table/{table}" + + +def _deny_when_absent(arn: str, action: str) -> dict: + """Everything on the table, except an `action` request whose key is absent.""" + return { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": arn}, + { + "Effect": "Deny", + "Action": f"dynamodb:{action}", + "Resource": arn, + "Condition": NULL_KEY, + }, + ], + } + + +@pytest.fixture(scope="module") +def table_arn(env: Env, account_id: str, region: str) -> str: + return _arn(region, account_id, 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 + + +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 + + +# -- the table's key attributes on other operations -------------------------- + + +def _naming_name_and_the_key(table: str) -> dict[str, Callable[[Any], Any]]: + """Per action, a request that names name, the table's key attribute, or + both.""" + return { + "Query": lambda c: c.query( + TableName=table, KeyConditionExpression="pk = :p", ProjectionExpression="#n", + ExpressionAttributeNames={"#n": "name"}, + ExpressionAttributeValues={":p": KEY_A["pk"]}), + "UpdateItem": lambda c: c.update_item( + TableName=table, Key=_new_key(), UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": "name"}, + ExpressionAttributeValues={":v": {"S": "n"}}), + "DeleteItem": lambda c: c.delete_item(TableName=table, Key=_new_key()), + "BatchWriteItem": lambda c: c.batch_write_item( + RequestItems={table: [{"DeleteRequest": {"Key": _new_key()}}]}), + } + + +KEY_ATTRIBUTE_ACTIONS = ["Query", "UpdateItem", "DeleteItem", "BatchWriteItem"] + + +@pytest.mark.parametrize("action", KEY_ATTRIBUTE_ACTIONS) +def test_for_all_values_allow_without_the_key_attribute_refuses( + env: Env, client_for, table_arn: str, action: str +): + """An Allow of the action alone with ForAllValues:StringEquals + over ["name"] refuses a Query projecting name, an UpdateItem setting name, + a DeleteItem and a BatchWriteItem DeleteRequest, because the key holds pk.""" + client = client_for(_allow_only(table_arn, action, _for_all(["name"]))) + _assert_not_allowed(lambda: _naming_name_and_the_key(env.table)[action](client), action) + + +@pytest.mark.parametrize("action", KEY_ATTRIBUTE_ACTIONS) +def test_for_all_values_allow_with_the_key_attribute_admits( + env: Env, client_for, table_arn: str, action: str +): + """The same Allow over ["pk", "name"] admits each request.""" + client = client_for(_allow_only(table_arn, action, _for_all(["pk", "name"]))) + _naming_name_and_the_key(env.table)[action](client) + + +# -- Scan holds the table's key attributes ----------------------------------- + + +def _scans(table: str) -> dict[str, Callable[[Any], Any]]: + return { + "plain": lambda c: c.scan(TableName=table, Limit=2), + "projected": lambda c: c.scan( + TableName=table, Limit=2, ProjectionExpression="#n", + ExpressionAttributeNames={"#n": "name"}), + "count": lambda c: c.scan(TableName=table, Select="COUNT"), + } + + +def _assert_whole_items(env: Env, resp: dict) -> None: + assert resp["Count"] == 2 + for item in resp["Items"]: + assert item == _stored(env, {"pk": item["pk"]}) + + +def test_a_deny_on_the_key_attribute_refuses_a_scan_with_no_projection( + env: Env, client_for, table_arn: str +): + """A Deny over pk refuses a Scan that names no attribute.""" + client = client_for(_deny(table_arn, "pk")) + _assert_denied(lambda: client.scan(TableName=env.table, Limit=1), "Scan") + + +def test_a_deny_on_the_key_attribute_refuses_a_scan_from_an_exclusive_start_key( + env: Env, client_for, table_arn: str +): + """A Deny over pk refuses a Scan whose only mention of pk is + ExclusiveStartKey. The Scan holds pk without the start key too, so whether + ExclusiveStartKey adds names of its own cannot be told on the service.""" + client = client_for(_deny(table_arn, "pk")) + _assert_denied(lambda: client.scan(TableName=env.table, ExclusiveStartKey=KEY_A), "Scan") + + +@pytest.mark.parametrize("shape", ["plain", "projected", "count"]) +def test_for_all_values_allow_without_the_key_attribute_refuses_a_scan( + env: Env, client_for, table_arn: str, shape: str +): + """An Allow of Scan alone with ForAllValues:StringEquals over + ["name"] refuses a Scan with no projection, a Scan projecting name and a + Select COUNT Scan.""" + client = client_for(_allow_only(table_arn, "Scan", _for_all(["name"]))) + _assert_not_allowed(lambda: _scans(env.table)[shape](client), "Scan") + + +def test_for_all_values_allow_with_the_key_attribute_admits_a_projected_scan( + env: Env, client_for, table_arn: str +): + """The same Allow over ["pk", "name"] admits a Scan projecting + name.""" + client = client_for(_allow_only(table_arn, "Scan", _for_all(["pk", "name"]))) + assert "Items" in _scans(env.table)["projected"](client) + + +def test_for_all_values_allow_of_the_key_attribute_admits_a_scan_of_whole_items( + env: Env, client_for, table_arn: str +): + """An Allow over ["pk"] admits a Scan with no projection, which + returns whole items.""" + client = client_for(_allow_only(table_arn, "Scan", _for_all(["pk"]))) + _assert_whole_items(env, _scans(env.table)["plain"](client)) + + +def test_a_null_deny_does_not_refuse_a_scan_with_no_projection( + env: Env, client_for, table_arn: str +): + """A Deny of Scan gated on Null true does not refuse a Scan that + names no attribute, because the key is present; the Scan returns whole + items.""" + client = client_for(_deny_when_absent(table_arn, "Scan")) + _assert_whole_items(env, _scans(env.table)["plain"](client)) + + +def test_a_null_deny_does_not_refuse_a_projected_scan(env: Env, client_for, table_arn: str): + """The same Deny does not refuse a Scan projecting name.""" + client = client_for(_deny_when_absent(table_arn, "Scan")) + assert "Items" in _scans(env.table)["projected"](client) + + +@pytest.mark.parametrize("shape", ["plain", "count"]) +def test_a_null_allow_grants_no_scan(env: Env, client_for, table_arn: str, shape: str): + """An Allow of Scan alone gated on Null true grants neither a Scan + with no projection nor a Select COUNT Scan.""" + client = client_for(_allow_only(table_arn, "Scan", NULL_KEY)) + _assert_not_allowed(lambda: _scans(env.table)[shape](client), "Scan") + + +# -- the queried index's key attributes -------------------------------------- + + +@pytest.fixture(scope="module") +def indexed(env: Env, account_id: str, region: str): + """A table keyed on pk with a global secondary index by-gk keyed on gk, + and the table's ARN.""" + table = f"attrs-ix-{uuid.uuid4().hex[:8]}" + env.admin.create_table( + TableName=table, + AttributeDefinitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "gk", "AttributeType": "S"}, + ], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + GlobalSecondaryIndexes=[{ + "IndexName": "by-gk", + "KeySchema": [{"AttributeName": "gk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(env.admin, table) + env.admin.put_item(TableName=table, Item={"pk": {"S": "a"}, "gk": {"S": "g"}}) + yield table, _arn(region, account_id, table) + try: + env.admin.delete_table(TableName=table) + except ClientError: + pass + + +def _allow_query_on_indexes(arn: str, attributes: list[str]) -> dict: + return { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "dynamodb:Query", + "Resource": [arn, f"{arn}/index/*"], + "Condition": _for_all(attributes), + }], + } + + +def _query_by_gk(client: Any, table: str) -> Any: + return client.query( + TableName=table, IndexName="by-gk", KeyConditionExpression="gk = :g", + ExpressionAttributeValues={":g": {"S": "g"}}, + ) + + +def test_for_all_values_allow_without_the_index_key_refuses_a_query_on_the_index( + indexed, client_for +): + """Follows the IAM guide's rule, not measured: a policy on + dynamodb:Attributes lists the key attributes of the table and of every + index it covers. An Allow of Query with ForAllValues:StringEquals over + ["pk"] refuses a Query on the index keyed on gk.""" + table, arn = indexed + client = client_for(_allow_query_on_indexes(arn, ["pk"])) + _assert_not_allowed(lambda: _query_by_gk(client, table), "Query") + + +def test_for_all_values_allow_with_the_index_key_admits_a_query_on_the_index( + indexed, client_for +): + """Follows the IAM guide's rule, not measured: the same Allow over + ["pk", "gk"] admits the Query on the index.""" + table, arn = indexed + client = client_for(_allow_query_on_indexes(arn, ["pk", "gk"])) + assert "Items" in _query_by_gk(client, table) + + +# -- a BatchWriteItem is evaluated once per table ---------------------------- + + +def _allow_batch_write_naming_name(table_arn: str) -> dict: + return _allow_only( + table_arn, + "BatchWriteItem", + {"ForAnyValue:StringEquals": {"dynamodb:Attributes": ["name"]}}, + ) + + +def test_a_batch_write_is_evaluated_once_per_table_with_the_names_of_all_its_items( + env: Env, client_for, table_arn: str +): + """An Allow of BatchWriteItem alone with ForAnyValue:StringEquals + over ["name"] admits a batch that puts {pk, name} and {pk, ssn} on one + table, and both items are written.""" + client = client_for(_allow_batch_write_naming_name(table_arn)) + named, other = _new_key(), _new_key() + resp = client.batch_write_item(RequestItems={env.table: [ + {"PutRequest": {"Item": {**named, "name": {"S": "n"}}}}, + {"PutRequest": {"Item": {**other, "ssn": {"S": "1"}}}}, + ]}) + assert resp["UnprocessedItems"] == {} + assert _stored(env, named) == {**named, "name": {"S": "n"}} + assert _stored(env, other) == {**other, "ssn": {"S": "1"}} + + +def test_a_batch_write_of_an_item_naming_a_listed_attribute_is_allowed( + env: Env, client_for, table_arn: str +): + """The same Allow admits a batch that puts {pk, name} alone.""" + client = client_for(_allow_batch_write_naming_name(table_arn)) + key = _new_key() + client.batch_write_item( + RequestItems={env.table: [{"PutRequest": {"Item": {**key, "name": {"S": "n"}}}}]} + ) + assert _stored(env, key) == {**key, "name": {"S": "n"}} + + +def test_a_batch_write_of_an_item_naming_no_listed_attribute_is_refused( + env: Env, client_for, table_arn: str +): + """The same Allow refuses a batch that puts {pk, ssn} alone.""" + client = client_for(_allow_batch_write_naming_name(table_arn)) + key = _new_key() + _assert_not_allowed( + lambda: client.batch_write_item( + RequestItems={env.table: [{"PutRequest": {"Item": {**key, "ssn": {"S": "1"}}}}]} + ), + "BatchWriteItem", + ) + assert _stored(env, key) is None, "a refused write stores nothing" From 2a821f94e8f727e9277e540b903e8a74ec734a4f Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:21:19 +0000 Subject: [PATCH 11/12] docs: describe dynamodb:Attributes on Scan and the upgrade's policy shapes The security model, the upgrade entry, the troubleshooting guide and the policy evaluator's comments now say that a Scan holds the table's key attributes. On a table that exists the key is absent only on a SearchVectors with no ProjectionExpression. On a table that does not exist it is also absent 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, because the extractor reads no Key on a GetItem or a Get and no key condition on a Query. The rows DynamoDB answered on 2026-11-01 are labelled measured; the index key attributes and the names of ExclusiveStartKey are labelled unmeasured. The upgrade entry opens its list with the rule the shapes follow: every item request other than SearchVectors now carries the key, holding at least the table's key attributes, so a ForAllValues condition no longer holds vacuously, a ForAnyValue condition can match, Null true stops matching and Null false starts matching. Its bullets cover a Deny gated on a ForAnyValue string operator; a Deny gated on a negated one, which now needs the key attributes in its list; an Allow gated on ForAllValues; an Allow gated on ForAnyValue over any listed attribute, with the per-table BatchWriteItem union as its example; the negated Allow shapes; and Null with either value. The security model says that a Deny on the key does not stop ReturnValues ALL_OLD or ReturnValuesOnConditionCheckFailure ALL_OLD from returning the whole stored item, as on DynamoDB, and that the IAM guide's guard against ReturnValues returning attributes is a condition on dynamodb:ReturnValues. The extractor's module docs say that ReturnValues and ReturnValuesOnConditionCheckFailure were measured to add no names and that Select was not measured. The unit test module docs say which unmeasured rows follow the IAM guide's rule and which follow the rule measured for the same parameter on another operation. --- crates/auth/src/policy/condition.rs | 20 ++++++++++++------- .../src/policy/condition/tests/multivalued.rs | 18 +++++++++++------ crates/auth/src/policy/context.rs | 15 ++++++++------ crates/server/src/request_attributes.rs | 6 +++--- crates/server/src/request_attributes/tests.rs | 7 +++++-- docs/manuals/07-upgrade-manual.md | 16 +++++++++------ docs/manuals/10-security-model.md | 12 ++++++----- docs/troubleshooting.md | 4 ++-- 8 files changed, 61 insertions(+), 37 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 0f7886798..c00ae39a6 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -131,13 +131,19 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext // it is right only where ExtendDB leaves the key absent on the same // requests the service does. ExtendDB leaves `dynamodb:LeadingKeys` // absent on query shapes it cannot pin to a partition value, where - // the service populates it, and `dynamodb:Attributes` absent only - // on a request that names no attribute, where the service's answer - // for a bare negated operator was not measured; matching there - // could grant an Allow the service refuses, so those keys keep the - // no-match rule. `aws:TagKeys` is populated on exactly the three - // operations the service reference lists it for, so its absent-key - // answer is IAM's. + // the service populates it. It leaves `dynamodb:Attributes` absent + // only on a `SearchVectors` with no `ProjectionExpression` (#729) + // 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. On the service the key holds the table's key + // attributes on every item request measured, a `Scan` that names + // no attribute included, and an Allow gated on a bare + // `StringNotEquals` over it grants no `Scan` (measured 2026-11-01). + // Matching on those absent keys could grant an Allow the service + // refuses, so they keep the no-match rule. `aws:TagKeys` is + // populated on exactly the three operations the service reference + // lists it for, so its absent-key answer is IAM's. None => { is_negative_operator(other) && (!is_multivalued diff --git a/crates/auth/src/policy/condition/tests/multivalued.rs b/crates/auth/src/policy/condition/tests/multivalued.rs index aad148ae8..2eedae166 100644 --- a/crates/auth/src/policy/condition/tests/multivalued.rs +++ b/crates/auth/src/policy/condition/tests/multivalued.rs @@ -459,12 +459,18 @@ fn bare_negated_op_absent_tagkeys_matches() { #[test] fn bare_negated_op_absent_dynamodb_keys_do_not_match() { // IAM matches a bare negated operator on absent dynamodb:Attributes and - // dynamodb:LeadingKeys too, but ExtendDB leaves both absent on requests - // where the service populates them (Attributes on writes, AttributesToGet - // reads and batch and transaction targets; LeadingKeys on query shapes the - // extractor cannot pin). Matching there would grant an Allow the service - // refuses for the present key, so for these two keys an absent key keeps - // the no-match rule (BR-7085). + // dynamodb:LeadingKeys too. ExtendDB leaves dynamodb:LeadingKeys absent on + // query shapes it cannot pin to a partition value, where the service + // populates it. It leaves dynamodb:Attributes absent only on a + // SearchVectors with no ProjectionExpression (#729) 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. On the service the key holds the table's key + // attributes on every item request measured, a Scan that names no + // attribute included, and an Allow gated on a bare StringNotEquals over it + // grants no Scan (measured 2026-11-01). Matching on those absent keys could + // grant an Allow the service refuses, so for these two keys an absent key + // keeps the no-match rule (BR-7085). let ctx = TestContext::new(); for key in ["dynamodb:Attributes", "dynamodb:LeadingKeys"] { for op in [ diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index ed3372726..16220ec67 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -460,12 +460,15 @@ impl ConditionContext for RequestContext { // its absence here is the service's absence. `dynamodb:LeadingKeys` is // left absent on query shapes the extractor cannot pin to a partition // value, where the service populates it. `dynamodb:Attributes` is - // absent only on a request that names no attribute (a `Scan` that - // projects and filters nothing, or a request on a table that does not - // exist), where the service's answer for a bare negated operator was - // not measured. For those two an absent key keeps the no-match rule, - // so an Allow gated on a bare negated operator over them grants - // nothing. + // absent only on a `SearchVectors` with no `ProjectionExpression` + // (#729) 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. On the service the key is present on a `Scan` that + // names no attribute, and an Allow gated on a bare `StringNotEquals` + // over it grants no `Scan` (measured 2026-11-01). + // For those two keys an absent key keeps the no-match rule, so an + // Allow gated on a bare negated operator over them grants nothing. super::casefold::case_fold(key) == "aws:tagkeys" } } diff --git a/crates/server/src/request_attributes.rs b/crates/server/src/request_attributes.rs index a7d9bfaef..8c7a854f7 100644 --- a/crates/server/src/request_attributes.rs +++ b/crates/server/src/request_attributes.rs @@ -9,9 +9,9 @@ //! `Name, Address.City` gives `Name` and `Address`. The names come from the //! request alone. A `DeleteItem` and a read with no projection hold only the //! names the request gives, not the names of the stored item they delete or -//! return (measured 2026-10-31). `ReturnValues`, -//! `ReturnValuesOnConditionCheckFailure` and `Select` add no names -//! (`ReturnValuesOnConditionCheckFailure` measured 2026-11-01). +//! return (measured 2026-10-31). `ReturnValues` and +//! `ReturnValuesOnConditionCheckFailure` add no names (measured 2026-11-01), +//! and `Select` adds none (not measured). //! //! What the key holds for each operation: //! diff --git a/crates/server/src/request_attributes/tests.rs b/crates/server/src/request_attributes/tests.rs index 722b2670d..b44d97f25 100644 --- a/crates/server/src/request_attributes/tests.rs +++ b/crates/server/src/request_attributes/tests.rs @@ -3,8 +3,11 @@ //! Unit tests for `dynamodb:Attributes` extraction. //! -//! The rows named "measured" mirror the service's answers of 2026-10-31 and -//! 2026-11-01; the rest follow the IAM documentation for the key. +//! Rows marked "Measured" mirror the service's answers of 2026-10-31 and +//! 2026-11-01; the parent module's docs list what was measured. Index key +//! attributes and a table's sort key follow the IAM guide's rule, not measured +//! (the measured table was keyed on `pk` alone). Other unmeasured parameters +//! follow the rule measured for the same parameter on another operation. use std::collections::HashMap; diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index c0d9c3d69..c54e40be2 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -643,7 +643,7 @@ To get the intended behaviour, write the condition on `aws:ResourceTag/`. - A `Deny` with a wildcarded `ArnNotEquals` now exempts the callers the wildcard matches; before, it applied to every caller. - A bare `*` value now matches every ARN. An `Allow` gated on `ArnEquals: "*"` grants, and a `Deny` gated on it applies, whenever the key holds an ARN. A `Deny` gated on `ArnNotEquals: "*"` now applies only when the key is absent, and an `Allow` gated on it grants only then. AWS IAM Access Analyzer reports a bare `*` Arn value as an error, and the IAM policy simulator refuses to evaluate one against an ARN, but ExtendDB accepts it at write. Replace any of these shapes before upgrading if you wrote it by hand. - An Arn operator over a key whose value is not an ARN, such as `aws:ResourceTag/`, is now false, so a `Deny` written that way no longer applies and an `Allow` gated on one no longer grants. -- `dynamodb:LeadingKeys`, `dynamodb:Attributes` and `aws:TagKeys` hold values the caller or the admin chooses: the partition key values in a request (every key in a batch), the attribute names in a projection, and the session tag keys in an AssumeRole call. Each value can be ARN-shaped or not. A `ForAnyValue:` or `ForAllValues:` Arn condition over one of these keys is now false while any value in the set is not an ARN and the policy lists at least one value. A `Deny` gated on `ForAnyValue:ArnNotLike` with `arn:aws:dynamodb:us-east-1:123456789012:table/Z*` on `dynamodb:LeadingKeys` no longer applies to a request whose partition key values include one that is not an ARN, and `ForAnyValue:ArnLike` with `j*` on the same key no longer holds for the partition key value `junk`. +- `dynamodb:LeadingKeys`, `dynamodb:Attributes` and `aws:TagKeys` hold values the caller or the admin chooses: the partition key values in a request (every key in a batch), the attribute names in a request, and the session tag keys in an AssumeRole call. Each value can be ARN-shaped or not. A `ForAnyValue:` or `ForAllValues:` Arn condition over one of these keys is now false while any value in the set is not an ARN and the policy lists at least one value. A `Deny` gated on `ForAnyValue:ArnNotLike` with `arn:aws:dynamodb:us-east-1:123456789012:table/Z*` on `dynamodb:LeadingKeys` no longer applies to a request whose partition key values include one that is not an ARN, and `ForAnyValue:ArnLike` with `j*` on the same key no longer holds for the partition key value `junk`. A policy whose Arn values are literal ARNs, applied to a context value that is an ARN, evaluates as before. @@ -667,13 +667,17 @@ No catalog migration is required. Before upgrading a deployment that restricts t ### v0.1.15: `dynamodb:Attributes` holds the attributes every item request names -**What changed.** `dynamodb:Attributes` now holds the top-level names of the attributes a request names on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, `SearchVectors`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` and `TransactWriteItems`, as the security model manual lists them per operation. Before this release the key was read from `ProjectionExpression` alone, on single-table requests only, and a projected document path was compared as the whole path: the key was absent on `PutItem`, `UpdateItem` and `DeleteItem`, on reads that use `AttributesToGet`, and on every batch and transaction request, and a projection of `secret.a` held `secret.a` instead of `secret`. The key now also holds the attributes a condition or filter expression reads, the target of each update expression action, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, `ExclusiveStartKey`, and on every item operation other than `Scan` and `SearchVectors` the key attributes of the table (and of a queried index). It does not hold the attributes a `SET` value reads. +**What changed.** `dynamodb:Attributes` now holds the top-level names of the attributes a request names on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, `SearchVectors`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` and `TransactWriteItems`, as the security model manual lists them per operation. Before this release the key was read from `ProjectionExpression` alone, on single-table requests only, and a projected document path was compared as the whole path: the key was absent on `PutItem`, `UpdateItem` and `DeleteItem`, on reads that use `AttributesToGet`, and on every batch and transaction request, and a projection of `secret.a` held `secret.a` instead of `secret`. The key now also holds the attributes a condition or filter expression reads, the target of each update expression action, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, `ExclusiveStartKey` (whether it adds names of its own cannot be told on the service), and on every item operation other than `SearchVectors` the key attributes of the table and of the index a `Query` or `Scan` names (the index key attributes follow the IAM guide's rule, not measured). It does not hold the attributes a `SET` value reads. -**Who is affected.** Any deployment with a policy statement that names `dynamodb:Attributes` in a condition: +**Who is affected.** Any deployment with a policy statement that names `dynamodb:Attributes` in a condition. On a table that exists, every item request other than `SearchVectors` now carries the key, holding at least the table's key attributes, so a `ForAllValues` condition no longer holds vacuously on an absent key, a `ForAnyValue` condition can match, `Null` with value `true` stops matching and `Null` with value `false` starts matching. A `SearchVectors` still holds only the names in its `ProjectionExpression` and leaves the key absent when it has none. Statements of these shapes now decide differently: -- A `Deny` gated on `ForAnyValue:StringEquals` over an attribute now applies to writes that name it, to `AttributesToGet` reads and projections of it or of a path inside it, to conditions and filters that read it, and to the batch and transaction forms of those requests. It did not apply to them before. -- An `Allow` gated on `ForAllValues:StringEquals` used to grant those requests whatever attributes they named, because the absent key satisfied it. It now grants only when every name the request holds is in the list. A list that omits the table's key attributes stops granting `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query` and the batch and transaction requests; add the key attributes of the table and of every index the statement covers. -- `Null` with value `true` on `dynamodb:Attributes` matched those requests before, and matched a `GetItem`, `Query`, `BatchGetItem` or `TransactGetItems` read with no projection. It no longer matches any of them, because each holds at least the table's key attributes; it still matches a `Scan` that names no attribute. A statement that used it to refuse reads with no projection no longer refuses them. +- A `Deny` gated on a `ForAnyValue:String*` operator that matches an attribute's name (`StringEquals`, `StringLike`, `StringEqualsIgnoreCase`) now applies to writes that name the attribute, to update actions that target it, to `AttributesToGet` reads, to projections of a path inside it, to conditions and filters that read it, and to batch and transaction requests. It did not apply to them before. +- A `Deny` gated on a negated `ForAnyValue:String*` operator (`StringNotEquals`, `StringNotLike`, `StringNotEqualsIgnoreCase`) now applies to every item request whose names fall outside its list, the key attributes included, so the list must hold the key attributes of the table and of every index the statement covers (the index key attributes follow the IAM guide's rule, not measured). A list that omits them now refuses every `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, batch and transaction request on the table. +- An `Allow` gated on `ForAllValues:StringEquals` used to grant every request that left the key absent, whatever attributes it named, because the absent key satisfied it. It now grants only when every name the request holds is in the list. A list that omits the table's key attributes stops granting `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan` and the batch and transaction requests (measured on DynamoDB 2026-11-01 for `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `BatchWriteItem` `DeleteRequest`); add the key attributes of the table and of every index the statement covers (the index key attributes follow the IAM guide's rule, not measured). +- An `Allow` gated on a `ForAnyValue:String*` operator now grants every item request its `Action` covers that holds a name the list matches. Before, it granted only a single-table request whose `ProjectionExpression` named one. A `BatchWriteItem` is evaluated once per table, with the union of that table's item names, so an `Allow` of `BatchWriteItem` over `["name"]` grants a batch that puts `{pk, name}` and `{pk, ssn}` on one table and writes both items, and refuses a batch that puts `{pk, ssn}` alone, as on DynamoDB (measured 2026-11-01). A list that holds a key attribute grants every item request other than `SearchVectors` that the `Action` covers, because each holds the table's key attributes; DynamoDB puts them in the key on `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `BatchWriteItem` `DeleteRequest` (measured 2026-11-01). An `Allow` of `GetItem` over `["pk"]`, on a table keyed on `pk`, grants every `GetItem` on the table, one with no projection included, which returns the whole item. +- An `Allow` of `PutItem` gated on `ForAnyValue:StringNotEquals` over `["ssn"]` granted no `PutItem` before, and now grants one of `{pk, ssn}`, because `pk` is outside the list. An `Allow` of `PutItem` gated on `ForAllValues:StringNotEquals` over `["ssn"]` granted every `PutItem` before, because the absent key satisfied it, and now refuses one of `{pk, ssn}` and grants one of `{pk, name}`. +- `Null` with value `true` matched every request that left the key absent before. A statement gated on it no longer matches any item request except a `SearchVectors` with no `ProjectionExpression` and, on a table that does not exist, 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` gated on it stops refusing the other item requests and an `Allow` gated on it stops granting them, as on DynamoDB (measured 2026-11-01 on `GetItem` and `Scan`). +- `Null` with value `false` matched only a single-table request with a `ProjectionExpression` before, and now matches every item request that carries the key. An `Allow` gated on it, written to require that a read names its attributes, now grants each of these requests that its `Action` covers, a read with no projection and its whole item included, and a `Deny` gated on it now refuses them. 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. diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index d93f5ae6b..d959f04b8 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -142,15 +142,17 @@ extenddb supports the same access control patterns as real AWS IAM: | `UpdateItem` | The `Key` attributes, the table's key attributes, `AttributeUpdates`, `Expected`, the attributes in `ConditionExpression`, and the target of every `SET`, `REMOVE`, `ADD` and `DELETE` action in `UpdateExpression`; not the attributes a `SET` value reads | | `DeleteItem` | The `Key` attributes, the table's key attributes, `Expected`, and the attributes in `ConditionExpression` | | `Query` | The key attributes of the table and of the queried index, `AttributesToGet`, `QueryFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | -| `Scan` | `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | -| `SearchVectors` | The attributes in `ProjectionExpression` | +| `Scan` | The key attributes of the table and of the scanned index, `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | +| `SearchVectors` | The attributes in `ProjectionExpression`; the key is absent when there is none | | `BatchGetItem` | Per table: the attributes of every key, the table's key attributes, `AttributesToGet`, and the attributes in `ProjectionExpression` | -| `BatchWriteItem` | Per table: the attributes of every `PutRequest` item and `DeleteRequest` key, and the table's key attributes | +| `BatchWriteItem` | Per table: the attributes of every `PutRequest` item and `DeleteRequest` key, and the table's key attributes, evaluated once with the union of the table's items | | `TransactGetItems`, `TransactWriteItems` | Per item, what the single-item operation holds: `Get` as `GetItem`, `Put` as `PutItem`, `Update` as `UpdateItem`, `Delete` as `DeleteItem`; a `ConditionCheck` holds the table's key attributes and the attributes in its `ConditionExpression` | -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` that names no attribute is evaluated with the key absent. A bare `StringNotEquals` on the key does not match whether the key is present or absent, so an `Allow` gated on it grants nothing (measured on DynamoDB 2026-11-01 on `Scan`). 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 `Scan` and `SearchVectors` holds them. +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. -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` and a `ConditionCheck`; `FilterExpression` on `Query` and `Scan`; `Expected`, `AttributeUpdates` and `ScanFilter`; `AttributesToGet` on `GetItem` and `Scan`; the table's key attribute on `GetItem`; projected document paths; reads with no projection; `DeleteItem`; and the `BatchGetItem`, `TransactGetItems`, `BatchWriteItem` and `TransactWriteItems` forms of a projection and a put. The other entries (`ExclusiveStartKey`, `QueryFilter`, `AttributesToGet` on `Query` and `BatchGetItem`, the queried index's key attributes, `DeleteRequest` keys, and the `Update` and `Delete` items of a transaction) follow the same rules. +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). + +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. ### Resource ARNs diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 059f442ab..a11ae8e2b 100755 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -331,11 +331,11 @@ These errors occur when `auth.provider = "builtin"` is enabled. To allowlist attributes (allow only when *every* requested attribute is in the set), use `ForAllValues:` on an `Allow` statement. The same rule applies to `dynamodb:LeadingKeys`. -`dynamodb:Attributes` holds the top-level names of the attributes the request names: the attributes of a `PutItem` item and of the items and keys in a `BatchWriteItem` request, the target of each `UpdateExpression` action, `AttributesToGet`, the first name of each projected document path (`secret` for `secret.a`), the attributes a `ConditionExpression` or `FilterExpression` reads, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, and on every item operation other than `Scan` and `SearchVectors` the key attributes of the table (and of a queried index). The attributes a `SET` value reads are not in it. Batch requests are evaluated per table and transactions per item. The [security model manual](manuals/10-security-model.md) lists what the key holds for each operation. It follows that: +`dynamodb:Attributes` holds the top-level names of the attributes the request names: the attributes of a `PutItem` item and of the items and keys in a `BatchWriteItem` request, the target of each `UpdateExpression` action, `AttributesToGet`, the first name of each projected document path (`secret` for `secret.a`), the attributes a `ConditionExpression` or `FilterExpression` reads, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, and on every item operation other than `SearchVectors` the key attributes of the table (and of the index a `Query` or `Scan` names). The attributes a `SET` value reads are not in it. Batch requests are evaluated per table and transactions per item. The [security model manual](manuals/10-security-model.md) lists what the key holds for each operation. It follows that: - A `Deny` on `ForAnyValue:StringEquals` over `ssn` does not block a `DeleteItem` of an item that holds `ssn`, or a read with no projection, which returns `ssn` with the rest of the item: neither request names `ssn`. DynamoDB answers the same way (measured 2026-10-31). - The same `Deny` does not block an `UpdateItem` that reads `ssn` only in a `SET` value. `SET #n = ssn` copies the value of `ssn` into `name`, and a projection of `name` then returns it. DynamoDB answers the same way (measured 2026-11-01). -- An `Allow` on `ForAllValues:StringEquals` that does not list the table's key attributes grants no `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, batch or transaction request, because each of them holds the key attributes (measured on DynamoDB 2026-11-01 for `GetItem`). List the key attributes of the table and of every index the statement covers. +- An `Allow` on `ForAllValues:StringEquals` that does not list the table's key attributes grants no `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, batch or transaction request, because each of them holds the key attributes (measured on DynamoDB 2026-11-01 for `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `BatchWriteItem` `DeleteRequest`). List the key attributes of the table and of every index the statement covers; the index key attributes follow the IAM guide's rule and were not measured. > Note: prior to v0.1.6, extenddb incorrectly treated a bare operator on these keys as an implicit AND across the requested values, so a single-attribute request appeared to be denied while a multi-attribute request leaked. That behavior diverged from AWS and gave a false sense of security. See the upgrade manual for migration guidance. From 147378ac5e8c8a65c268a647d92f793187574db6 Mon Sep 17 00:00:00 2001 From: Lee <29015211+LeeroyHannigan@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:33:08 +0000 Subject: [PATCH 12/12] fix(server): match dynamodb:Attributes on index scans and missing tables The review of this pull request measured three request shapes on DynamoDB on 2026-11-09, in two accounts. A further measurement on 2026-11-10, in one account, covered a later page of a Scan. This change follows those measurements. A Scan with IndexName no longer adds the index's key schema. On a global secondary index the service allows a Scan under an Allow whose ForAllValues list holds only the table's keys. A Deny on that index's partition key does not refuse it either. On a local secondary index, an Allow of the table's keys and the projected name grants it. The branch refused these first-page requests, which carry no ExclusiveStartKey. A later page's start key holds the table's and the index's key attributes, and the key holds them. DynamoDB does the same: on 2026-11-10 it refused page 2 of either index under both Allows. A Deny on the global index's partition key or the local index's sort key refused it too. Page 2 of the table passed under the Allow of its keys alone. A Query of an index still adds its full key schema, sort key included (measured on a global secondary index). A Query now adds the names in KeyConditionExpression and the keys of KeyConditions. The expression goes through the engine's limits, its reserved-word check when the enforce_reserved_keywords setting is on, and its key-condition parser. A key condition that one of them refuses adds no names. On a table that exists, the engine refuses a non-key attribute in KeyConditions. It also refuses one in a KeyConditionExpression on a key schema with several HASH or several RANGE attributes. On a key schema with one HASH and at most one RANGE attribute, it checks a KeyConditionExpression only for an equality on the partition key. So it accepts a clause on another attribute (crates/engine/src/query.rs:295-297 and :328-336), and the key also holds that name. On a missing table the service answers AccessDeniedException under a Deny on a name in the key condition. The branch answered ResourceNotFoundException there, which told a denied caller the table was missing. BatchGetItem no longer adds the names in Keys. For a table named by its name it holds what GetItem holds. On a missing table the service answers ResourceNotFoundException under a Deny on the key attribute, where the branch answered AccessDeniedException. A RequestItems key that is a table ARN gets no key info at authorization, so that part holds no key attributes. A GetItem by the same ARN holds them. A separate change will close that gap. The security model manual, troubleshooting guide, v0.1.15 upgrade entry and evaluator comments on the absent key describe these rules. The manual also says how the PartiQL path fills the key. Unit tests cover each arm. Wire tests cover the review's experiments and four later-page answers. --- crates/auth/src/policy/condition.rs | 16 +- .../src/policy/condition/tests/multivalued.rs | 17 +- crates/auth/src/policy/context.rs | 13 +- crates/server/src/request_attributes.rs | 164 +++++-- crates/server/src/request_attributes/tests.rs | 184 +++++++- docs/manuals/07-upgrade-manual.md | 8 +- docs/manuals/10-security-model.md | 10 +- docs/troubleshooting.md | 4 +- ...test_dynamodb_attributes_key_attributes.py | 401 +++++++++++++++++- 9 files changed, 723 insertions(+), 94 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 92a7df01d..aefbe1f91 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -158,13 +158,15 @@ pub fn evaluate_condition_with( // absent on query shapes it cannot pin to a partition value, where // the service populates it. It leaves `dynamodb:Attributes` absent // only on a `SearchVectors` with no `ProjectionExpression` (#729) - // 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. On the service the key holds the table's key - // attributes on every item request measured, a `Scan` that names - // no attribute included, and an Allow gated on a bare - // `StringNotEquals` over it grants no `Scan` (measured 2026-11-01). + // and, on a table that does not exist, on a `GetItem`, + // transaction `Get` or `BatchGetItem` that names no attribute + // outside its keys, a `Query` whose key condition the key + // condition parser refuses and that names none elsewhere, and a + // `Scan` that names none. On the service the key holds the + // table's key attributes on every item request measured, a `Scan` + // that names no attribute included, and an Allow gated on a bare + // `StringNotEquals` over it grants no `Scan` (measured + // 2026-11-01). // Matching on those absent keys could grant an Allow the service // refuses, so they keep the no-match rule. `aws:TagKeys` is // populated on exactly the three operations the service reference diff --git a/crates/auth/src/policy/condition/tests/multivalued.rs b/crates/auth/src/policy/condition/tests/multivalued.rs index eed990865..763192567 100644 --- a/crates/auth/src/policy/condition/tests/multivalued.rs +++ b/crates/auth/src/policy/condition/tests/multivalued.rs @@ -496,14 +496,15 @@ fn bare_negated_op_absent_dynamodb_keys_do_not_match() { // query shapes it cannot pin to a partition value, where the service // populates it. It leaves dynamodb:Attributes absent only on a // SearchVectors with no ProjectionExpression (#729) 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. On the service the key holds the table's key - // attributes on every item request measured, a Scan that names no - // attribute included, and an Allow gated on a bare StringNotEquals over it - // grants no Scan (measured 2026-11-01). Matching on those absent keys could - // grant an Allow the service refuses, so for these two keys an absent key - // keeps the no-match rule (BR-7085). + // does not exist, on a GetItem, transaction Get or BatchGetItem that names + // no attribute outside its keys, a Query whose key condition the key + // condition parser refuses and that names none elsewhere, and a Scan that + // names none. On the service the key holds the table's key attributes on + // every item request measured, a Scan that names no attribute included, + // and an Allow gated on a bare StringNotEquals over it grants no Scan + // (measured 2026-11-01). Matching on those absent keys could grant an + // Allow the service refuses, so for these two keys an absent key keeps the + // no-match rule (BR-7085). let ctx = TestContext::new(); for key in ["dynamodb:Attributes", "dynamodb:LeadingKeys"] { for op in [ diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 0d1a34573..8f13aa580 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -481,12 +481,13 @@ impl ConditionContext for RequestContext { // left absent on query shapes the extractor cannot pin to a partition // value, where the service populates it. `dynamodb:Attributes` is // absent only on a `SearchVectors` with no `ProjectionExpression` - // (#729) 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. On the service the key is present on a `Scan` that - // names no attribute, and an Allow gated on a bare `StringNotEquals` - // over it grants no `Scan` (measured 2026-11-01). + // (#729) and, on a table that does not exist, on a `GetItem`, + // transaction `Get` or `BatchGetItem` that names no attribute outside + // its keys, a `Query` whose key condition the key condition parser + // refuses and that names none elsewhere, and a `Scan` that names none. + // On the service the key is present on a `Scan` that names no + // attribute, and an Allow gated on a bare `StringNotEquals` over it + // grants no `Scan` (measured 2026-11-01). // For those two keys an absent key keeps the no-match rule, so an // Allow gated on a bare negated operator over them grants nothing. super::casefold::case_fold(key) == "aws:tagkeys" diff --git a/crates/server/src/request_attributes.rs b/crates/server/src/request_attributes.rs index 8c7a854f7..8e07bc3bb 100644 --- a/crates/server/src/request_attributes.rs +++ b/crates/server/src/request_attributes.rs @@ -27,19 +27,49 @@ //! `name`, under a Deny on `ssn` (measured 2026-11-01). //! - `DeleteItem`: the `Key` attributes, the table's key attributes, `Expected`, //! and the paths in `ConditionExpression`. -//! - `Query`: the key attributes of the table and of the queried index, -//! `AttributesToGet`, `QueryFilter`, `ExclusiveStartKey`, and the paths in -//! `FilterExpression` and `ProjectionExpression`. A key condition can name -//! only key attributes, which the key already holds. -//! - `Scan`: the key attributes of the table and of the scanned index, -//! `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the paths in -//! `FilterExpression` and `ProjectionExpression`. A `Scan` that names no -//! attribute, a `Select COUNT` `Scan` included, holds the table's key -//! attributes (measured 2026-11-01). +//! - `Query`: the key attributes of the table and of the queried index, the +//! paths in `KeyConditionExpression`, `KeyConditions`, `AttributesToGet`, +//! `QueryFilter`, `ExclusiveStartKey`, and the paths in `FilterExpression` +//! and `ProjectionExpression`. On a table that exists, the engine refuses a +//! non-key attribute in `KeyConditions`, and in a `KeyConditionExpression` +//! on a key schema with several HASH or several RANGE attributes. On a key +//! schema with one HASH attribute and at most one RANGE attribute it checks +//! a `KeyConditionExpression` only for an equality on the partition key +//! (the note on the single-attribute path in `handle_query`, +//! `crates/engine/src/query.rs`), so it accepts a clause on another +//! attribute, and the key also holds that name. On a table that does not +//! exist the key holds the names in the key condition, a non-key name +//! included: under a Deny on one of them the service answers +//! `AccessDeniedException`, not `ResourceNotFoundException` (measured +//! 2026-11-09 for `KeyConditionExpression` and `KeyConditions`). The +//! queried index's full key schema is in the key, its range key included, +//! when the key condition names only the index's partition key (measured +//! 2026-11-09 on a global secondary index; a local secondary index follows +//! the same rule, not measured). +//! - `Scan`: the table's key attributes, `AttributesToGet`, `ScanFilter`, +//! `ExclusiveStartKey`, and the paths in `FilterExpression` and +//! `ProjectionExpression`. A `Scan` that names no attribute, a `Select +//! COUNT` `Scan` included, holds the table's key attributes (measured +//! 2026-11-01). A `Scan` with `IndexName` does not add the index's key +//! schema. The index's key attributes are in the key only when the request +//! names them, as the `ExclusiveStartKey` of a later page does (see below). +//! Measured 2026-11-09 on a `Scan` with no `ExclusiveStartKey`: the service +//! allows a `Scan` of a global secondary index under an Allow whose +//! `ForAllValues` list holds the table's keys and not the index's, and +//! under a Deny on the index's partition key. It allows a `Scan` of a local +//! secondary index under an Allow whose list holds the table's keys and the +//! projected name, and (measured 2026-11-10) under a Deny on the index's +//! sort key, so the index's sort key is not in the key. The other rules for +//! a local secondary index follow from that key content. //! - `SearchVectors`: the paths in `ProjectionExpression`. A `SearchVectors` //! with no `ProjectionExpression` leaves the key absent. -//! - `BatchGetItem`: per table, the attributes of every key, the table's key -//! attributes, `AttributesToGet`, and the paths in `ProjectionExpression`. +//! - `BatchGetItem`: per table, the table's key attributes, `AttributesToGet`, +//! and the paths in `ProjectionExpression`, as for `GetItem`. The names in +//! `Keys` are not read: on a table that does not exist, under a Deny on the +//! key attribute, the service answers `ResourceNotFoundException` (measured +//! 2026-11-09). A `RequestItems` key that is a table ARN gets no key info +//! from `authorize_request`, so that part holds no key attributes, where a +//! `GetItem` whose `TableName` is the same ARN holds them. //! - `BatchWriteItem`: per table, the attributes of every `PutRequest` item and //! every `DeleteRequest` key, and the table's key attributes. Each table's //! part is evaluated once, with the union of its items' names, as on the @@ -57,16 +87,36 @@ //! `BatchGetItem`; the table's key attributes on `GetItem`, `Query`, `Scan`, //! `UpdateItem`, `DeleteItem` and a `DeleteRequest`; a `ProjectionExpression` //! naming a document path; reads with no projection; a transaction `Update`; -//! and the batch and transaction forms of `GetItem` and `PutItem`. The rest -//! follows the rules measured for the same parameter on other operations: +//! and the batch and transaction forms of `GetItem` and `PutItem`. On +//! 2026-11-09, on a table keyed on `pk` and `sk` with a global and a local +//! secondary index: the table's sort key; the key attributes of a queried +//! global secondary index; the key attributes of a scanned global secondary +//! index and the sort key of a scanned local secondary index, on a `Scan` +//! with no `ExclusiveStartKey`; and the key condition of a `Query` and the +//! `Keys` of a `BatchGetItem` on a table that does not exist. On 2026-11-10, +//! on a table of the same shape holding two items: a later page of a `Scan` +//! of each index and of the table, and the first page of a `Scan` of the +//! local secondary index under a Deny on its sort key. The rest follows the +//! rules measured for the same parameter on other operations: //! `ProjectionExpression` on `Query` and `Scan`, `Expected` and -//! `ConditionExpression` on `UpdateItem`, and `Expected` on `DeleteItem`. The -//! key attributes of the queried or scanned index follow the IAM guide's rule -//! that a policy on the key 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 the service: a valid start key holds only key attributes, which the key -//! already holds. +//! `ConditionExpression` on `UpdateItem`, and `Expected` on `DeleteItem`. +//! +//! `ExclusiveStartKey` adds its names as written, as on the service. The +//! start key of a `Scan` with `IndexName` holds the table's and the index's +//! key attributes, the shape the engine requires and returns as +//! `LastEvaluatedKey`, and the key holds them. So a later page of a `Scan` of +//! an index holds the index's key attributes, and the first page holds them +//! only when the request names them. Measured 2026-11-10, with the previous +//! page's `LastEvaluatedKey` as the start key: the service refuses a later +//! page of a `Scan` of a global or a local secondary index under an Allow +//! whose `ForAllValues` list holds the table's keys, alone or with the +//! projected name, and allows a later page of a `Scan` of the table under +//! the Allow of the table's keys alone. Under a Deny on the global secondary +//! index's partition key, or on the local secondary index's sort key, it +//! refuses a later page of a `Scan` of that index and allows the first page. +//! On a `Query`, and on a `Scan` of the table, a valid start key holds only +//! key attributes that the key already holds, so the service cannot show +//! whether it adds names of its own there. //! //! An expression is read with the tokenizer and parser the engine uses, under //! the same size, token and depth limits, so on every operation other than @@ -74,15 +124,21 @@ //! `SearchVectors` matches a `#name` against `ExpressionAttributeNames` keys //! with their leading `#` removed, so it accepts keys this module does not //! resolve, and its `SearchConditionExpression` is not read here (both #729). -//! An expression the engine refuses gives none: the engine then answers -//! `ValidationException` without reading or writing an item. A `#name` with no -//! `ExpressionAttributeNames` entry is skipped for the same reason. +//! An expression the parser refuses gives none, and the engine refuses the +//! same request without reading or writing an item. A key condition also +//! goes through the engine's reserved-word check when +//! `enforce_reserved_keywords` is set, so a key condition with a reserved +//! word written bare gives none. The other expressions skip that check: a +//! reserved word written bare in one of them adds its name, and the engine +//! refuses the request after authorization. A `#name` with no +//! `ExpressionAttributeNames` entry is skipped, as the engine refuses it. use std::collections::BTreeSet; use extenddb_core::expression::{ Expr, ExpressionKind, PathElement, UpdateAction, parse_condition_with_depth_limit, - parse_projection, parse_update_from, tokenize_for, tokenize_with_limit, + parse_key_condition, parse_projection, parse_update_from, tokenize_for, tokenize_with_limit, + validate_no_reserved_words, }; use extenddb_core::limits::LimitsConfig; use extenddb_core::types::TableKeyInfo; @@ -106,12 +162,13 @@ pub(crate) fn single_table_attributes( "Query" => { names.key_schema(key_info); names.index_key_schema(input, key_info); + names.key_condition(input.get("KeyConditionExpression")); + names.map_keys(input.get("KeyConditions")); names.map_keys(input.get("QueryFilter")); names.range_read(input); } "Scan" => { names.key_schema(key_info); - names.index_key_schema(input, key_info); names.map_keys(input.get("ScanFilter")); names.range_read(input); } @@ -132,17 +189,7 @@ pub(crate) fn batch_table_attributes( ) -> Option> { let mut names = Names::new(body, limits); match operation { - "BatchGetItem" => { - for key in body - .get("Keys") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - names.map_keys(Some(key)); - } - names.get_item(body, key_info); - } + "BatchGetItem" => names.get_item(body, key_info), "BatchWriteItem" => { for request in body.as_array().into_iter().flatten() { names.map_keys(request.get("PutRequest").and_then(|p| p.get("Item"))); @@ -247,8 +294,12 @@ impl<'a> Names<'a> { } } - /// The key attributes of the index a `Query` or `Scan` names. An index the - /// table does not have adds none: the engine refuses that request. + /// The key attributes of the index a `Query` names. An index the table + /// does not have adds none: the engine refuses that request. A `Scan` of + /// an index does not call this (measured 2026-11-09 on a `Scan` with no + /// `ExclusiveStartKey`). The start key of a later page adds the index's + /// key attributes in `range_read`, as on the service (measured + /// 2026-11-10). fn index_key_schema(&mut self, request: &Value, key_info: Option<&TableKeyInfo>) { let (Some(key_info), Some(index)) = (key_info, request.get("IndexName").and_then(Value::as_str)) @@ -304,6 +355,43 @@ impl<'a> Names<'a> { } } + /// The paths in a `KeyConditionExpression`, read as the engine's key + /// condition parser reads it (`parse_key_condition_expr` in + /// `crates/engine/src/expression_helpers.rs`): under the size and token + /// limits, with the reserved-word check when `enforce_reserved_keywords` + /// is set, then `parse_key_condition`. A key condition the key condition + /// parser refuses gives none. + fn key_condition(&mut self, value: Option<&Value>) { + let Some(text) = self.text(value) else { + return; + }; + let limits = self.limits; + let parsed = tokenize_for( + text, + limits.max_expression_tokens, + ExpressionKind::KeyCondition, + ) + .and_then(|tokens| { + if limits.enforce_reserved_keywords { + validate_no_reserved_words(&tokens)?; + } + parse_key_condition(&tokens) + }); + let Ok(condition) = parsed else { + return; + }; + self.path(&condition.pk_path); + for (path, _) in &condition.extra_pk_conditions { + self.path(path); + } + if let Some(sort_key) = &condition.sk_condition { + self.path(sort_key.path()); + } + for (path, _) in &condition.extra_sk_conditions { + self.path(path); + } + } + /// The attribute each `UpdateExpression` action names. The paths a value /// reads are left out, as the service leaves them out. fn update(&mut self, value: Option<&Value>) { diff --git a/crates/server/src/request_attributes/tests.rs b/crates/server/src/request_attributes/tests.rs index b44d97f25..7a4ecb2dc 100644 --- a/crates/server/src/request_attributes/tests.rs +++ b/crates/server/src/request_attributes/tests.rs @@ -3,11 +3,17 @@ //! Unit tests for `dynamodb:Attributes` extraction. //! -//! Rows marked "Measured" mirror the service's answers of 2026-10-31 and -//! 2026-11-01; the parent module's docs list what was measured. Index key -//! attributes and a table's sort key follow the IAM guide's rule, not measured -//! (the measured table was keyed on `pk` alone). Other unmeasured parameters -//! follow the rule measured for the same parameter on another operation. +//! Rows marked "Measured" mirror the service's answers of 2026-10-31, +//! 2026-11-01, 2026-11-09 and 2026-11-10; the parent module's docs list what +//! was measured. The 2026-11-09 rows were measured on a table keyed on `pk` +//! and `sk` with a global and a local secondary index: the key attributes of +//! a queried GSI, of a scanned GSI and the sort key of a scanned LSI on a +//! `Scan` with no `ExclusiveStartKey`, a table's sort key, and the key +//! condition of a `Query` and the `Keys` of a `BatchGetItem` on a table that +//! does not exist. The 2026-11-10 rows were measured on a table of the same +//! shape: a later page of a `Scan` of a GSI and of an LSI. Other unmeasured +//! parameters follow the rule measured for the same parameter on another +//! operation. use std::collections::HashMap; @@ -276,6 +282,16 @@ fn a_table_that_does_not_exist_adds_no_key_schema() { #[test] fn query_holds_the_table_and_index_keys_and_its_read_parameters() { + // Measured: a Query of a GSI whose key condition names only the GSI's + // partition key is refused under an Allow whose ForAllValues list holds + // the table's keys, the GSI's partition key and the projected name, and + // allowed once the list holds the GSI's sort key too. Only the index's + // key schema gives `gsk` here. + let gsi = json!({"TableName": "i", "IndexName": "gsi1", "KeyConditionExpression": "gpk = :g"}); + assert_eq!( + single("Query", &gsi, Some(&indexed_table())), + names(&["gpk", "gsk", "pk", "sk"]) + ); let input = json!({ "TableName": "i", "IndexName": "gsi1", @@ -310,6 +326,85 @@ fn query_holds_the_legacy_filter_and_attributes_to_get() { ); } +#[test] +fn a_query_of_a_table_that_does_not_exist_holds_its_key_condition_names() { + // Measured: on a table that does not exist, a Query whose + // KeyConditionExpression is `#n = :p` with `#n` for `name` is refused + // under a Deny on name, and one whose KeyConditions name pk is refused + // under a Deny on pk, with AccessDeniedException rather than + // ResourceNotFoundException. + let input = json!({ + "TableName": "gone", + "KeyConditionExpression": "#n = :p", + "ExpressionAttributeNames": {"#n": "name"}, + "ExpressionAttributeValues": {":p": {"S": "p1"}}, + }); + assert_eq!(single("Query", &input, None), names(&["name"])); + let legacy = json!({ + "TableName": "gone", + "KeyConditions": {"pk": {"ComparisonOperator": "EQ", "AttributeValueList": [{"S": "p1"}]}}, + }); + assert_eq!(single("Query", &legacy, None), names(&["pk"])); + // Every clause of the condition, a BETWEEN and a begins_with included. + let two = json!({ + "TableName": "gone", + "KeyConditionExpression": "pk = :p AND sk BETWEEN :a AND :b", + }); + assert_eq!(single("Query", &two, None), names(&["pk", "sk"])); + let prefix = json!({ + "TableName": "gone", + "KeyConditionExpression": "begins_with(#s, :a) AND #p = :p", + "ExpressionAttributeNames": {"#s": "sort", "#p": "part"}, + }); + assert_eq!(single("Query", &prefix, None), names(&["part", "sort"])); + // A key condition the key condition parser refuses gives none. On this + // table, which does not exist, the engine answers + // ResourceNotFoundException before it reads the key condition. + let refused = json!({"TableName": "gone", "KeyConditionExpression": "pk > :v"}); + assert_eq!(single("Query", &refused, None), None); + let refused = json!({"TableName": "gone", "KeyConditionExpression": "pk = :p OR sk = :s"}); + assert_eq!(single("Query", &refused, None), None); +} + +#[test] +fn a_key_condition_with_a_reserved_word_gives_no_names() { + // The engine's key condition parser refuses `name = :p`, with the + // reserved word `name` written bare, when enforce_reserved_keywords is + // set (the default), so the key condition adds no name. With the check + // off, the engine accepts it and the key holds `name`. + let input = json!({"TableName": "gone", "KeyConditionExpression": "name = :p"}); + assert_eq!(single("Query", &input, None), None); + let placeholder = json!({ + "TableName": "gone", + "KeyConditionExpression": "#n = :p", + "ExpressionAttributeNames": {"#n": "name"}, + }); + assert_eq!(single("Query", &placeholder, None), names(&["name"])); + let lax = LimitsConfig { + enforce_reserved_keywords: false, + ..LimitsConfig::default() + }; + assert_eq!( + single_table_attributes("Query", &input, None, &lax), + names(&["name"]) + ); +} + +#[test] +fn a_key_condition_on_a_table_that_exists_adds_no_name_the_key_lacks() { + let input = json!({ + "TableName": "i", + "IndexName": "gsi1", + "KeyConditionExpression": "gpk = :g AND gsk < :s", + }); + assert_eq!( + single("Query", &input, Some(&indexed_table())), + names(&["gpk", "gsk", "pk", "sk"]) + ); + let input = json!({"TableName": "t", "KeyConditionExpression": "pk = :p"}); + assert_eq!(single("Query", &input, Some(&hash_table())), names(&["pk"])); +} + #[test] fn scan_with_no_named_attribute_holds_the_table_key_attributes() { // Measured: a Deny on pk refuses a Scan with no projection, and a Deny @@ -340,18 +435,32 @@ fn scan_holds_the_sort_key_of_the_table() { } #[test] -fn scan_on_an_index_holds_the_index_key_attributes() { - // Follows the IAM guide's rule for the key attributes of an index; not - // measured. +fn scan_on_an_index_holds_the_table_key_attributes_and_not_the_index_keys() { + // Measured, on a Scan with no ExclusiveStartKey: a Scan of a GSI is + // allowed under an Allow whose ForAllValues list holds the table's keys + // and not the GSI's, and under a Deny on the GSI's partition key, and an + // Allow whose list holds the GSI's keys and not the table's refuses it. A + // Scan of an LSI projecting name is allowed under an Allow over pk, sk and + // name, so the LSI's sort key is not in the key. let gsi = json!({"TableName": "i", "IndexName": "gsi1"}); assert_eq!( single("Scan", &gsi, Some(&indexed_table())), - names(&["gpk", "gsk", "pk", "sk"]) + names(&["pk", "sk"]) + ); + let projected = json!({ + "TableName": "i", + "IndexName": "gsi1", + "ProjectionExpression": "#n", + "ExpressionAttributeNames": {"#n": "name"}, + }); + assert_eq!( + single("Scan", &projected, Some(&indexed_table())), + names(&["name", "pk", "sk"]) ); let lsi = json!({"TableName": "i", "IndexName": "lsi1"}); assert_eq!( single("Scan", &lsi, Some(&indexed_table())), - names(&["lsk", "pk", "sk"]) + names(&["pk", "sk"]) ); let unknown = json!({"TableName": "i", "IndexName": "nope"}); assert_eq!( @@ -360,6 +469,33 @@ fn scan_on_an_index_holds_the_index_key_attributes() { ); } +#[test] +fn a_later_page_of_an_index_scan_holds_the_index_keys_of_its_start_key() { + // Measured, on a later page: a Scan of a GSI or an LSI whose start key + // holds the table's and the index's key attributes is refused under an + // Allow whose ForAllValues list holds the table's keys, and under a Deny + // on the GSI's partition key or the LSI's sort key. The key holds the + // start key's names as written. + let gsi = json!({ + "TableName": "i", + "IndexName": "gsi1", + "ExclusiveStartKey": {"pk": {"S": "a"}, "sk": {"S": "b"}, "gpk": {"S": "g"}, "gsk": {"S": "h"}}, + }); + assert_eq!( + single("Scan", &gsi, Some(&indexed_table())), + names(&["gpk", "gsk", "pk", "sk"]) + ); + let lsi = json!({ + "TableName": "i", + "IndexName": "lsi1", + "ExclusiveStartKey": {"pk": {"S": "a"}, "sk": {"S": "b"}, "lsk": {"S": "l"}}, + }); + assert_eq!( + single("Scan", &lsi, Some(&indexed_table())), + names(&["lsk", "pk", "sk"]) + ); +} + #[test] fn a_scan_of_a_table_that_does_not_exist_names_only_what_it_gives() { assert_eq!(single("Scan", &json!({"TableName": "gone"}), None), None); @@ -466,7 +602,7 @@ fn an_expression_the_engine_refuses_gives_no_names() { // ---- batch and transaction requests --------------------------------------- #[test] -fn batch_get_item_holds_every_key_and_the_projection_per_table() { +fn batch_get_item_holds_the_key_schema_and_the_projection_per_table() { // Measured: BatchGetItem with ProjectionExpression=ssn matches a Deny on // ssn; with no projection it does not. let body = json!({ @@ -490,6 +626,32 @@ fn batch_get_item_holds_every_key_and_the_projection_per_table() { ); } +#[test] +fn batch_get_item_keys_add_no_names_of_their_own() { + // Measured: on a table that does not exist, a BatchGetItem whose only key + // names pk is answered ResourceNotFoundException under a Deny on pk, as a + // GetItem of the same key is. The names in Keys are not in the key; the + // table's key schema is, as for GetItem. + let limits = LimitsConfig::default(); + let body = json!({"Keys": [{"pk": {"S": "a"}}, {"pk": {"S": "b"}}]}); + assert_eq!( + batch_table_attributes("BatchGetItem", &body, None, &limits), + None + ); + let body = json!({"Keys": [{"pk": {"S": "a"}}], "ProjectionExpression": "ssn"}); + assert_eq!( + batch_table_attributes("BatchGetItem", &body, None, &limits), + names(&["ssn"]) + ); + // A key naming an attribute outside the key schema is refused by the + // engine; the key holds the schema, not the name. + let body = json!({"Keys": [{"pk": {"S": "a"}, "extra": {"S": "x"}}]}); + assert_eq!( + batch_table_attributes("BatchGetItem", &body, Some(&hash_table()), &limits), + names(&["pk"]) + ); +} + #[test] fn batch_write_item_holds_every_put_item_and_delete_key() { // Measured: a PutRequest of {pk, ssn} matches a Deny on ssn. diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 75a8980a4..a521acd82 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -856,16 +856,16 @@ This change needs no catalog migration of its own. Before upgrading a deployment ### v0.1.15: `dynamodb:Attributes` holds the attributes every item request names -**What changed.** `dynamodb:Attributes` now holds the top-level names of the attributes a request names on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, `SearchVectors`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` and `TransactWriteItems`, as the security model manual lists them per operation. Before this release the key was read from `ProjectionExpression` alone, on single-table requests only, and a projected document path was compared as the whole path: the key was absent on `PutItem`, `UpdateItem` and `DeleteItem`, on reads that use `AttributesToGet`, and on every batch and transaction request, and a projection of `secret.a` held `secret.a` instead of `secret`. The key now also holds the attributes a condition or filter expression reads, the target of each update expression action, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, `ExclusiveStartKey` (whether it adds names of its own cannot be told on the service), and on every item operation other than `SearchVectors` the key attributes of the table and of the index a `Query` or `Scan` names (the index key attributes follow the IAM guide's rule, not measured). It does not hold the attributes a `SET` value reads. +**What changed.** `dynamodb:Attributes` now holds the top-level names of the attributes a request names on `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, `SearchVectors`, `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` and `TransactWriteItems`, as the security model manual lists them per operation. Before this release the key was read from `ProjectionExpression` alone, on single-table requests only, and a projected document path was compared as the whole path: the key was absent on `PutItem`, `UpdateItem` and `DeleteItem`, on reads that use `AttributesToGet`, and on every batch and transaction request, and a projection of `secret.a` held `secret.a` instead of `secret`. The key now also holds the attributes a condition or filter expression reads, the target of each update expression action, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, `ExclusiveStartKey`, the attributes in a `Query` key condition, and on every item operation other than `SearchVectors` the key attributes of the table and of the index a `Query` names. The first page of a `Scan` of an index holds the table's key attributes and not the index's (measured on DynamoDB 2026-11-09 for the keys of a global secondary index and the sort key of a local secondary index). A later page carries an `ExclusiveStartKey` that holds the table's and the index's key attributes, and the key holds them. DynamoDB does the same: it refused a later page of a `Scan` of a global and of a local secondary index under an `Allow` whose `ForAllValues` list holds only the table's keys, and under a `Deny` on the global secondary index's partition key or the local secondary index's sort key (measured 2026-11-10). The key does not hold the attributes a `SET` value reads. **Who is affected.** Any deployment with a policy statement that names `dynamodb:Attributes` in a condition. On a table that exists, every item request other than `SearchVectors` now carries the key, holding at least the table's key attributes, so a `ForAllValues` condition no longer holds vacuously on an absent key, a `ForAnyValue` condition can match, `Null` with value `true` stops matching and `Null` with value `false` starts matching. A `SearchVectors` still holds only the names in its `ProjectionExpression` and leaves the key absent when it has none. Statements of these shapes now decide differently: - A `Deny` gated on a `ForAnyValue:String*` operator that matches an attribute's name (`StringEquals`, `StringLike`, `StringEqualsIgnoreCase`) now applies to writes that name the attribute, to update actions that target it, to `AttributesToGet` reads, to projections of a path inside it, to conditions and filters that read it, and to batch and transaction requests. It did not apply to them before. -- A `Deny` gated on a negated `ForAnyValue:String*` operator (`StringNotEquals`, `StringNotLike`, `StringNotEqualsIgnoreCase`) now applies to every item request whose names fall outside its list, the key attributes included, so the list must hold the key attributes of the table and of every index the statement covers (the index key attributes follow the IAM guide's rule, not measured). A list that omits them now refuses every `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, batch and transaction request on the table. -- An `Allow` gated on `ForAllValues:StringEquals` used to grant every request that left the key absent, whatever attributes it named, because the absent key satisfied it. It now grants only when every name the request holds is in the list. A list that omits the table's key attributes stops granting `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan` and the batch and transaction requests (measured on DynamoDB 2026-11-01 for `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `BatchWriteItem` `DeleteRequest`); add the key attributes of the table and of every index the statement covers (the index key attributes follow the IAM guide's rule, not measured). +- A `Deny` gated on a negated `ForAnyValue:String*` operator (`StringNotEquals`, `StringNotLike`, `StringNotEqualsIgnoreCase`) now applies to every item request whose names fall outside its list, the key attributes included, so the list must hold the key attributes of the table and, for a `Query` of an index or a later page of a `Scan` of an index, of every index the statement covers (the `Query` rule measured on DynamoDB 2026-11-09 on a global secondary index, and the later-page rule on 2026-11-10 on a global and a local secondary index). A list that omits them now refuses every `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, batch and transaction request on the table. +- An `Allow` gated on `ForAllValues:StringEquals` used to grant every request that left the key absent, whatever attributes it named, because the absent key satisfied it. It now grants only when every name the request holds is in the list. A list that omits the table's key attributes stops granting `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan` and the batch and transaction requests (measured on DynamoDB 2026-11-01 for `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `BatchWriteItem` `DeleteRequest`); add the key attributes of the table and, for a `Query` of an index or a later page of a `Scan` of an index, of every index the statement covers. A `Query` of an index holds the index's full key schema, its sort key included, and the first page of a `Scan` of an index holds the table's key attributes only (measured on DynamoDB 2026-11-09 on a global secondary index). A later page of a `Scan` of an index holds the index's key attributes from its `ExclusiveStartKey`, and DynamoDB refused such a page under an `Allow` whose list holds only the table's keys (measured 2026-11-10 on a global and a local secondary index). - An `Allow` gated on a `ForAnyValue:String*` operator now grants every item request its `Action` covers that holds a name the list matches. Before, it granted only a single-table request whose `ProjectionExpression` named one. A `BatchWriteItem` is evaluated once per table, with the union of that table's item names, so an `Allow` of `BatchWriteItem` over `["name"]` grants a batch that puts `{pk, name}` and `{pk, ssn}` on one table and writes both items, and refuses a batch that puts `{pk, ssn}` alone, as on DynamoDB (measured 2026-11-01). A list that holds a key attribute grants every item request other than `SearchVectors` that the `Action` covers, because each holds the table's key attributes; DynamoDB puts them in the key on `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `BatchWriteItem` `DeleteRequest` (measured 2026-11-01). An `Allow` of `GetItem` over `["pk"]`, on a table keyed on `pk`, grants every `GetItem` on the table, one with no projection included, which returns the whole item. - An `Allow` of `PutItem` gated on `ForAnyValue:StringNotEquals` over `["ssn"]` granted no `PutItem` before, and now grants one of `{pk, ssn}`, because `pk` is outside the list. An `Allow` of `PutItem` gated on `ForAllValues:StringNotEquals` over `["ssn"]` granted every `PutItem` before, because the absent key satisfied it, and now refuses one of `{pk, ssn}` and grants one of `{pk, name}`. -- `Null` with value `true` matched every request that left the key absent before. A statement gated on it no longer matches any item request except a `SearchVectors` with no `ProjectionExpression` and, on a table that does not exist, 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` gated on it stops refusing the other item requests and an `Allow` gated on it stops granting them, as on DynamoDB (measured 2026-11-01 on `GetItem` and `Scan`). +- `Null` with value `true` matched every request that left the key absent before. A statement gated on it no longer matches any item request except a `SearchVectors` with no `ProjectionExpression` and, on a table that does not exist, a `GetItem`, transaction `Get` or `BatchGetItem` that names no attribute outside its keys, a `Query` whose key condition the key condition parser refuses and that names none elsewhere, and a `Scan` that names none. A `Deny` gated on it stops refusing the other item requests and an `Allow` gated on it stops granting them, as on DynamoDB (measured 2026-11-01 on `GetItem` and `Scan`). - `Null` with value `false` matched only a single-table request with a `ProjectionExpression` before, and now matches every item request that carries the key. An `Allow` gated on it, written to require that a read names its attributes, now grants each of these requests that its `Action` covers, a read with no projection and its whole item included, and a `Deny` gated on it now refuses them. 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. diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index d71c02a3b..485fe925f 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -197,18 +197,18 @@ extenddb supports the same access control patterns as real AWS IAM: | `PutItem` | The table's key attributes, the item's attributes, `Expected`, and the attributes in `ConditionExpression` | | `UpdateItem` | The `Key` attributes, the table's key attributes, `AttributeUpdates`, `Expected`, the attributes in `ConditionExpression`, and the target of every `SET`, `REMOVE`, `ADD` and `DELETE` action in `UpdateExpression`; not the attributes a `SET` value reads | | `DeleteItem` | The `Key` attributes, the table's key attributes, `Expected`, and the attributes in `ConditionExpression` | -| `Query` | The key attributes of the table and of the queried index, `AttributesToGet`, `QueryFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | -| `Scan` | The key attributes of the table and of the scanned index, `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | +| `Query` | The key attributes of the table and of the queried index, the attributes in `KeyConditionExpression` and `KeyConditions`, `AttributesToGet`, `QueryFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | +| `Scan` | The table's key attributes, `AttributesToGet`, `ScanFilter`, `ExclusiveStartKey`, and the attributes in `FilterExpression` and `ProjectionExpression` | | `SearchVectors` | The attributes in `ProjectionExpression`; the key is absent when there is none | -| `BatchGetItem` | Per table: the attributes of every key, the table's key attributes, `AttributesToGet`, and the attributes in `ProjectionExpression` | +| `BatchGetItem` | Per table: the table's key attributes, `AttributesToGet`, and the attributes in `ProjectionExpression`, as for `GetItem`; the names in `Keys` add nothing | | `BatchWriteItem` | Per table: the attributes of every `PutRequest` item and `DeleteRequest` key, and the table's key attributes, evaluated once with the union of the table's items | | `TransactGetItems`, `TransactWriteItems` | Per item, what the single-item operation holds: `Get` as `GetItem`, `Put` as `PutItem`, `Update` as `UpdateItem`, `Delete` as `DeleteItem`; a `ConditionCheck` holds the table's key attributes and the attributes in its `ConditionExpression` | -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. +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 table's key attributes, because every item request other than `SearchVectors` holds them. For a `Query` of an index, and for a later page of a `Scan` of an index, it must also list the index's key attributes. A `Query` of an index holds the index's full key schema, its sort key included, when the key condition names only the index's partition key (measured on DynamoDB 2026-11-09 on a global secondary index). The first page of a `Scan` of an index, which carries no `ExclusiveStartKey`, holds the table's key attributes and not the index's. On DynamoDB an `Allow` of `Scan` whose list holds the table's keys and not the index's grants that `Scan` of a global secondary index, and a `Deny` on the global secondary index's partition key does not refuse it. An `Allow` whose list holds the table's keys and the projected name grants that `Scan` of a local secondary index (measured 2026-11-09), and a `Deny` on the local secondary index's sort key does not refuse it (measured 2026-11-10). The other rules for a local secondary index follow from that key content. A later page carries the previous page's `LastEvaluatedKey` as its `ExclusiveStartKey`, which holds the table's and the index's key attributes, and the key holds them. So an allowlist that omits the index's key attributes refuses a later page, and a `Deny` on one of them refuses it. DynamoDB answers the same (measured 2026-11-10). Under an `Allow` whose list holds the table's keys, alone or with the projected name, it refused a later page of a `Scan` of the global and of the local secondary index, and allowed a later page of a `Scan` of the table. A `Deny` on the global secondary index's partition key, or on the local secondary index's sort key, refused a later page of a `Scan` of that index. On a table that does not exist, a `Query` holds the names in its key condition, a non-key name included, so a `Deny` on one of them answers `AccessDeniedException` rather than `ResourceNotFoundException`; a `GetItem` or `BatchGetItem` holds nothing from its key, so under a `Deny` on the key attribute both answer `ResourceNotFoundException` (measured on DynamoDB 2026-11-09). The key is absent on a `SearchVectors` with no `ProjectionExpression` and, on a table that does not exist, on a `GetItem`, transaction `Get` or `BatchGetItem` that names no attribute outside its keys, a `Query` whose key condition the key condition parser refuses and that names none elsewhere, and a `Scan` that names none. The table above covers the item, batch and transaction operations. A PartiQL statement (`ExecuteStatement`, `BatchExecuteStatement` and `ExecuteTransaction`) is authorized by the engine, one statement at a time, with the `dynamodb:PartiQL*` action of its type. The engine fills `dynamodb:Attributes` with the table's key attributes and the top-level names the statement uses (measured 2026-11-03). For a read of an index, it holds the index's key attributes only when the statement names them (not measured). 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). -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. +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. These were measured on 2026-11-09, on a table keyed on a partition and a sort key with a global and a local secondary index: the table's sort key; the key attributes of a queried global secondary index (in the key, its sort key included); on the first page of a `Scan`, the key attributes of a scanned global secondary index and the sort key of a scanned local secondary index (not in the key); and the key condition of a `Query` and the `Keys` of a `BatchGetItem` on a table that does not exist. These were measured on 2026-11-10, on a table of the same shape holding two items: a later page of a `Scan` of each index and of the table (the start key's names are in the key), and the first page of a `Scan` of the local secondary index under a `Deny` on its sort key (not in the key). `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. `ExclusiveStartKey` adds its names as written. The start key of a `Scan` of an index also holds the index's key attributes, and the key holds them, as on DynamoDB (measured 2026-11-10). On a `Query`, and on a `Scan` of the table, a valid start key holds only key attributes that the key already holds, so DynamoDB cannot show whether it adds names of its own there. ### Resource ARNs diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 429047f8b..da7884254 100755 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -331,11 +331,11 @@ These errors occur when `auth.provider = "builtin"` is enabled. To allowlist attributes (allow only when *every* requested attribute is in the set), use `ForAllValues:` on an `Allow` statement. The same rule applies to `dynamodb:LeadingKeys`. -`dynamodb:Attributes` holds the top-level names of the attributes the request names: the attributes of a `PutItem` item and of the items and keys in a `BatchWriteItem` request, the target of each `UpdateExpression` action, `AttributesToGet`, the first name of each projected document path (`secret` for `secret.a`), the attributes a `ConditionExpression` or `FilterExpression` reads, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, and on every item operation other than `SearchVectors` the key attributes of the table (and of the index a `Query` or `Scan` names). The attributes a `SET` value reads are not in it. Batch requests are evaluated per table and transactions per item. The [security model manual](manuals/10-security-model.md) lists what the key holds for each operation. It follows that: +`dynamodb:Attributes` holds the top-level names of the attributes the request names: the attributes of a `PutItem` item and of the items and keys in a `BatchWriteItem` request, the target of each `UpdateExpression` action, `AttributesToGet`, the first name of each projected document path (`secret` for `secret.a`), the attributes a `ConditionExpression` or `FilterExpression` reads, the legacy `Expected`, `AttributeUpdates`, `QueryFilter` and `ScanFilter` names, the attributes in a `Query` key condition, and on every item operation other than `SearchVectors` the key attributes of the table (and of the index a `Query` names). The first page of a `Scan` of an index holds the table's key attributes and not the index's (measured on DynamoDB 2026-11-09), and a later page also holds the index's key attributes, which its `ExclusiveStartKey` carries (measured 2026-11-10). The attributes a `SET` value reads are not in it. Batch requests are evaluated per table and transactions per item. The [security model manual](manuals/10-security-model.md) lists what the key holds for each operation, and how a PartiQL statement is authorized. It follows that: - A `Deny` on `ForAnyValue:StringEquals` over `ssn` does not block a `DeleteItem` of an item that holds `ssn`, or a read with no projection, which returns `ssn` with the rest of the item: neither request names `ssn`. DynamoDB answers the same way (measured 2026-10-31). - The same `Deny` does not block an `UpdateItem` that reads `ssn` only in a `SET` value. `SET #n = ssn` copies the value of `ssn` into `name`, and a projection of `name` then returns it. DynamoDB answers the same way (measured 2026-11-01). -- An `Allow` on `ForAllValues:StringEquals` that does not list the table's key attributes grants no `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, batch or transaction request, because each of them holds the key attributes (measured on DynamoDB 2026-11-01 for `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `BatchWriteItem` `DeleteRequest`). List the key attributes of the table and of every index the statement covers; the index key attributes follow the IAM guide's rule and were not measured. +- An `Allow` on `ForAllValues:StringEquals` that does not list the table's key attributes grants no `GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, batch or transaction request, because each of them holds the key attributes (measured on DynamoDB 2026-11-01 for `GetItem`, `Query`, `Scan`, `UpdateItem`, `DeleteItem` and a `BatchWriteItem` `DeleteRequest`). List the key attributes of the table and, for a `Query` of an index or a later page of a `Scan` of an index, of every index the statement covers. A `Query` of an index holds the index's full key schema (measured on DynamoDB 2026-11-09 on a global secondary index). The first page of a `Scan` of an index holds the table's key attributes and not the index's (measured the same day). A later page holds the index's key attributes from its `ExclusiveStartKey`. On DynamoDB an `Allow` that lists only the table's keys refused a later page of a `Scan` of a global and of a local secondary index (measured 2026-11-10). > Note: prior to v0.1.6, extenddb incorrectly treated a bare operator on these keys as an implicit AND across the requested values, so a single-attribute request appeared to be denied while a multi-attribute request leaked. That behavior diverged from AWS and gave a false sense of security. See the upgrade manual for migration guidance. diff --git a/tests/test_dynamodb_attributes_key_attributes.py b/tests/test_dynamodb_attributes_key_attributes.py index 292479340..3904d5760 100644 --- a/tests/test_dynamodb_attributes_key_attributes.py +++ b/tests/test_dynamodb_attributes_key_attributes.py @@ -4,14 +4,17 @@ """dynamodb:Attributes and the key attributes of the table and of a queried index: Allows over ForAllValues lists on Query, UpdateItem, DeleteItem, BatchWriteItem and Scan, a Deny over the key attribute on Scan, Null in a -Deny and an Allow on Scan, and the evaluation of a BatchWriteItem once per -table. +Deny and an Allow on Scan, the evaluation of a BatchWriteItem once per table, +the key attributes of a scanned index, a later page of a Scan of an index and +of the table, a Query key condition and a BatchGetItem key on a table that +does not exist. 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; its docstring describes the -policies. +DynamoDB on 2026-11-01, 2026-11-09 or 2026-11-10 under a session policy of the +same shape as the user policy the test attaches, twice, with the same answer +both times. The 2026-11-09 shapes were measured in two accounts, and the +2026-11-10 shapes in one. The fixtures are those of +test_dynamodb_attributes_condition.py; its docstring describes the policies. Prerequisites and run command: as for test_dynamodb_attributes_condition.py. @@ -27,7 +30,7 @@ import pytest from botocore.exceptions import ClientError -from conftest import wait_for_active +from conftest import wait_for_active, wait_for_gsi_items # The fixtures are imported so this module gets its own account and table. from test_dynamodb_attributes_condition import ( # noqa: F401 @@ -289,10 +292,10 @@ def _query_by_gk(client: Any, table: str) -> Any: def test_for_all_values_allow_without_the_index_key_refuses_a_query_on_the_index( indexed, client_for ): - """Follows the IAM guide's rule, not measured: a policy on - dynamodb:Attributes lists the key attributes of the table and of every - index it covers. An Allow of Query with ForAllValues:StringEquals over - ["pk"] refuses a Query on the index keyed on gk.""" + """A Query of an index holds the index's key attributes (measured + 2026-11-09 on an index with a sort key, below). An Allow of Query with + ForAllValues:StringEquals over ["pk"] refuses a Query on the index keyed + on gk.""" table, arn = indexed client = client_for(_allow_query_on_indexes(arn, ["pk"])) _assert_not_allowed(lambda: _query_by_gk(client, table), "Query") @@ -301,13 +304,385 @@ def test_for_all_values_allow_without_the_index_key_refuses_a_query_on_the_index def test_for_all_values_allow_with_the_index_key_admits_a_query_on_the_index( indexed, client_for ): - """Follows the IAM guide's rule, not measured: the same Allow over - ["pk", "gk"] admits the Query on the index.""" + """The same Allow over ["pk", "gk"] admits the Query on the index.""" table, arn = indexed client = client_for(_allow_query_on_indexes(arn, ["pk", "gk"])) assert "Items" in _query_by_gk(client, table) +# -- a scanned index's key attributes, and a queried index's sort key -------- + + +COMPOSITE_ITEM = { + "pk": {"S": "p1"}, + "sk": {"S": "s1"}, + "ls": {"S": "l1"}, + "gk": {"S": "g1"}, + "gs": {"S": "t1"}, + "name": {"S": "n"}, +} + + +def _create_composite_table(env: Env, table: str) -> None: + """Create a table keyed on pk and sk, with a local secondary index lsi + keyed on pk and ls and a global secondary index gsi2 keyed on gk and gs, + and wait until it is ACTIVE.""" + env.admin.create_table( + TableName=table, + AttributeDefinitions=[ + {"AttributeName": name, "AttributeType": "S"} + for name in ["pk", "sk", "ls", "gk", "gs"] + ], + KeySchema=[ + {"AttributeName": "pk", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + LocalSecondaryIndexes=[{ + "IndexName": "lsi", + "KeySchema": [ + {"AttributeName": "pk", "KeyType": "HASH"}, + {"AttributeName": "ls", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + GlobalSecondaryIndexes=[{ + "IndexName": "gsi2", + "KeySchema": [ + {"AttributeName": "gk", "KeyType": "HASH"}, + {"AttributeName": "gs", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(env.admin, table) + + +@pytest.fixture(scope="module") +def composite(env: Env, account_id: str, region: str): + """A table keyed on pk and sk, with a local secondary index lsi keyed on + pk and ls and a global secondary index gsi2 keyed on gk and gs, holding + COMPOSITE_ITEM; and the table's ARN.""" + table = f"attrs-cx-{uuid.uuid4().hex[:8]}" + _create_composite_table(env, table) + env.admin.put_item(TableName=table, Item=COMPOSITE_ITEM) + # A write reaches gsi2 after index_propagation_delay_ms, so wait until an + # admin Scan of gsi2 returns the item; each test's single read then checks + # authorization alone. + seen = wait_for_gsi_items( + lambda: env.admin.scan(TableName=table, IndexName="gsi2")["Items"], 1 + ) + assert seen == [COMPOSITE_ITEM], seen + yield table, _arn(region, account_id, table) + try: + env.admin.delete_table(TableName=table) + except ClientError: + pass + + +def _allow_on_indexes(arn: str, action: str, attributes: list[str]) -> dict: + """An Allow of `action` alone, on the table and its indexes, over a + ForAllValues list.""" + return { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": f"dynamodb:{action}", + "Resource": [arn, f"{arn}/index/*"], + "Condition": _for_all(attributes), + }], + } + + +def _deny_on_indexes(arn: str, attribute: str) -> dict: + """deny(X) on the table and its indexes.""" + resources = [arn, f"{arn}/index/*"] + return { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": resources}, + { + "Effect": "Deny", + "Action": "dynamodb:*", + "Resource": resources, + "Condition": { + "ForAnyValue:StringEquals": {"dynamodb:Attributes": [attribute]} + }, + }, + ], + } + + +def _scan_index_projecting_name(client: Any, table: str, index: str) -> Any: + return client.scan( + TableName=table, IndexName=index, ProjectionExpression="#n", + ExpressionAttributeNames={"#n": "name"}, + ) + + +def _assert_the_name_only(resp: dict) -> None: + assert resp["Count"] == 1 + assert resp["Items"] == [{"name": {"S": "n"}}] + + +def _assert_the_whole_item(resp: dict) -> None: + assert resp["Count"] == 1 + assert resp["Items"] == [COMPOSITE_ITEM] + + +@pytest.mark.parametrize("index", ["gsi2", "lsi"]) +def test_for_all_values_allow_of_the_table_keys_admits_a_scan_of_an_index( + composite, client_for, index: str +): + """The first page of a Scan of a global or a local secondary index holds + the table's key attributes, and not gk, gs or ls: an Allow of Scan over + ["pk", "sk", "name"] admits a Scan of either index projecting name.""" + table, arn = composite + client = client_for(_allow_on_indexes(arn, "Scan", ["pk", "sk", "name"])) + _assert_the_name_only(_scan_index_projecting_name(client, table, index)) + + +def test_for_all_values_allow_of_the_table_keys_alone_admits_a_scan_of_an_index( + composite, client_for +): + """An Allow of Scan over ["pk", "sk"] admits a Scan of the global + secondary index with no projection, which returns the whole item.""" + table, arn = composite + client = client_for(_allow_on_indexes(arn, "Scan", ["pk", "sk"])) + _assert_the_whole_item(client.scan(TableName=table, IndexName="gsi2")) + + +def test_a_deny_on_an_index_key_attribute_does_not_refuse_a_scan_of_the_index( + composite, client_for +): + """A Deny over gk, the global secondary index's partition key, does not + refuse a Scan of that index, which returns the whole item.""" + table, arn = composite + client = client_for(_deny_on_indexes(arn, "gk")) + _assert_the_whole_item(client.scan(TableName=table, IndexName="gsi2")) + + +def test_for_all_values_allow_of_the_index_keys_alone_refuses_a_scan_of_the_index( + composite, client_for +): + """An Allow of Scan over the index's keys and name, without the table's + keys, refuses a Scan of the index projecting name: the key holds pk and + sk.""" + table, arn = composite + client = client_for(_allow_on_indexes(arn, "Scan", ["gk", "gs", "name"])) + _assert_not_allowed(lambda: _scan_index_projecting_name(client, table, "gsi2"), "Scan") + + +def _query_gsi2_projecting_name(client: Any, table: str) -> Any: + return client.query( + TableName=table, IndexName="gsi2", KeyConditionExpression="gk = :g", + ProjectionExpression="#n", ExpressionAttributeNames={"#n": "name"}, + ExpressionAttributeValues={":g": {"S": "g1"}}, + ) + + +def test_for_all_values_allow_without_the_index_sort_key_refuses_a_query_on_the_index( + composite, client_for +): + """A Query of an index holds the index's full key schema: an Allow of + Query over ["pk", "sk", "gk", "name"] refuses a Query of the global + secondary index whose key condition names only gk, because the key holds + gs too.""" + table, arn = composite + client = client_for(_allow_on_indexes(arn, "Query", ["pk", "sk", "gk", "name"])) + _assert_not_allowed(lambda: _query_gsi2_projecting_name(client, table), "Query") + + +def test_for_all_values_allow_with_the_index_sort_key_admits_a_query_on_the_index( + composite, client_for +): + """The same Allow over ["pk", "sk", "gk", "gs", "name"] admits the + Query, which returns name.""" + table, arn = composite + client = client_for(_allow_on_indexes(arn, "Query", ["pk", "sk", "gk", "gs", "name"])) + _assert_the_name_only(_query_gsi2_projecting_name(client, table)) + + +# -- a later page of a Scan -------------------------------------------------- + + +PAGED_ITEMS = [ + { + "pk": {"S": f"p{i}"}, + "sk": {"S": f"s{i}"}, + "ls": {"S": f"l{i}"}, + "gk": {"S": f"g{i}"}, + "gs": {"S": f"t{i}"}, + "name": {"S": f"n{i}"}, + } + for i in (1, 2) +] + +# The names in the LastEvaluatedKey of page 1, which page 2 sends as its +# ExclusiveStartKey: the table's key attributes, and on a Scan of an index the +# index's key attributes too. +START_KEY_NAMES = { + "gsi2": ["gk", "gs", "pk", "sk"], + "lsi": ["ls", "pk", "sk"], + None: ["pk", "sk"], +} + + +@pytest.fixture(scope="module") +def paged(env: Env, account_id: str, region: str): + """A table of the composite fixture's shape holding the two PAGED_ITEMS, + and the table's ARN.""" + table = f"attrs-pg-{uuid.uuid4().hex[:8]}" + _create_composite_table(env, table) + for item in PAGED_ITEMS: + env.admin.put_item(TableName=table, Item=item) + seen = wait_for_gsi_items( + lambda: env.admin.scan(TableName=table, IndexName="gsi2")["Items"], 2 + ) + assert len(seen) == 2, seen + yield table, _arn(region, account_id, table) + try: + env.admin.delete_table(TableName=table) + except ClientError: + pass + + +def _first_page(env: Env, table: str, index: str | None) -> dict: + """Page 1 of a Scan with Limit 1, read by the table's owner. Its + LastEvaluatedKey is the ExclusiveStartKey of page 2.""" + kwargs = {"IndexName": index} if index else {} + resp = env.admin.scan(TableName=table, Limit=1, **kwargs) + assert resp["Count"] == 1, resp + assert sorted(resp["LastEvaluatedKey"]) == START_KEY_NAMES[index], resp + return resp + + +@pytest.mark.parametrize("index", ["gsi2", "lsi"]) +def test_for_all_values_allow_of_the_table_keys_refuses_a_later_page_of_an_index_scan( + env: Env, paged, client_for, index: str +): + """A later page of a Scan of a global or a local secondary index holds the + index's key attributes, which its ExclusiveStartKey carries: an Allow of + Scan over ["pk", "sk", "name"], which admits page 1 projecting name, + refuses page 2 projecting name.""" + table, arn = paged + start = _first_page(env, table, index)["LastEvaluatedKey"] + client = client_for(_allow_on_indexes(arn, "Scan", ["pk", "sk", "name"])) + _assert_not_allowed( + lambda: client.scan( + TableName=table, IndexName=index, Limit=1, ExclusiveStartKey=start, + ProjectionExpression="#n", ExpressionAttributeNames={"#n": "name"}, + ), + "Scan", + ) + + +def test_a_deny_on_an_index_key_attribute_refuses_a_later_page_of_the_index_scan( + env: Env, paged, client_for +): + """A Deny over gk, which does not refuse page 1 of a Scan of gsi2, + refuses page 2, whose ExclusiveStartKey holds gk.""" + table, arn = paged + start = _first_page(env, table, "gsi2")["LastEvaluatedKey"] + client = client_for(_deny_on_indexes(arn, "gk")) + _assert_denied( + lambda: client.scan( + TableName=table, IndexName="gsi2", Limit=1, ExclusiveStartKey=start, + ), + "Scan", + ) + + +def test_for_all_values_allow_of_the_table_keys_alone_admits_a_later_page_of_the_table( + env: Env, paged, client_for +): + """A later page of a Scan of the table holds only the table's key + attributes: an Allow of Scan over ["pk", "sk"] admits page 2, which + returns the item that page 1 did not.""" + table, arn = paged + first = _first_page(env, table, None) + client = client_for(_allow_on_indexes(arn, "Scan", ["pk", "sk"])) + resp = client.scan(TableName=table, Limit=1, ExclusiveStartKey=first["LastEvaluatedKey"]) + assert resp["Count"] == 1, resp + assert resp["Items"][0] in PAGED_ITEMS, resp + assert resp["Items"] != first["Items"], resp + + +# -- a table that does not exist --------------------------------------------- + + +@pytest.fixture(scope="module") +def missing(account_id: str, region: str) -> tuple[str, str]: + """The name and ARN of a table that is never created.""" + table = f"attrs-missing-{uuid.uuid4().hex[:8]}" + return table, _arn(region, account_id, table) + + +def _assert_not_found(call: Callable[[], Any]) -> None: + with pytest.raises(ClientError) as exc: + call() + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException", exc.value.response + + +def test_a_query_of_a_missing_table_holds_the_names_in_its_key_condition( + missing, client_for +): + """On a table that does not exist, a Query whose KeyConditionExpression + names name (a non-key name) is refused under a Deny over name with + AccessDeniedException, not ResourceNotFoundException: the key holds the + names the key condition gives.""" + table, arn = missing + client = client_for(_deny(arn, "name")) + _assert_denied( + lambda: client.query( + TableName=table, KeyConditionExpression="#n = :p", + ExpressionAttributeNames={"#n": "name"}, + ExpressionAttributeValues={":p": {"S": "p1"}}, + ), + "Query", + ) + + +def test_a_query_of_a_missing_table_holds_the_names_in_its_legacy_key_conditions( + missing, client_for +): + """The same with legacy KeyConditions naming pk, under a Deny over pk.""" + table, arn = missing + client = client_for(_deny(arn, "pk")) + _assert_denied( + lambda: client.query( + TableName=table, + KeyConditions={"pk": {"ComparisonOperator": "EQ", + "AttributeValueList": [{"S": "p1"}]}}, + ), + "Query", + ) + + +def test_a_batch_get_of_a_missing_table_does_not_hold_the_names_in_its_keys( + missing, client_for +): + """On a table that does not exist, a BatchGetItem whose only key names pk + is answered ResourceNotFoundException under a Deny over pk: the names in + Keys are not in the key, and the table has no key schema to add.""" + table, arn = missing + client = client_for(_deny(arn, "pk")) + _assert_not_found( + lambda: client.batch_get_item(RequestItems={table: {"Keys": [{"pk": {"S": "p1"}}]}}) + ) + + +def test_a_get_of_a_missing_table_does_not_hold_the_names_in_its_key( + missing, client_for +): + """A GetItem of the same key under the same Deny is answered + ResourceNotFoundException too.""" + table, arn = missing + client = client_for(_deny(arn, "pk")) + _assert_not_found(lambda: client.get_item(TableName=table, Key={"pk": {"S": "p1"}})) + + # -- a BatchWriteItem is evaluated once per table ----------------------------