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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions crates/server/src/leading_keys/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ use extenddb_core::types::TableKeyInfo;
use serde_json::Value;

use super::{NumberForm, hash_attr, pk_from_map};
use crate::parameter_keys::{self, ParameterKeys};
use crate::request_attributes::{batch_table_attributes, transact_item_attributes};

/// A single `(action, table)` authorization to perform for a batch or
/// transaction operation, carrying the LeadingKeys and Attributes the service
/// would evaluate.
/// transaction operation, carrying the LeadingKeys, Attributes, Select,
/// ReturnValues and ReturnConsumedCapacity values the service would evaluate.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct AuthzTarget {
/// The bare operation string the action maps to (no `dynamodb:` prefix).
Expand All @@ -28,11 +29,14 @@ pub(crate) struct AuthzTarget {
/// The `dynamodb:Attributes` names for this authorization, or `None` when
/// it names no attribute. See `crate::request_attributes`.
pub attributes: Option<Vec<String>>,
/// The `dynamodb:Select`, `dynamodb:ReturnValues` and
/// `dynamodb:ReturnConsumedCapacity` values. See `crate::parameter_keys`.
pub parameters: ParameterKeys,
}

/// The per-(action, table) or per-item authorizations for a batch or
/// transaction operation, each carrying the LeadingKeys and Attributes the
/// service evaluates.
/// transaction operation, each carrying the condition key values the service
/// evaluates.
///
/// Returns `None` for non-batch/transact operations. Batch operations
/// authorize once per table with the set of all keys for that table;
Expand All @@ -41,9 +45,10 @@ pub(crate) struct AuthzTarget {
/// two (a batch of one own and one other key passes `ForAnyValue`; a
/// transaction of the same two does not). Transaction items that give the same
/// target are returned once, in the order of their first occurrence: the
/// decision for a target depends only on its action, table, LeadingKeys and
/// Attributes, so a hundred identical `ConditionCheck`s on one partition need
/// one evaluation.
/// decision for a target depends only on its action, table and condition key
/// values, so a hundred identical `ConditionCheck`s on one partition need one
/// evaluation. Two `Get`s of one partition that differ only in whether they
/// project give two targets, since their `dynamodb:Select` values differ.
///
/// `key_info_by_table` is the authorization snapshot; a `None` entry is a table
/// that does not exist, whose targets carry no LeadingKeys and none of the
Expand Down Expand Up @@ -79,6 +84,7 @@ pub(crate) fn batch_transact_targets(
table: table.clone(),
leading_keys: keys,
attributes: batch_table_attributes(operation, body, key_info_of(table), limits),
parameters: parameter_keys::batch_table(operation, body, input),
});
}
Some(out)
Expand Down Expand Up @@ -108,6 +114,7 @@ pub(crate) fn batch_transact_targets(
key_info_of(table),
limits,
),
parameters: parameter_keys::transact_item("Get", get, input),
},
);
}
Expand Down Expand Up @@ -145,6 +152,7 @@ pub(crate) fn batch_transact_targets(
key_info_of(table),
limits,
),
parameters: parameter_keys::transact_item(sub, op, input),
},
);
}
Expand Down
1 change: 1 addition & 0 deletions crates/server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub mod key_info_cache;
mod leading_keys;
pub mod management;
mod metrics_endpoint;
mod parameter_keys;
pub mod rate_limit;
mod request_attributes;
mod request_helpers;
Expand Down
185 changes: 185 additions & 0 deletions crates/server/src/parameter_keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

