diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 0f29d39df..aefbe1f91 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -154,12 +154,21 @@ pub fn evaluate_condition_with( // `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 + // requests the service does. 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`, + // 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 // lists it for, so its absent-key answer is IAM's. None => { diff --git a/crates/auth/src/policy/condition/tests/multivalued.rs b/crates/auth/src/policy/condition/tests/multivalued.rs index 7369deb36..763192567 100644 --- a/crates/auth/src/policy/condition/tests/multivalued.rs +++ b/crates/auth/src/policy/condition/tests/multivalued.rs @@ -492,12 +492,19 @@ 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, 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 3d3fca7ff..8f13aa580 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -477,13 +477,19 @@ 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 `SearchVectors` with no `ProjectionExpression` + // (#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/authz_resource/cross_account.rs b/crates/server/src/authz_resource/cross_account.rs index 3136d26bd..49a12de35 100644 --- a/crates/server/src/authz_resource/cross_account.rs +++ b/crates/server/src/authz_resource/cross_account.rs @@ -143,7 +143,15 @@ pub(crate) fn find(operation: &str, input: &Value, account_id: &str) -> Option Option { - let targets = crate::leading_keys::batch_transact_targets(operation, input, &HashMap::new())?; + // Only each target's table and action are read here. The limits govern + // how the expressions are read for Attributes, which this check ignores, + // so the defaults serve. + let targets = crate::leading_keys::batch_transact_targets( + operation, + input, + &HashMap::new(), + &extenddb_core::limits::LimitsConfig::default(), + )?; let mut accounts = BTreeSet::new(); let mut first = None; for target in targets { diff --git a/crates/server/src/leading_keys.rs b/crates/server/src/leading_keys.rs index 3a6c733b7..f7f83b84b 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..ca0eacec2 --- /dev/null +++ b/crates/server/src/leading_keys/batch.rs @@ -0,0 +1,233 @@ +// 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::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 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). + 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 `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 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; +/// 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, 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 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_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| { + 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, + attributes: batch_table_attributes(operation, body, key_info_of(table), limits), + }); + } + 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, + attributes: transact_item_attributes( + "Get", + get, + key_info_of(table), + limits, + ), + }, + ); + } + } + 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, + attributes: transact_item_attributes( + sub, + op, + key_info_of(table), + limits, + ), + }, + ); + } + } + } + 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) } +} 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 7e53af249..8031e8d88 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -26,6 +26,7 @@ mod metrics_endpoint; pub mod rate_limit; mod registration; mod repeated_headers; +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..8e07bc3bb --- /dev/null +++ b/crates/server/src/request_attributes.rs @@ -0,0 +1,495 @@ +// 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). `ReturnValues` and +//! `ReturnValuesOnConditionCheckFailure` add no names (measured 2026-11-01), +//! and `Select` adds none (not measured). +//! +//! 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`, 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, 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 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 +//! 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`, `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`. 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`. +//! +//! `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 +//! `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 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_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; +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.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.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" => 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, + placeholders: Option<&'a Map>, + limits: &'a LimitsConfig, +} + +impl<'a> Names<'a> { + fn new(request: &'a Value, limits: &'a LimitsConfig) -> Self { + Self { + found: BTreeSet::new(), + placeholders: 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. 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)) + 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 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>) { + 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, .. } + | UpdateAction::Remove { path } + | UpdateAction::Add { path, .. } + | UpdateAction::Delete { 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, .. } + | Expr::And(left, right) + | Expr::Or(left, right) => { + self.expr(left); + self.expr(right); + } + Expr::Not(inner) => self.expr(inner), + Expr::Function { args, .. } => { + for arg in args { + self.expr(arg); + } + } + Expr::Between { operand, low, high } => { + self.expr(operand); + self.expr(low); + self.expr(high); + } + Expr::In { operand, list } => { + self.expr(operand); + for item in list { + 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 + .placeholders + .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..7a4ecb2dc --- /dev/null +++ b/crates/server/src/request_attributes/tests.rs @@ -0,0 +1,762 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Unit tests for `dynamodb:Attributes` extraction. +//! +//! 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; + +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_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"}}, + "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", "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"]) + ); +} + +#[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())), names(&["pk"])); +} + +// ---- 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() { + // 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", + "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 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 + // 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())), 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_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(&["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(&["pk", "sk"]) + ); + let unknown = json!({"TableName": "i", "IndexName": "nope"}); + assert_eq!( + single("Scan", &unknown, Some(&indexed_table())), + names(&["pk", "sk"]) + ); +} + +#[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); + 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(&["pk", "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_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!({ + "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_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. + 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 2faaabfdf..f83f7735b 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -317,10 +317,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 @@ -347,9 +349,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 @@ -379,6 +385,7 @@ pub(crate) async fn authorize_request( ); 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( @@ -413,7 +420,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()) @@ -456,36 +468,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 { @@ -556,46 +542,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/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 750a872ba..d13cebae0 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -868,7 +868,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. @@ -920,6 +920,22 @@ This change needs no catalog migration of its own. Before upgrading a deployment **Who is affected.** A policy whose pattern uses `?` against a request value with a character outside ASCII. A character up to U+FFFF, such as `é` or `æ—¥`, is one unit and now takes one `?`; before this release it took one `?` per byte (two for `é`, three for `æ—¥`). A character above U+FFFF, such as an emoji, is two units and takes `??`, where it took four. A pattern written with one `?` per byte stops matching such a value and a pattern with one `?` per unit starts matching it, so a `Deny` or an `Allow` that relies on such a pattern can change its decision. Review policies that use `?` on condition keys that carry caller data, such as `dynamodb:LeadingKeys` and `dynamodb:Attributes`. No catalog migration is required. +### 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`, 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, 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`, 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. + --- ### v0.1.15: the console metrics dashboard is admin only, and admin-only console pages redirect IAM users diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index c9b6ab412..808514dd4 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -170,7 +170,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 | @@ -193,6 +193,27 @@ 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 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, 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 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 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. 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 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 a9c629fe1..4a9340b8a 100755 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -331,6 +331,12 @@ 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, 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, 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. ## Connection Issues diff --git a/tests/test_dynamodb_attributes_condition.py b/tests/test_dynamodb_attributes_condition.py new file mode 100644 index 000000000..34f57dcb6 --- /dev/null +++ b/tests/test_dynamodb_attributes_condition.py @@ -0,0 +1,468 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""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 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-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 + - 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_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": f"dynamodb:{action}", + "Resource": arn, + "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 + table: str + 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") +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_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) + 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] diff --git a/tests/test_dynamodb_attributes_expressions.py b/tests/test_dynamodb_attributes_expressions.py new file mode 100644 index 000000000..47c2a0927 --- /dev/null +++ b/tests/test_dynamodb_attributes_expressions.py @@ -0,0 +1,389 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""dynamodb:Attributes for conditions, filters, legacy parameters, update +actions, transaction items, key attributes, returned items, and bare and Null +conditions. + +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. + +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 + + +# -- 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..3904d5760 --- /dev/null +++ b/tests/test_dynamodb_attributes_key_attributes.py @@ -0,0 +1,738 @@ +# 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, 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, 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. + +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, 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 + 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 +): + """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") + + +def test_for_all_values_allow_with_the_index_key_admits_a_query_on_the_index( + indexed, client_for +): + """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 ---------------------------- + + +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" 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.