Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
992f550
refactor(server): move batch and transaction authorization targets in…
LeeroyHannigan Oct 31, 2026
b302f90
fix(server): populate dynamodb:Attributes for every item request
LeeroyHannigan Oct 31, 2026
7827a5c
test: cover dynamodb:Attributes on writes, legacy reads, batch and tr…
LeeroyHannigan Oct 31, 2026
d95c2cd
docs: describe what dynamodb:Attributes holds for each operation
LeeroyHannigan Nov 1, 2026
cd5c766
refactor(server): merge identical match arms in the Attributes extractor
LeeroyHannigan Nov 1, 2026
da12470
fix(server): leave the paths a SET value reads out of dynamodb:Attrib…
LeeroyHannigan Nov 1, 2026
ee85410
test: cover dynamodb:Attributes for conditions, filters, update actio…
LeeroyHannigan Nov 1, 2026
c5b49ee
docs: describe the update action rule for dynamodb:Attributes
LeeroyHannigan Nov 1, 2026
5ab9c26
fix(server): hold the table's key attributes in dynamodb:Attributes o…
LeeroyHannigan Nov 1, 2026
0f14e8a
test: cover dynamodb:Attributes rows DynamoDB answered on 2026-11-01
LeeroyHannigan Nov 1, 2026
2a821f9
docs: describe dynamodb:Attributes on Scan and the upgrade's policy s…
LeeroyHannigan Nov 1, 2026
890ee53
Merge origin/main into fix/dynamodb-attributes-key
LeeroyHannigan Nov 5, 2026
6ee143f
Merge origin/main into fix/dynamodb-attributes-key
LeeroyHannigan Nov 10, 2026
147378a
fix(server): match dynamodb:Attributes on index scans and missing tables
LeeroyHannigan Nov 10, 2026
26617aa
Merge origin/main into fix/dynamodb-attributes-key
LeeroyHannigan Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions crates/auth/src/policy/condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
19 changes: 13 additions & 6 deletions crates/auth/src/policy/condition/tests/multivalued.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
20 changes: 13 additions & 7 deletions crates/auth/src/policy/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down
10 changes: 9 additions & 1 deletion crates/server/src/authz_resource/cross_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,15 @@ pub(crate) fn find(operation: &str, input: &Value, account_id: &str) -> Option<O
/// each is authorized for: `Mixed` when they are in more than one account
/// (a bare name is in the caller's), else the first in another account.
fn batch_or_transaction(operation: &str, input: &Value, account_id: &str) -> Option<OtherAccount> {
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 {
Expand Down
199 changes: 2 additions & 197 deletions crates/server/src/leading_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
}

/// Find the HASH (partition) key attribute name of a key schema.
fn hash_attr(schema: &[extenddb_core::types::KeySchemaElement]) -> Option<&str> {
schema
Expand Down Expand Up @@ -412,190 +399,8 @@ fn values_map(input: &Value) -> HashMap<String, Value> {
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<String, Option<TableKeyInfo>>,
) -> Option<Vec<AuthzTarget>> {
let pk_attr_of = |table: &str| -> Option<String> {
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<AuthzTarget>, 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<std::collections::BTreeSet<String>> {
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<Vec<String>> {
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;
Loading
Loading