//! `dynamodb:Select`, `dynamodb:ReturnValues` and
//! `dynamodb:ReturnConsumedCapacity` for request authorization.
//!
//! Each key holds the value of the request parameter it is named after. When a
//! request that takes the parameter leaves it out, the service evaluates the
//! value the operation then uses, so the key is present whether or not the
//! caller spells that value out. The list after the bullets says which values
//! were measured on 2026-11-01 and which follow them:
//!
//! - `dynamodb:Select` on `GetItem`, `Query`, `Scan`, each table of a
//! `BatchGetItem` and each `Get` of a `TransactGetItems`:
//! `SPECIFIC_ATTRIBUTES` for a read with a projection (`ProjectionExpression`
//! or `AttributesToGet`), `ALL_ATTRIBUTES` for a read of a table without one,
//! and the `Select` of a `Query` or `Scan` that has one. `GetItem`,
//! `BatchGetItem` and `TransactGetItems` take no `Select` parameter, so a
//! `Select` member in one of them is not read: the engine ignores it and
//! returns what the projection asks for.
//! - `dynamodb:ReturnValues` on `PutItem`, `UpdateItem` and `DeleteItem`: the
//! request's `ReturnValues`, or `NONE`. The `Put`, `Update` and `Delete` items
//! of a `TransactWriteItems` take no `ReturnValues` and hold `NONE`. A
//! `BatchWriteItem` and a `ConditionCheck` leave the key absent.
//! - `dynamodb:ReturnConsumedCapacity` on the item operations: the request's
//! `ReturnConsumedCapacity`, or `NONE`. A batch or transaction request carries
//! one value, and each of its tables or items is evaluated with it.
//!
//! Measured on 2026-11-01: `dynamodb:Select` on `GetItem` and `Query` with and
//! without a projection, on `Scan` without one and with `Select` `COUNT`, on
//! `Query` with `Select` `COUNT`, on a `BatchGetItem` with and without a
//! projection, and on a `TransactGetItems` `Get` without one; `dynamodb:ReturnValues`
//! on `PutItem`, `UpdateItem` and `DeleteItem` with and without the parameter,
//! on a `BatchWriteItem`, and on the `Put`, `Update` and `Delete` items of a
//! `TransactWriteItems`; `dynamodb:ReturnConsumedCapacity` on `GetItem` with and
//! without the parameter, on a `BatchWriteItem` and on a `TransactGetItems`
//! `Get`. `dynamodb:ReturnConsumedCapacity` on the other item operations follows
//! those and was not measured. A read that projects with `AttributesToGet` is
//! treated as one with a `ProjectionExpression`, which the engine reads it as;
//! that was not measured. `SPECIFIC_ATTRIBUTES` on a `Scan` with a projection
//! follows the `Query` and `GetItem` rows and was not measured, and a
//! `TransactGetItems` `Get` with a projection was measured only against a `Deny`
//! on `ALL_ATTRIBUTES`, which it passed, so its `SPECIFIC_ATTRIBUTES` value
//! follows the `BatchGetItem` and `GetItem` rows and was not measured directly.
//! A `ConditionCheck` leaving `dynamodb:ReturnValues` absent follows the
//! transaction write items and was not measured. A `Query` or `Scan` of an index
//! with no projection and no `Select` holds `ALL_PROJECTED_ATTRIBUTES`, the
//! default the DynamoDB API reference gives for a read of an index; that was not
//! measured either.
//!
//! On a single table and in a batch or transaction, an operation carries only
//! the keys it takes; a member an operation does not take is not read.

use serde_json::Value;

/// `dynamodb:Select` for a read with a projection and no `Select`.
const SPECIFIC_ATTRIBUTES: &str = "SPECIFIC_ATTRIBUTES";
/// `dynamodb:Select` for a read of a table with neither.
const ALL_ATTRIBUTES: &str = "ALL_ATTRIBUTES";
/// `dynamodb:Select` for a `Query` or `Scan` of an index with neither (the API
/// reference's default, not measured).
const ALL_PROJECTED_ATTRIBUTES: &str = "ALL_PROJECTED_ATTRIBUTES";
/// `dynamodb:ReturnValues` and `dynamodb:ReturnConsumedCapacity` for a request
/// that takes the parameter and leaves it out.
const NONE: &str = "NONE";

/// The `dynamodb:Select`, `dynamodb:ReturnValues` and
/// `dynamodb:ReturnConsumedCapacity` values of one authorization. `None`
/// leaves a key absent.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub(crate) struct ParameterKeys {
pub select: Option<String>,
pub return_values: Option<String>,
pub return_consumed_capacity: Option<String>,
}

/// The keys of a request authorized once, against one resource. Each key is
/// set only on the operations whose request carries that parameter, the rule
/// `batch_table` and `transact_item` follow: a member an operation does not
/// take (a `Select` or `ReturnValues` on a write, any of the three on a
/// control-plane request) is not read, so a condition on that key cannot be
/// satisfied by a member added to a request the operation does not model.
pub(crate) fn single_table(operation: &str, input: &Value) -> ParameterKeys {
match operation {
"GetItem" => ParameterKeys {
select: Some(projection_select(input).to_owned()),
return_values: None,
return_consumed_capacity: Some(consumed_capacity(input)),
},
"Query" | "Scan" => ParameterKeys {
select: Some(
given(input, "Select").unwrap_or_else(|| range_read_select(input).to_owned()),
),
return_values: None,
return_consumed_capacity: Some(consumed_capacity(input)),
},
"PutItem" | "UpdateItem" | "DeleteItem" => ParameterKeys {
select: None,
return_values: Some(given(input, "ReturnValues").unwrap_or_else(|| NONE.to_owned())),
return_consumed_capacity: Some(consumed_capacity(input)),
},
// SearchVectors takes ReturnConsumedCapacity but no Select or
// ReturnValues. Its value is not measured, so it is read as given and
// left absent when the request omits it, as before.
"SearchVectors" => ParameterKeys {
select: None,
return_values: None,
return_consumed_capacity: given(input, "ReturnConsumedCapacity"),
},
// Control-plane and every other single-resource operation take none of
// the three.
_ => ParameterKeys::default(),
}
}

/// The keys of one table's part of a `BatchGetItem` or `BatchWriteItem`:
/// `body` is that table's `RequestItems` entry and `request` the whole
/// request, which carries `ReturnConsumedCapacity`.
pub(crate) fn batch_table(operation: &str, body: &Value, request: &Value) -> ParameterKeys {
ParameterKeys {
select: (operation == "BatchGetItem").then(|| projection_select(body).to_owned()),
return_values: None,
return_consumed_capacity: Some(consumed_capacity(request)),
}
}

/// The keys of one transaction item: `sub` is the item's member name (`Get`,
/// `Put`, `Update`, `Delete` or `ConditionCheck`), `item` its body and
/// `request` the whole request, which carries `ReturnConsumedCapacity`.
pub(crate) fn transact_item(sub: &str, item: &Value, request: &Value) -> ParameterKeys {
ParameterKeys {
select: (sub == "Get").then(|| projection_select(item).to_owned()),
return_values: matches!(sub, "Put" | "Update" | "Delete").then(|| NONE.to_owned()),
return_consumed_capacity: Some(consumed_capacity(request)),
}
}

/// The request's own value of `parameter`, when it is a string; `None` for a
/// missing member or a `null`. The engine reads a `null` `Select` or projection
/// member as absent, and refuses a `null` `ReturnValues` or
/// `ReturnConsumedCapacity` with a `SerializationException`, so such a request
/// is rejected before this value decides anything.
fn given(request: &Value, parameter: &str) -> Option<String> {
request
.get(parameter)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}

/// Whether a read projects. The engine projects whenever the request carries
/// `ProjectionExpression` or `AttributesToGet`, and refuses an empty one, so a
/// read without either returns every attribute. A `null` member is absent.
fn has_projection(read: &Value) -> bool {
read.get("ProjectionExpression")
.is_some_and(Value::is_string)
|| read.get("AttributesToGet").is_some_and(Value::is_array)
}

/// `dynamodb:Select` of a read that takes no `Select` parameter.
fn projection_select(read: &Value) -> &'static str {
if has_projection(read) {
SPECIFIC_ATTRIBUTES
} else {
ALL_ATTRIBUTES
}
}

/// `dynamodb:Select` of a `Query` or `Scan` with no `Select`.
fn range_read_select(read: &Value) -> &'static str {
if has_projection(read) {
SPECIFIC_ATTRIBUTES
} else if read.get("IndexName").is_some_and(Value::is_string) {
ALL_PROJECTED_ATTRIBUTES
} else {
ALL_ATTRIBUTES
}
}

/// `dynamodb:ReturnConsumedCapacity` of a request that takes the parameter.
fn consumed_capacity(request: &Value) -> String {
given(request, "ReturnConsumedCapacity").unwrap_or_else(|| NONE.to_owned())
}

#[cfg(test)]
mod tests;
Loading
Loading