diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index a48f6084..a70c93a2 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -18,9 +18,13 @@ use super::matcher::wildcard_match; /// - Base (bare) operators on a **single-valued** key: key must be present and /// its value must match at least one policy value. /// - Base (bare) operators on a **multivalued** key (`dynamodb:Attributes`, -/// `dynamodb:LeadingKeys`): never match. AWS IAM requires a -/// `ForAllValues:`/`ForAnyValue:` qualifier for multivalued keys; a bare -/// operator on such a key is a no-op regardless of value count (BR-7085). +/// `dynamodb:LeadingKeys`, `aws:TagKeys`): never match while the key is +/// present. AWS IAM requires a `ForAllValues:`/`ForAnyValue:` qualifier for +/// multivalued keys; a bare operator on a present multivalued key is a no-op +/// regardless of value count (BR-7085). A bare NEGATED operator on an +/// **absent** multivalued key matches on IAM; here it matches only when the +/// context's `bare_negated_matches_absent_multivalued_key` says so for the +/// key, which the `DynamoDB` request context does for `aws:TagKeys` alone. /// - `IfExists`: passes if the key is absent; otherwise evaluates as the base /// operator (and so never matches a present multivalued key). /// - `Null`: checks key presence/absence. @@ -109,18 +113,32 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext Some(vals) => evaluate_single_value_condition(inner, &vals, &expanded_values), }, other => match context_values { - // A negated operator on an absent single-valued key evaluates true - // in AWS IAM: the key has no value, so it cannot equal the policy - // value, so "not equals" holds. This makes a Deny gated on - // StringNotEquals apply when the caller omits the key (the - // confused-deputy guard). Measured on live IAM/STS and DynamoDB. - // Bare operators on multivalued keys - // never match, present or absent (BR-7085), so the rule is scoped - // to single-valued keys. - None => is_negative_operator(other) && !is_multivalued, - // Bare operator on a multivalued key never matches (BR-7085). This is - // fail-safe: a bare Deny becomes a no-op (matching AWS, which forces the - // author to use ForAnyValue:), and a bare Allow allowlist stops granting. + // A negated operator on an absent key evaluates true in AWS IAM: + // the key has no value, so it cannot equal the policy value, so + // "not equals" holds. This makes a Deny gated on StringNotEquals + // apply when the caller omits the key (the confused-deputy guard). + // Measured on live IAM/STS and DynamoDB for single-valued keys, and + // on IAM for the absent multivalued keys `aws:TagKeys`, + // `dynamodb:LeadingKeys` and `dynamodb:Attributes`. For a + // multivalued key the context decides whether that answer applies: + // it is right only where ExtendDB leaves the key absent on the same + // requests the service does. ExtendDB leaves `dynamodb:Attributes` + // absent on writes, on `AttributesToGet` reads and on batch and + // transaction targets, and `dynamodb:LeadingKeys` absent on query + // shapes it cannot pin to a partition value, where the service + // populates them; matching there would grant an Allow the service + // refuses, so those keys keep the no-match rule. `aws:TagKeys` is + // populated on exactly the three operations the service reference + // lists it for, so its absent-key answer is IAM's. + None => { + is_negative_operator(other) + && (!is_multivalued + || context.bare_negated_matches_absent_multivalued_key(&condition.key)) + } + // Bare operator on a present multivalued key never matches + // (BR-7085). This is fail-safe: a bare Deny becomes a no-op + // (matching AWS, which forces the author to use ForAnyValue:), and a + // bare Allow allowlist stops granting. Some(_) if is_multivalued => false, Some(vals) => evaluate_single_value_condition(other, &vals, &expanded_values), }, @@ -153,14 +171,15 @@ pub enum ConditionMatch { /// Evaluate one condition three-valued. /// /// Returns [`ConditionMatch::Unresolvable`] when the key is unresolved and the -/// context reports it as service-populated (case 3). Otherwise returns `Match` -/// or `NoMatch` from the ordinary evaluation, so cases 1 and 2 are unchanged. +/// context reports it as service-populated (case 3), and when a condition value +/// reads such a key through a `${name}` policy variable, since the variable has +/// no value to expand to. Otherwise returns `Match` or `NoMatch` from the +/// ordinary evaluation, so cases 1 and 2 are unchanged. pub fn evaluate_condition_match( condition: &Condition, context: &impl ConditionContext, ) -> ConditionMatch { - if context.resolve_key(&condition.key).is_none() && context.is_unresolvable_key(&condition.key) - { + if is_unresolvable(&condition.key, context) || reads_unresolvable_variable(condition, context) { return ConditionMatch::Unresolvable; } if evaluate_condition(condition, context) { @@ -170,6 +189,35 @@ pub fn evaluate_condition_match( } } +/// Whether `key` is unresolved and the context reports it as one the service +/// populates. +fn is_unresolvable(key: &str, context: &impl ConditionContext) -> bool { + context.resolve_key(key).is_none() && context.is_unresolvable_key(key) +} + +/// Whether a value of `condition` names, as a `${name}` policy variable, a key +/// [`is_unresolvable`] reports. +fn reads_unresolvable_variable(condition: &Condition, context: &impl ConditionContext) -> bool { + condition + .values + .iter() + .any(|value| policy_variable_names(value).any(|name| is_unresolvable(name, context))) +} + +/// The names of the `${name}` policy variables in `value`, in the order +/// [`expand_policy_variables`] reads them. A `${` with no closing brace names +/// nothing, as it expands to itself. +fn policy_variable_names(value: &str) -> impl Iterator { + let mut rest = value; + std::iter::from_fn(move || { + let start = rest.find("${")?; + let after_start = &rest[start + 2..]; + let end = after_start.find('}')?; + rest = &after_start[end + 1..]; + Some(&after_start[..end]) + }) +} + /// Evaluate a statement's whole condition block (all conditions are ANDed). /// /// `NoMatch` dominates: if any condition is definitely not satisfied the @@ -198,8 +246,15 @@ pub fn evaluate_statement_conditions( /// Expand IAM policy variables in a string value. /// /// Replaces `${variable}` patterns with their resolved values from the context. -/// Supported variables: `aws:PrincipalTag/*` and any key the context can -/// resolve. Unresolvable variables are left as-is (IAM behavior). +/// Supported variables: `aws:PrincipalTag/*` and any single-valued key the +/// context can resolve. A variable that names an unresolvable key stays +/// literal (IAM behavior), and so does one that names a multivalued key +/// (`aws:TagKeys`, `dynamodb:LeadingKeys`, `dynamodb:Attributes`): a set of +/// values has no one value to expand to. IAM leaves such a variable as text +/// (measured 2026-10-27: a Deny on `aws:ResourceTag/team` equal to +/// `${aws:TagKeys}` applies to no request, whatever order the request lists +/// its tag keys in, and the same Deny under `StringNotEquals` applies to +/// every request). fn expand_policy_variables(value: &str, context: &impl ConditionContext) -> String { if !value.contains("${") { return value.to_owned(); @@ -213,13 +268,18 @@ fn expand_policy_variables(value: &str, context: &impl ConditionContext) -> Stri let after_start = &rest[start + 2..]; if let Some(end) = after_start.find('}') { let var_name = &after_start[..end]; - if let Some(vals) = context.resolve_key(var_name) { - // Use the first value for single-valued expansion. + let resolved = if context.is_multivalued_key(var_name) { + None + } else { + context.resolve_key(var_name) + }; + if let Some(vals) = resolved { + // A single-valued key resolves to one value. if let Some(v) = vals.first() { result.push_str(v); } } else { - // Unresolvable — leave the variable literal. + // Unresolvable or multivalued: leave the variable literal. result.push_str(&rest[start..start + 3 + end]); } rest = &after_start[end + 1..]; diff --git a/crates/auth/src/policy/condition/tests.rs b/crates/auth/src/policy/condition/tests.rs index b3c90cc9..665ca0ee 100644 --- a/crates/auth/src/policy/condition/tests.rs +++ b/crates/auth/src/policy/condition/tests.rs @@ -46,12 +46,20 @@ impl ConditionContext for TestContext { fn is_multivalued_key(&self, key: &str) -> bool { // Mirror the real RequestContext classification so tests exercise the // production arity rule. - matches!(key, "dynamodb:LeadingKeys" | "dynamodb:Attributes") + matches!( + key, + "dynamodb:LeadingKeys" | "dynamodb:Attributes" | "aws:TagKeys" + ) } fn is_unresolvable_key(&self, key: &str) -> bool { self.unresolvable.contains(key) } + + fn bare_negated_matches_absent_multivalued_key(&self, key: &str) -> bool { + // Mirror RequestContext: only aws:TagKeys takes IAM's absent-key answer. + key == "aws:TagKeys" + } } fn cond(op: ConditionOperator, key: &str, values: Vec<&str>) -> Condition { diff --git a/crates/auth/src/policy/condition/tests/multivalued.rs b/crates/auth/src/policy/condition/tests/multivalued.rs index 17f28a0e..aad148ae 100644 --- a/crates/auth/src/policy/condition/tests/multivalued.rs +++ b/crates/auth/src/policy/condition/tests/multivalued.rs @@ -297,6 +297,55 @@ fn policy_variable_unresolvable_left_literal() { )); } +#[test] +fn policy_variable_naming_aws_tagkeys_is_left_literal() { + // A multivalued key has no single value to expand to. The condition value + // ${aws:TagKeys} is text that no tag key equals, so a Deny comparing + // aws:ResourceTag/team with it applies to no request, whichever tag key the + // request lists first. IAM answers the same (SimulateCustomPolicy, + // 2026-10-27). + let team_is_tag_keys = cond( + ConditionOperator::StringEquals, + "aws:ResourceTag/team", + vec!["${aws:TagKeys}"], + ); + for keys in [vec!["env", "owner"], vec!["owner", "env"]] { + let ctx = TestContext::new() + .with("aws:ResourceTag/team", vec!["env"]) + .with("aws:TagKeys", keys.clone()); + assert!( + !evaluate_condition(&team_is_tag_keys, &ctx), + "${{aws:TagKeys}} expanded for the keys {keys:?}" + ); + } + // The variable is text, so it equals the same text. + let ctx = TestContext::new() + .with("aws:ResourceTag/team", vec!["${aws:TagKeys}"]) + .with("aws:TagKeys", vec!["env", "owner"]); + assert!(evaluate_condition(&team_is_tag_keys, &ctx)); +} + +#[test] +fn policy_variables_naming_the_dynamodb_multivalued_keys_are_left_literal() { + for key in ["dynamodb:LeadingKeys", "dynamodb:Attributes"] { + let variable = format!("${{{key}}}"); + let ctx = TestContext::new() + .with("k", vec!["pk1"]) + .with(key, vec!["pk1", "pk2"]); + assert!( + !evaluate_condition( + &cond( + ConditionOperator::StringEquals, + "k", + vec![variable.as_str()] + ), + &ctx + ), + "{variable} expanded to its first value" + ); + } +} + // --- ForAllValues with negative operators --- #[test] @@ -375,17 +424,79 @@ fn for_any_value_string_not_equals_one_outside_set() { } #[test] -fn bare_negative_op_multivalued_absent_no_match() { - // BR-7085 preserved: a bare negated operator on an ABSENT multivalued key - // still never matches. The negated-absent rule is scoped to single-valued - // keys; multivalued keys require ForAllValues/ForAnyValue. +fn bare_negated_op_absent_tagkeys_matches() { + // A bare negated operator on an ABSENT aws:TagKeys matches, as on an + // absent single-valued key. Measured on IAM with simulate-custom-policy on + // 2026-10-24: a Deny carrying a bare StringNotEquals, + // StringNotEqualsIgnoreCase or StringNotLike on the key fires when the key + // is absent, and an Allow gated the same way grants. ExtendDB populates + // aws:TagKeys on exactly the operations the service does, so its absence + // is the service's absence and IAM's answer applies. let ctx = TestContext::new(); - assert!(!evaluate_condition( - &cond( - ConditionOperator::StringNotEquals, - "dynamodb:Attributes", - vec!["ssn"] + for op in [ + ConditionOperator::StringNotEquals, + ConditionOperator::StringNotEqualsIgnoreCase, + ConditionOperator::StringNotLike, + ] { + assert!( + evaluate_condition(&cond(op, "aws:TagKeys", vec!["secret"]), &ctx), + "a bare negated operator on absent aws:TagKeys matches, as on IAM" + ); + } + assert!( + !evaluate_condition( + &cond( + ConditionOperator::StringEquals, + "aws:TagKeys", + vec!["secret"] + ), + &ctx ), - &ctx - )); + "a bare positive operator on absent aws:TagKeys does not match" + ); +} + +#[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). + let ctx = TestContext::new(); + for key in ["dynamodb:Attributes", "dynamodb:LeadingKeys"] { + for op in [ + ConditionOperator::StringNotEquals, + ConditionOperator::StringNotEqualsIgnoreCase, + ConditionOperator::StringNotLike, + ConditionOperator::StringEquals, + ] { + assert!( + !evaluate_condition(&cond(op, key, vec!["ssn"]), &ctx), + "a bare operator on absent {key} does not match" + ); + } + } +} + +#[test] +fn bare_op_multivalued_present_never_matches() { + // BR-7085, measured again on IAM 2026-10-24: with the key PRESENT, neither + // a bare positive nor a bare negative operator matches. The author must + // qualify with ForAllValues or ForAnyValue. + for key in ["dynamodb:Attributes", "dynamodb:LeadingKeys", "aws:TagKeys"] { + let ctx = TestContext::new().with(key, vec!["colour"]); + for op in [ + ConditionOperator::StringEquals, + ConditionOperator::StringNotEquals, + ConditionOperator::StringNotEqualsIgnoreCase, + ] { + assert!( + !evaluate_condition(&cond(op, key, vec!["ssn"]), &ctx), + "a bare operator on present {key} does not match" + ); + } + } } diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 25a4a076..be764624 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -9,6 +9,8 @@ use std::collections::HashMap; +use request_tags::{RequestTagIndex, RequestTagLookup}; + /// Trait for resolving condition keys during policy evaluation. /// /// Implemented by `RequestContext` (`DynamoDB` operations) and @@ -43,6 +45,23 @@ pub trait ConditionContext { fn is_unresolvable_key(&self, _key: &str) -> bool { false } + + /// Whether a bare negated operator (`StringNotEquals`, `StringNotLike`, + /// `StringNotEqualsIgnoreCase` and the other `Not` forms) matches when + /// `key`, a multivalued key, is absent from the request. + /// + /// IAM answers yes for every key: an absent key has no value, so "not + /// equals" holds (measured with `simulate-custom-policy` for + /// `aws:TagKeys`, `dynamodb:LeadingKeys` and `dynamodb:Attributes`). That + /// answer is right for ExtendDB only where ExtendDB leaves the key absent + /// on the same requests the service does. Where ExtendDB leaves a key + /// absent that the service populates, matching would grant an Allow the + /// service refuses, so the default is `false` (no match) and a context + /// opts in per key. Consulted only for a key `is_multivalued_key` reports + /// as multivalued; single-valued keys always match here. + fn bare_negated_matches_absent_multivalued_key(&self, _key: &str) -> bool { + false + } } /// Look up a tag map by key, matching the live service. @@ -193,6 +212,12 @@ pub struct RequestParams { /// assumed without MFA) and leaves it absent for an IAM user with long-term /// keys. `Some(false)` for a role session, `None` for an IAM user. pub multi_factor_auth_present: Option, + /// The tags the request carries, for `aws:RequestTag/`. `None` for an + /// operation that carries no tag values, where the key is absent. + pub request_tags: Option>, + /// The tag keys the request names, for `aws:TagKeys`, in request order. + /// `None` for an operation that names no tag key, where the key is absent. + pub request_tag_keys: Option>, } /// Context for evaluating conditions on `DynamoDB` operations. @@ -251,6 +276,14 @@ pub struct RequestContext { pub current_time: String, /// The request time formatted for `aws:EpochTime` (whole seconds). pub epoch_time: String, + /// The tags the request carries, indexed by case-folded name and resolved + /// as `aws:RequestTag/`. `None` for an operation that carries no tag + /// values, where the service leaves the key absent. + pub request_tags: Option, + /// The tag keys the request names, resolved as `aws:TagKeys`, in request + /// order. `None` for an operation that names no tag key, where the service + /// leaves the key absent. + pub request_tag_keys: Option>, } impl RequestContext { @@ -291,6 +324,11 @@ impl RequestContext { multi_factor_auth_present: params.multi_factor_auth_present, current_time, epoch_time, + // An operation that carries a tag set but an empty one populates + // neither key: the service has no tag key to put in aws:TagKeys and + // no value to put in aws:RequestTag/, so both read absent. + request_tags: RequestTagIndex::from_tags(params.request_tags), + request_tag_keys: params.request_tag_keys.filter(|k| !k.is_empty()), } } } @@ -304,6 +342,19 @@ impl ConditionContext for RequestContext { // under Unicode full case folding, matching the live DynamoDB path. if let Some(sub) = strip_prefix_ci(key, "aws:PrincipalTag/") { tag_lookup(&self.principal_tags, sub, super::casefold::case_fold) + } else if let Some(sub) = strip_prefix_ci(key, "aws:RequestTag/") { + // The tags the request carries. The service populates this key for + // the operations that take a tag set, from the tags in the request + // body, and leaves it absent for every other operation. Resolved + // from the same map the engine writes, so a statement gated on a + // request tag reads the value the request would store. A request + // naming the tag under several spellings that fold to one name + // with differing values resolves to nothing here and is reported + // by `is_unresolvable_key`, so the statement fails closed. + match self.request_tag_values(sub) { + RequestTagLookup::Value(v) => Some(vec![v]), + RequestTagLookup::Absent | RequestTagLookup::Ambiguous => None, + } } else if let Some(sub) = strip_prefix_ci(key, "aws:ResourceTag/") { // The service evaluates resource tags only as aws:ResourceTag/*, // resolved from the resource-tag map. dynamodb:ResourceTag/* is @@ -349,6 +400,14 @@ impl ConditionContext for RequestContext { "aws:userid" => self.userid.as_deref().map(|v| vec![v]), "aws:resourceaccount" => self.resource_account.as_deref().map(|v| vec![v]), "aws:tokenissuetime" => self.token_issue_time.as_deref().map(|v| vec![v]), + // The tag keys the request names, in request order. The service + // populates this key for the operations that take a tag set and + // leaves it absent for every other operation, and for a + // tag-taking operation that names no key. + "aws:tagkeys" => self + .request_tag_keys + .as_ref() + .map(|v| v.iter().map(std::string::String::as_str).collect()), // Populated for a role session (false for a session assumed // without MFA), absent for an IAM user, matching the service. "aws:multifactorauthpresent" => self @@ -367,22 +426,46 @@ impl ConditionContext for RequestContext { fn is_multivalued_key(&self, key: &str) -> bool { // Per the AWS DynamoDB service authorization reference these keys are // `ArrayOfString` (multivalued); all other DynamoDB keys and tag keys are - // single-valued. Verified against real AWS IAM (BR-7085). Key names are + // single-valued. Verified against real AWS IAM (BR-7085). `aws:TagKeys` + // is multivalued for the same reason: it is the set of tag keys the + // request names, and IAM answers a bare operator on it the way it + // answers one on `dynamodb:LeadingKeys` (measured). Key names are // matched under Unicode full case folding, matching the service. let folded = super::casefold::case_fold(key); matches!( folded.as_str(), - "dynamodb:leadingkeys" | "dynamodb:attributes" + "dynamodb:leadingkeys" | "dynamodb:attributes" | "aws:tagkeys" ) } fn is_unresolvable_key(&self, key: &str) -> bool { - // An unresolved aws: global key that is neither a tag sub-key nor on the - // absent-ok allowlist is one the service populates but this context has - // no value for (for example aws:PrincipalOrgID, aws:SourceIp): it fails - // closed. Keys resolved above never reach here. + // A request tag key the request names under two or more spellings that + // fold to one name and do not all carry one value cannot be read as one + // value: the statement fails closed (a Deny blocks, an Allow does not + // grant). Every other unresolved aws: global key that is neither a tag + // sub-key nor on the absent-ok allowlist is one the service populates + // but this context has no value for (for example aws:PrincipalOrgID, + // aws:SourceIp): it fails closed. Keys resolved above never reach here. + if let Some(sub) = strip_prefix_ci(key, "aws:RequestTag/") { + return self.request_tag_values(sub) == RequestTagLookup::Ambiguous; + } aws_key_is_unresolvable(key) } + + fn bare_negated_matches_absent_multivalued_key(&self, key: &str) -> bool { + // IAM matches a bare negated operator on any absent multivalued key. + // 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. + super::casefold::case_fold(key) == "aws:tagkeys" + } } /// Context for evaluating trust policy conditions during `AssumeRole`. @@ -509,6 +592,11 @@ impl ConditionContext for AssumeRoleContext { } } +mod request_tags; + +#[cfg(test)] +mod request_tag_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs new file mode 100644 index 00000000..38b91e40 --- /dev/null +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -0,0 +1,411 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `RequestContext` resolution of `aws:TagKeys` and `aws:RequestTag/`. + +use super::*; + +fn ctx_with(tags: &[(&str, &str)], keys: &[&str]) -> RequestContext { + RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + request_tags: Some( + tags.iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(), + ), + request_tag_keys: Some(keys.iter().map(|k| (*k).to_owned()).collect()), + ..Default::default() + }, + ) +} + +#[test] +fn no_tag_fields_leave_both_keys_absent() { + let ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams::default(), + ); + assert_eq!(ctx.resolve_key("aws:TagKeys"), None); + assert_eq!(ctx.resolve_key("aws:RequestTag/env"), None); + assert!(!ctx.is_unresolvable_key("aws:TagKeys")); + assert!(!ctx.is_unresolvable_key("aws:RequestTag/env")); +} + +#[test] +fn empty_tag_set_and_empty_key_list_leave_both_keys_absent() { + // An operation that carries a tag field with nothing in it populates + // neither key, so a Null:true condition on either sees an absent key. + let ctx = ctx_with(&[], &[]); + assert_eq!(ctx.request_tags, None); + assert_eq!(ctx.request_tag_keys, None); + assert_eq!(ctx.resolve_key("aws:TagKeys"), None); + assert_eq!(ctx.resolve_key("aws:RequestTag/env"), None); +} + +#[test] +fn present_tags_resolve_both_keys() { + let ctx = ctx_with(&[("env", "prod"), ("owner", "ops")], &["env", "owner"]); + assert_eq!(ctx.resolve_key("aws:TagKeys"), Some(vec!["env", "owner"])); + assert_eq!(ctx.resolve_key("aws:RequestTag/env"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:RequestTag/owner"), Some(vec!["ops"])); + // The prefix and the tag name are matched without regard to case. + assert_eq!(ctx.resolve_key("AWS:TAGKEYS"), Some(vec!["env", "owner"])); + assert_eq!(ctx.resolve_key("aws:requesttag/ENV"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:RequestTag/missing"), None); + assert!(!ctx.is_unresolvable_key("aws:RequestTag/missing")); +} + +#[test] +fn tagkeys_is_multivalued_and_the_request_tag_is_not() { + let ctx = ctx_with(&[("env", "prod")], &["env"]); + assert!(ctx.is_multivalued_key("aws:TagKeys")); + assert!(ctx.is_multivalued_key("AWS:TAGKEYS")); + assert!(ctx.is_multivalued_key("dynamodb:LeadingKeys")); + assert!(ctx.is_multivalued_key("dynamodb:Attributes")); + assert!(!ctx.is_multivalued_key("aws:RequestTag/env")); + assert!(!ctx.is_multivalued_key("aws:PrincipalTag/env")); +} + +#[test] +fn absent_key_negated_rule_applies_to_tagkeys_only() { + let ctx = ctx_with(&[], &[]); + assert!(ctx.bare_negated_matches_absent_multivalued_key("aws:TagKeys")); + assert!(ctx.bare_negated_matches_absent_multivalued_key("aws:tagkeys")); + assert!(!ctx.bare_negated_matches_absent_multivalued_key("dynamodb:LeadingKeys")); + assert!(!ctx.bare_negated_matches_absent_multivalued_key("dynamodb:Attributes")); +} + +#[test] +fn case_variant_keys_with_one_value_resolve_to_it() { + // Env=prod and ENV=prod fold to one name and carry one value: the key + // reads prod whichever spelling the policy uses. + let ctx = ctx_with(&[("Env", "prod"), ("ENV", "prod")], &["Env", "ENV"]); + assert_eq!(ctx.resolve_key("aws:RequestTag/env"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:RequestTag/Env"), Some(vec!["prod"])); + assert!(!ctx.is_unresolvable_key("aws:RequestTag/env")); +} + +#[test] +fn case_variant_keys_with_differing_values_fail_closed() { + // Env=prod and env=dev are stored as two tags by the engine. The key has + // no single value, so it resolves to nothing and is reported unresolvable: + // a Deny on it blocks and an Allow on it does not grant. Both orders. + for tags in [ + [("Env", "prod"), ("env", "dev")], + [("env", "dev"), ("Env", "prod")], + ] { + let ctx = ctx_with(&tags, &["Env", "env"]); + for key in [ + "aws:RequestTag/env", + "aws:RequestTag/Env", + "aws:RequestTag/ENV", + ] { + assert_eq!(ctx.resolve_key(key), None, "{key} resolves to nothing"); + assert!(ctx.is_unresolvable_key(key), "{key} fails closed"); + } + // Another tag on the same request is unaffected. + let ctx = ctx_with(&[("Env", "prod"), ("env", "dev"), ("owner", "ops")], &[]); + assert_eq!(ctx.resolve_key("aws:RequestTag/owner"), Some(vec!["ops"])); + assert!(!ctx.is_unresolvable_key("aws:RequestTag/owner")); + } +} + +#[test] +fn ambiguous_request_tag_fails_the_statement_closed() { + use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions}; + use crate::policy::document::{Condition, ConditionOperator}; + + let ctx = ctx_with(&[("Env", "prod"), ("env", "dev")], &["Env", "env"]); + let deny_on_prod = [Condition { + operator: ConditionOperator::StringEquals, + key: "aws:RequestTag/env".to_owned(), + values: vec!["prod".to_owned()], + }]; + assert_eq!( + evaluate_statement_conditions(&deny_on_prod, &ctx), + ConditionMatch::Unresolvable + ); + // The same statement against a request naming the tag once reads its value. + let one = ctx_with(&[("env", "prod")], &["env"]); + assert_eq!( + evaluate_statement_conditions(&deny_on_prod, &one), + ConditionMatch::Match + ); +} + +#[test] +fn ambiguous_request_tag_as_a_policy_variable_fails_the_statement_closed() { + use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions}; + use crate::policy::document::{Condition, ConditionOperator}; + + let mut resource = HashMap::new(); + resource.insert("env".to_owned(), "prod".to_owned()); + let with_resource_tag = |tags: &[(&str, &str)]| { + let mut ctx = ctx_with(tags, &[]); + ctx.resource_tags = resource.clone(); + ctx + }; + let deny = [Condition { + operator: ConditionOperator::StringEquals, + key: "aws:ResourceTag/env".to_owned(), + values: vec!["${aws:RequestTag/env}".to_owned()], + }]; + // One spelling expands to its value. + for tags in [[("env", "prod")], [("Env", "prod")]] { + assert_eq!( + evaluate_statement_conditions(&deny, &with_resource_tag(&tags)), + ConditionMatch::Match + ); + } + // Two spellings with differing values have no value to expand to. + for tags in [ + [("Env", "prod"), ("env", "dev")], + [("env", "dev"), ("Env", "prod")], + ] { + assert_eq!( + evaluate_statement_conditions(&deny, &with_resource_tag(&tags)), + ConditionMatch::Unresolvable + ); + } + // The variable inside a longer value, and a negated operator, fail closed too. + let embedded = [Condition { + operator: ConditionOperator::StringNotEquals, + key: "aws:ResourceTag/env".to_owned(), + values: vec!["x-${aws:RequestTag/env}-y".to_owned()], + }]; + assert_eq!( + evaluate_statement_conditions( + &embedded, + &with_resource_tag(&[("Env", "prod"), ("env", "dev")]) + ), + ConditionMatch::Unresolvable + ); + // An absent request tag is not ambiguous: the variable stays literal, as before. + assert_eq!( + evaluate_statement_conditions(&deny, &with_resource_tag(&[("team", "x")])), + ConditionMatch::NoMatch + ); + // A brace that never closes names no variable. + let unclosed = [Condition { + operator: ConditionOperator::StringEquals, + key: "aws:ResourceTag/env".to_owned(), + values: vec!["${aws:RequestTag/env".to_owned()], + }]; + assert_eq!( + evaluate_statement_conditions( + &unclosed, + &with_resource_tag(&[("Env", "prod"), ("env", "dev")]) + ), + ConditionMatch::NoMatch + ); +} + +#[test] +fn policy_variable_naming_aws_tagkeys_is_left_literal_for_every_tag_order() { + use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions}; + use crate::policy::document::{Condition, ConditionOperator}; + + // A Deny on aws:ResourceTag/team equal to ${aws:TagKeys} on a table tagged + // team=env. The variable names a multivalued key, so it stays literal and + // the statement applies to neither order of the request's tag keys. + let deny = [Condition { + operator: ConditionOperator::StringEquals, + key: "aws:ResourceTag/team".to_owned(), + values: vec!["${aws:TagKeys}".to_owned()], + }]; + for keys in [["env", "owner"], ["owner", "env"]] { + let mut ctx = ctx_with(&[("env", "x"), ("owner", "x")], &keys); + ctx.resource_tags + .insert("team".to_owned(), "env".to_owned()); + assert_eq!( + evaluate_statement_conditions(&deny, &ctx), + ConditionMatch::NoMatch, + "tag keys {keys:?}" + ); + } +} + +/// An `AssumeRoleContext` for an IAM user caller with the given principal tags +/// and session tags. +fn assume_role_ctx(principal: &[(&str, &str)], session: &[(&str, &str)]) -> AssumeRoleContext { + let owned = |tags: &[(&str, &str)]| -> HashMap { + tags.iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect() + }; + AssumeRoleContext { + principal_tags: owned(principal), + external_id: None, + session_name: Some("probe".to_owned()), + principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), + principal_account: Some("123456789012".to_owned()), + request_tags: owned(session), + region: Some("us-east-1".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + username: Some("alice".to_owned()), + source_ip: None, + user_agent: None, + userid: None, + resource_account: Some("123456789012".to_owned()), + current_time: "2026-01-01T00:00:00Z".to_owned(), + epoch_time: "1767225600".to_owned(), + } +} + +#[test] +fn trust_policy_variable_naming_aws_tagkeys_is_left_literal() { + use crate::policy::condition::{ConditionMatch, evaluate_statement_conditions}; + use crate::policy::document::{Condition, ConditionOperator}; + + // AssumeRoleContext reports aws:TagKeys as multivalued, so a trust policy + // condition value ${aws:TagKeys} stays literal text. Expanded, it would be + // the first session tag key in the map's iteration order, which a test + // cannot choose. One context holds both session tag keys in one map, and + // each of its two principal tags holds one key, so an expansion to either + // key matches the condition on the principal tag that holds it. + let compare_with_tag_keys = |principal_tag: &str| { + [Condition { + operator: ConditionOperator::StringEquals, + key: format!("aws:PrincipalTag/{principal_tag}"), + values: vec!["${aws:TagKeys}".to_owned()], + }] + }; + let session = [("env", "x"), ("owner", "x")]; + let ctx = assume_role_ctx(&[("key_env", "env"), ("key_owner", "owner")], &session); + for tag in ["key_env", "key_owner"] { + assert_eq!( + evaluate_statement_conditions(&compare_with_tag_keys(tag), &ctx), + ConditionMatch::NoMatch, + "${{aws:TagKeys}} expanded to the session tag key in {tag}" + ); + } + // The variable is text, so it equals the same text. + let ctx = assume_role_ctx(&[("team", "${aws:TagKeys}")], &session); + assert_eq!( + evaluate_statement_conditions(&compare_with_tag_keys("team"), &ctx), + ConditionMatch::Match + ); +} + +#[test] +fn an_empty_request_tag_value_is_a_present_key() { + use crate::policy::condition::evaluate_condition; + use crate::policy::document::{Condition, ConditionOperator}; + + // An empty tag value is a value, so the key is present: Null:false matches + // and Null:true does not. + let ctx = ctx_with(&[("env", "")], &["env"]); + assert_eq!(ctx.resolve_key("aws:RequestTag/env"), Some(vec![""])); + assert!(!ctx.is_unresolvable_key("aws:RequestTag/env")); + let null = |value: &str| Condition { + operator: ConditionOperator::Null, + key: "aws:RequestTag/env".to_owned(), + values: vec![value.to_owned()], + }; + assert!(evaluate_condition(&null("false"), &ctx)); + assert!(!evaluate_condition(&null("true"), &ctx)); +} + +#[test] +fn request_tag_names_match_beyond_ascii_case() { + // A tag name that differs from the policy's only in the case of a non-ASCII + // letter matches: a Deny on aws:RequestTag/Étag applies to a request that + // carries étag (iam simulate-custom-policy, 2026-10-27, two samples). + // + // ExtendDB folds the sharp s to ss as well, so Straße and STRASSE are one + // name here. On the service they are two: the same Deny does not apply to + // the other spelling, on iam simulate-custom-policy and on a resource policy + // on a DynamoDB table (2026-10-27, two samples each). That difference is + // pinned in sharp_s_and_ss_are_one_request_tag_name. + let ctx = ctx_with(&[("étag", "x")], &["étag"]); + assert_eq!(ctx.resolve_key("aws:RequestTag/étag"), Some(vec!["x"])); + assert_eq!(ctx.resolve_key("aws:RequestTag/Étag"), Some(vec!["x"])); + let ctx = ctx_with(&[("Étag", "x")], &["Étag"]); + assert_eq!(ctx.resolve_key("aws:RequestTag/étag"), Some(vec!["x"])); + // Two spellings that differ in case and carry differing values are ambiguous. + let ctx = ctx_with(&[("Étag", "x"), ("étag", "y")], &["Étag", "étag"]); + assert_eq!(ctx.resolve_key("aws:RequestTag/étag"), None); + assert!(ctx.is_unresolvable_key("aws:RequestTag/étag")); +} + +#[test] +fn sharp_s_and_ss_are_one_request_tag_name() { + // Known divergence from the service, kept until the folding rule is decided. + // The lookup folds names with full Unicode case folding, which maps the sharp + // s to ss, so Straße and STRASSE are one name here. The service does not + // equate them (measured 2026-10-27): a Deny on aws:RequestTag/STRASSE applies + // to a tag named STRASSE or strasse and does not apply to one named Straße, + // on iam simulate-custom-policy and on a resource policy on a DynamoDB table, + // two samples each. This test pins the ExtendDB side in both directions, so a + // change to the folding rule fails it and the test is then updated with the + // decision. + let ctx = ctx_with(&[("Straße", "1")], &["Straße"]); + assert_eq!( + ctx.request_tag_values("STRASSE"), + RequestTagLookup::Value("1") + ); + let ctx = ctx_with(&[("STRASSE", "1")], &["STRASSE"]); + assert_eq!( + ctx.request_tag_values("Straße"), + RequestTagLookup::Value("1") + ); +} + +#[test] +fn request_tag_lookups_do_not_scan_the_request_tags() { + // A request body can carry tens of thousands of tags and a policy can read + // aws:RequestTag/ in many conditions. Each lookup probes the index + // built with the context, so 3,000 lookups against 20,000 tags take + // milliseconds. Folding every tag name on every lookup folds 60 million + // names, which takes seconds even in a release build. + let tags: HashMap = (0..20_000) + .map(|i| (format!("t{i}"), "v".to_owned())) + .collect(); + let ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + request_tags: Some(tags), + ..Default::default() + }, + ); + let started = std::time::Instant::now(); + for i in 0..1_500 { + let key = format!("aws:RequestTag/absent{i}"); + assert_eq!(ctx.resolve_key(&key), None); + assert!(!ctx.is_unresolvable_key(&key)); + } + let elapsed = started.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(2), + "3,000 lookups against 20,000 tags took {elapsed:?}" + ); + assert_eq!(ctx.resolve_key("aws:RequestTag/t19999"), Some(vec!["v"])); +} + +#[test] +fn principal_and_resource_tag_lookup_keep_the_exact_first_rule() { + // Only aws:RequestTag changed. The neighbouring tag keys still resolve a + // byte-exact stored key first and otherwise the smallest fold-matching key. + let mut principal = HashMap::new(); + principal.insert("Env".to_owned(), "prod".to_owned()); + principal.insert("env".to_owned(), "dev".to_owned()); + let ctx = RequestContext::build( + principal.clone(), + principal, + false, + RequestParams::default(), + ); + assert_eq!(ctx.resolve_key("aws:PrincipalTag/env"), Some(vec!["dev"])); + assert_eq!(ctx.resolve_key("aws:PrincipalTag/Env"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:PrincipalTag/ENV"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:ResourceTag/env"), Some(vec!["dev"])); + assert!(!ctx.is_unresolvable_key("aws:PrincipalTag/ENV")); +} diff --git a/crates/auth/src/policy/context/request_tags.rs b/crates/auth/src/policy/context/request_tags.rs new file mode 100644 index 00000000..598771e1 --- /dev/null +++ b/crates/auth/src/policy/context/request_tags.rs @@ -0,0 +1,99 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Resolution of `aws:RequestTag/` against the tags a request carries. + +use std::collections::HashMap; +use std::collections::hash_map::Entry; + +use super::RequestContext; + +/// The tags a request carries, indexed by case-folded name. +/// +/// The index is built once, when the context is built, so resolving +/// `aws:RequestTag/` folds the policy's key name and probes the index. The +/// cost does not grow with the number of tags in the request or with the number +/// of statements that read the key. +#[derive(Debug, PartialEq, Eq)] +pub struct RequestTagIndex { + by_folded_name: HashMap, +} + +#[derive(Debug, PartialEq, Eq)] +enum FoldedTag { + /// Every tag whose name folds to this one carries this value. + Value(String), + /// Two or more tags whose names fold to this one carry differing values. + Ambiguous, +} + +impl RequestTagIndex { + /// Index the request's tags by the Unicode case fold of each tag name. An + /// operation that carries no tag set, or an empty one, has no index. + pub(super) fn from_tags(tags: Option>) -> Option { + tags.filter(|t| !t.is_empty()).map(Self::new) + } + + fn new(tags: HashMap) -> Self { + let mut by_folded_name: HashMap = HashMap::with_capacity(tags.len()); + for (name, value) in tags { + match by_folded_name.entry(crate::policy::casefold::case_fold(&name)) { + Entry::Vacant(slot) => { + slot.insert(FoldedTag::Value(value)); + } + Entry::Occupied(mut slot) => { + if matches!(slot.get(), FoldedTag::Value(seen) if *seen != value) { + slot.insert(FoldedTag::Ambiguous); + } + } + } + } + Self { by_folded_name } + } + + fn lookup(&self, sub_key: &str) -> RequestTagLookup<'_> { + match self + .by_folded_name + .get(&crate::policy::casefold::case_fold(sub_key)) + { + None => RequestTagLookup::Absent, + Some(FoldedTag::Value(value)) => RequestTagLookup::Value(value), + Some(FoldedTag::Ambiguous) => RequestTagLookup::Ambiguous, + } + } +} + +impl RequestContext { + /// Resolve `aws:RequestTag/` against the tags the request carries. + /// + /// The tag name in the condition key is matched under Unicode case folding, + /// as the principal-tag and resource-tag keys are on this path. The service + /// matches names that differ in the case of one character (`Étag` and + /// `étag`) and does not fold the sharp s to `ss`, which this lookup does, so + /// `Straße` and `STRASSE` are one name here and two on the service (measured + /// 2026-10-27 with `iam simulate-custom-policy`). The engine + /// stores every spelling the request names as its own tag, so a request + /// carrying `Env=prod` and `env=dev` stores both. When two or more stored + /// spellings fold to the policy's name, the key has one value only if they + /// all carry the same value; otherwise it is ambiguous, and the caller + /// fails the statement closed rather than pick one spelling and let the + /// other value through. IAM refuses a request context carrying two case + /// variants of one tag name (not measured on the service), so the service's + /// own answer for a request of this shape is not measured. + pub(super) fn request_tag_values(&self, sub_key: &str) -> RequestTagLookup<'_> { + self.request_tags + .as_ref() + .map_or(RequestTagLookup::Absent, |index| index.lookup(sub_key)) + } +} + +/// The outcome of resolving `aws:RequestTag/` on a `DynamoDB` request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RequestTagLookup<'a> { + /// The request names no tag that folds to the key. + Absent, + /// Every tag that folds to the key carries this value. + Value(&'a str), + /// Two or more tags fold to the key with differing values. + Ambiguous, +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 69dc9e0a..3b1b3030 100755 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -21,6 +21,7 @@ pub mod management; mod metrics_endpoint; pub mod rate_limit; mod request_helpers; +mod request_tags; mod response; mod serve; mod throttle_helpers; diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index e7d12abf..86350140 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -153,13 +153,29 @@ pub(crate) async fn authorize_request( } // Request-level condition keys every evaluation below carries: the - // deployment region, aws:SecureTransport, aws:SourceIp and aws:UserAgent. - let base_params = || extenddb_auth::policy::context::RequestParams { - region: Some(state.region.to_string()), - secure_transport: state.tls_enabled, - source_ip: source_ip.map(ToOwned::to_owned), - user_agent: user_agent.map(ToOwned::to_owned), - ..Default::default() + // deployment region, aws:SecureTransport, aws:SourceIp and aws:UserAgent, + // and the tag condition keys the operation populates (aws:TagKeys and + // aws:RequestTag/, empty for every operation that carries no tag set). + // The tag keys go in here, not in one operation's branch, so a tag-taking + // operation reaching any branch below carries them. + // + // A branch that authorizes once moves the tag set into its params. Only the + // three operations that carry a tag set populate it, and each authorizes + // against one resource, so the loops over several targets below clone an + // empty set. + let (request_tags, request_tag_keys) = + crate::request_tags::tag_condition_keys(operation, input); + let base_params = |request_tags: Option>, + request_tag_keys: Option>| { + extenddb_auth::policy::context::RequestParams { + region: Some(state.region.to_string()), + secure_transport: state.tls_enabled, + source_ip: source_ip.map(ToOwned::to_owned), + user_agent: user_agent.map(ToOwned::to_owned), + request_tags, + request_tag_keys, + ..Default::default() + } }; // DescribeEndpoints needs a signed request (SigV4 verification already ran @@ -228,7 +244,7 @@ pub(crate) async fn authorize_request( "GetRecords", &resource_arn, false, - base_params(), + base_params(request_tags, request_tag_keys), ) .await?; return Ok(authorized_key_info); @@ -295,7 +311,7 @@ pub(crate) async fn authorize_request( operation, &resource_arn, false, - base_params(), + base_params(request_tags, request_tag_keys), ) .await?; } else { @@ -308,7 +324,7 @@ pub(crate) async fn authorize_request( authz_resource::table_resource_arn(&target.table, &state.region, account_id); let params = extenddb_auth::policy::context::RequestParams { leading_keys: target.leading_keys, - ..base_params() + ..base_params(request_tags.clone(), request_tag_keys.clone()) }; authorization::check_authorization( state.authz_cache.as_ref(), @@ -355,7 +371,7 @@ pub(crate) async fn authorize_request( .get("ReturnConsumedCapacity") .and_then(|v| v.as_str()) .map(ToOwned::to_owned), - ..base_params() + ..base_params(request_tags, request_tag_keys) }; let (action, resource_arn) = &targets[0]; authorization::check_authorization( @@ -378,7 +394,7 @@ pub(crate) async fn authorize_request( action, resource_arn, false, - base_params(), + base_params(request_tags.clone(), request_tag_keys.clone()), ) .await?; } diff --git a/crates/server/src/request_tags.rs b/crates/server/src/request_tags.rs new file mode 100644 index 00000000..896c2929 --- /dev/null +++ b/crates/server/src/request_tags.rs @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The tag condition keys a `DynamoDB` request populates. +//! +//! Three operations carry a tag set, and the AWS service authorization +//! reference (`dynamodb` v1.5) lists the condition keys each one populates: +//! +//! | operation | `aws:RequestTag/${TagKey}` | `aws:TagKeys` | +//! |---|---|---| +//! | `TagResource` | yes, from `Tags` | yes, from the `Tags` keys | +//! | `CreateTable` | yes, from `Tags` | yes, from the `Tags` keys | +//! | `UntagResource` | no | yes, from `TagKeys` | +//! +//! `UntagResource` names keys and no values, so it populates `aws:TagKeys` +//! alone; a statement on `aws:RequestTag/` reads the key as absent for it. +//! Every other operation populates neither, including `ListTagsOfResource`, +//! which the reference gives no tag condition key. + +use std::collections::HashMap; + +use serde_json::Value; + +/// The tag condition keys `operation` populates from `input`. +/// +/// Returns `(request_tags, request_tag_keys)`: the tags for +/// `aws:RequestTag/` and the tag keys for `aws:TagKeys`. Either is `None` +/// when the operation does not populate that key, which leaves it absent in +/// policy evaluation. +/// +/// A duplicated tag key takes the LAST value in the request. `TagResource` +/// stores that value on SQLite, PostgreSQL and MongoDB: `tag_resource` walks the +/// tag list in request order and upserts each key, so the last occurrence wins +/// (Cassandra writes one `INSERT` per tag, and the order of two writes to one key +/// was not checked). Reading the request the same way keeps authorization and +/// `TagResource` on one value. `CreateTable` inserts its tags with a plain +/// `INSERT` on SQLite and PostgreSQL, so a repeated key aborts the request with +/// a server error before anything is stored, and the value authorization reads +/// is never written. The hosted service refuses a repeated key on both +/// operations with `ValidationException` (measured 2026-10-27). +/// +/// `Tags` is read as the engine reads it, as a `Vec`. serde accepts a tag +/// as an object (`{"Key": "k", "Value": "v"}`) and as a two-element array +/// (`["k", "v"]`), and the engine stores both, so both populate the keys. A +/// `Tags` value that does not parse as `Vec` (an entry without a `Key` or +/// a `Value`, a non-string member, a wrong-length array) populates neither key: +/// the engine answers it with a `SerializationException` before it writes +/// anything. +pub(crate) fn tag_condition_keys( + operation: &str, + input: &Value, +) -> (Option>, Option>) { + match operation { + "TagResource" | "CreateTable" => { + let Some(raw) = input.get("Tags") else { + return (None, None); + }; + // The type the engine deserializes `Tags` into, so both readers + // accept the same shapes. Reading through the reference leaves the + // array in `input` uncopied. + let Ok(tags) = as serde::Deserialize>::deserialize(raw) + else { + return (None, None); + }; + let mut values: HashMap = HashMap::with_capacity(tags.len()); + let mut keys: Vec = Vec::with_capacity(tags.len()); + for tag in tags { + keys.push(tag.key.clone()); + values.insert(tag.key, tag.value); + } + (Some(values), Some(keys)) + } + "UntagResource" => { + let Some(list) = input.get("TagKeys").and_then(Value::as_array) else { + return (None, None); + }; + let keys: Vec = list + .iter() + .filter_map(|k| k.as_str().map(ToOwned::to_owned)) + .collect(); + (None, Some(keys)) + } + _ => (None, None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tag_resource_populates_both_keys() { + let (tags, keys) = tag_condition_keys( + "TagResource", + &json!({"ResourceArn": "arn", "Tags": [{"Key": "env", "Value": "prod"}]}), + ); + assert_eq!( + tags.expect("TagResource carries tag values").get("env"), + Some(&"prod".to_owned()) + ); + assert_eq!(keys.expect("TagResource names tag keys"), vec!["env"]); + } + + #[test] + fn create_table_populates_both_keys() { + let (tags, keys) = tag_condition_keys( + "CreateTable", + &json!({"TableName": "t", "Tags": [{"Key": "env", "Value": "prod"}]}), + ); + assert_eq!( + tags.expect("CreateTable with Tags carries tag values") + .get("env"), + Some(&"prod".to_owned()) + ); + assert_eq!(keys.expect("CreateTable with Tags names keys"), vec!["env"]); + } + + #[test] + fn create_table_without_tags_populates_neither_key() { + let out = tag_condition_keys("CreateTable", &json!({"TableName": "t"})); + assert!( + out.0.is_none() && out.1.is_none(), + "CreateTable with no Tags leaves both keys absent" + ); + } + + #[test] + fn untag_resource_populates_tag_keys_only() { + let (tags, keys) = tag_condition_keys( + "UntagResource", + &json!({"ResourceArn": "arn", "TagKeys": ["env", "owner"]}), + ); + assert!( + tags.is_none(), + "UntagResource names no tag value, so aws:RequestTag stays absent" + ); + assert_eq!( + keys.expect("UntagResource names keys"), + vec!["env", "owner"] + ); + } + + #[test] + fn list_tags_of_resource_populates_neither_key() { + // The body carries the tag fields of the operations that populate the + // keys, so an arm that read them for ListTagsOfResource would show here. + let out = tag_condition_keys( + "ListTagsOfResource", + &json!({ + "ResourceArn": "arn", + "Tags": [{"Key": "env", "Value": "prod"}], + "TagKeys": ["env"] + }), + ); + assert!(out.0.is_none() && out.1.is_none()); + } + + #[test] + fn unrelated_operation_populates_neither_key() { + let out = tag_condition_keys( + "PutItem", + &json!({"TableName": "t", "Tags": [{"Key": "env", "Value": "prod"}]}), + ); + assert!( + out.0.is_none() && out.1.is_none(), + "an operation the reference gives no tag condition key populates neither, \ + whatever the body carries" + ); + } + + #[test] + fn duplicate_tag_key_takes_the_last_value_and_keeps_both_keys() { + let (tags, keys) = tag_condition_keys( + "TagResource", + &json!({"Tags": [{"Key": "env", "Value": "dev"}, {"Key": "env", "Value": "prod"}]}), + ); + assert_eq!( + tags.expect("tags present").get("env"), + Some(&"prod".to_owned()), + "the last occurrence is the value the upsert stores" + ); + assert_eq!( + keys.expect("keys present"), + vec!["env", "env"], + "aws:TagKeys keeps the keys the request named" + ); + } + + #[test] + fn case_variant_keys_are_separate_entries() { + let (tags, keys) = tag_condition_keys( + "TagResource", + &json!({"Tags": [{"Key": "Env", "Value": "a"}, {"Key": "env", "Value": "b"}]}), + ); + let tags = tags.expect("tags present"); + assert_eq!(tags.get("Env"), Some(&"a".to_owned())); + assert_eq!(tags.get("env"), Some(&"b".to_owned())); + assert_eq!(keys.expect("keys present"), vec!["Env", "env"]); + } + + #[test] + fn empty_tag_value_is_kept() { + let (tags, _) = tag_condition_keys( + "TagResource", + &json!({"Tags": [{"Key": "env", "Value": ""}]}), + ); + assert_eq!( + tags.expect("tags present").get("env"), + Some(&String::new()), + "the empty tag value is a value, not an absent key" + ); + } + + #[test] + fn array_form_tag_populates_both_keys() { + // serde reads a two-element array as a Tag, and the engine stores it, so + // authorization has to read it too. A Deny on either key must apply. + for operation in ["TagResource", "CreateTable"] { + let (tags, keys) = tag_condition_keys(operation, &json!({"Tags": [["secret", "x"]]})); + assert_eq!( + tags.expect("an array-form tag carries a value") + .get("secret"), + Some(&"x".to_owned()), + "{operation}: aws:RequestTag/secret reads the array-form value" + ); + assert_eq!( + keys.expect("an array-form tag names a key"), + vec!["secret"], + "{operation}: aws:TagKeys lists the array-form key" + ); + } + } + + #[test] + fn array_and_object_entries_in_one_list_both_count() { + let (tags, keys) = tag_condition_keys( + "TagResource", + &json!({"Tags": [{"Key": "colour", "Value": "red"}, ["secret", "x"]]}), + ); + let tags = tags.expect("tags present"); + assert_eq!(tags.get("colour"), Some(&"red".to_owned())); + assert_eq!(tags.get("secret"), Some(&"x".to_owned())); + assert_eq!(keys.expect("keys present"), vec!["colour", "secret"]); + } + + #[test] + fn malformed_tag_entry_populates_neither_key() { + // The engine answers each of these with a SerializationException before + // it writes anything, so there is no tag for a condition to read. The + // valid entry beside the malformed one does not populate the keys either: + // the whole list is refused. + let valid = json!({"Key": "env", "Value": "prod"}); + for bad in [ + json!({"Value": "prod"}), + json!({"Key": "env"}), + json!({"Key": "env", "Value": null}), + json!({"Key": 7, "Value": "prod"}), + json!({"Key": "env", "Value": 7}), + json!(["env"]), + json!(["env", "prod", "extra"]), + json!("env"), + json!(null), + ] { + for operation in ["TagResource", "CreateTable"] { + let out = + tag_condition_keys(operation, &json!({"Tags": [valid.clone(), bad.clone()]})); + assert!( + out.0.is_none() && out.1.is_none(), + "{operation} with {bad} populated a key" + ); + } + } + } + + #[test] + fn empty_tag_list_yields_an_empty_set() { + // RequestContext::build turns an empty set into an absent key; this + // function reports what the request carried. + let (tags, keys) = tag_condition_keys("TagResource", &json!({"Tags": []})); + assert!(tags.expect("an empty object, not None").is_empty()); + assert!(keys.expect("an empty list, not None").is_empty()); + } + + #[test] + fn non_array_tags_field_populates_neither_key() { + let out = tag_condition_keys("TagResource", &json!({"Tags": "env=prod"})); + assert!(out.0.is_none() && out.1.is_none()); + } + + #[test] + fn non_string_tag_key_in_untag_resource_is_skipped() { + let (_, keys) = tag_condition_keys("UntagResource", &json!({"TagKeys": [7, "env"]})); + assert_eq!(keys.expect("keys present"), vec!["env"]); + } + + /// For a body the engine stores, authorization reads the same tags in the + /// same order, whichever shape the body uses. A `Tags` value the engine + /// refuses populates neither key. + #[test] + fn authorization_reads_the_tags_the_engine_stores() { + use extenddb_core::types::{CreateTableInput, Tag, TagResourceInput}; + + let check = + |operation: &str, body: &Value, stored: Result>, serde_json::Error>| { + let (values, keys) = tag_condition_keys(operation, body); + match stored { + Ok(Some(tags)) => { + let named: Vec = tags.iter().map(|t| t.key.clone()).collect(); + let last: HashMap = + tags.into_iter().map(|t| (t.key, t.value)).collect(); + assert_eq!(keys, Some(named), "{operation} keys for {body}"); + assert_eq!(values, Some(last), "{operation} values for {body}"); + } + Ok(None) | Err(_) => assert!( + values.is_none() && keys.is_none(), + "{operation} populated a key for {body}" + ), + } + }; + let create_table = json!({ + "TableName": "t", + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + }); + for tags in [ + json!([{"Key": "a", "Value": "1"}]), + json!([["a", "1"]]), + json!([{"Key": "a", "Value": "1"}, ["b", "2"], ["a", "3"]]), + json!([{"Key": "a", "Value": "1", "Extra": true}]), + json!([]), + json!([{"Key": "a"}]), + json!([["a"]]), + json!([["a", "1", "2"]]), + json!(["a"]), + json!("a"), + json!(null), + ] { + let body = json!({"ResourceArn": "arn", "Tags": tags}); + let stored = serde_json::from_value::(body.clone()); + check("TagResource", &body, stored.map(|input| Some(input.tags))); + + let mut body = create_table.clone(); + body["Tags"] = tags; + let stored = serde_json::from_value::(body.clone()); + check("CreateTable", &body, stored.map(|input| input.tags)); + } + } + + /// `UntagResource` names keys only. For a body the engine accepts, + /// authorization reads the same keys. For a body it refuses, which removes + /// nothing, authorization still reads the string elements, so a neighbour + /// that is not a string cannot hide a key from a Deny. + #[test] + fn authorization_reads_the_keys_the_engine_removes() { + use extenddb_core::types::UntagResourceInput; + + for tag_keys in [ + json!(["a", "b"]), + json!([]), + json!(["a", 7]), + json!([null, "a"]), + json!([["a"]]), + json!("a"), + json!(null), + ] { + let body = json!({"ResourceArn": "arn", "TagKeys": tag_keys}); + let (_, keys) = tag_condition_keys("UntagResource", &body); + let named: Vec = + match serde_json::from_value::(body.clone()) { + Ok(input) => input.tag_keys, + Err(_) => tag_keys + .as_array() + .into_iter() + .flatten() + .filter_map(|k| k.as_str().map(ToOwned::to_owned)) + .collect(), + }; + assert_eq!( + keys.unwrap_or_default(), + named, + "UntagResource keys for {body}" + ); + } + } + + #[test] + fn aws_prefixed_and_long_tag_keys_are_carried_verbatim() { + let long = "k".repeat(128); + let (tags, keys) = tag_condition_keys( + "TagResource", + &json!({"Tags": [ + {"Key": "aws:cloudformation:stack-name", "Value": "s"}, + {"Key": long, "Value": "v"} + ]}), + ); + let tags = tags.expect("tags present"); + assert_eq!( + tags.get("aws:cloudformation:stack-name"), + Some(&"s".to_owned()) + ); + assert_eq!(tags.get(&long), Some(&"v".to_owned())); + assert_eq!(keys.expect("keys present").len(), 2); + } +} diff --git a/docs/design/05-component-auth.md b/docs/design/05-component-auth.md index fd65f9bf..2f1bcbb5 100755 --- a/docs/design/05-component-auth.md +++ b/docs/design/05-component-auth.md @@ -541,17 +541,19 @@ pub trait ConditionContext { /// Returns `true` if `key` is a multivalued (set-valued) condition key. /// Arity is a property of the key, not the number of values present. - /// `RequestContext` reports `dynamodb:Attributes` and `dynamodb:LeadingKeys` - /// as multivalued; all other keys (and all trust-policy keys) are single-valued. + /// `RequestContext` reports `dynamodb:Attributes`, `dynamodb:LeadingKeys` and + /// `aws:TagKeys` as multivalued; all other keys are single-valued, except + /// that `AssumeRoleContext` also reports `aws:TagKeys` as multivalued. fn is_multivalued_key(&self, key: &str) -> bool; } ``` **Condition-key arity (single-valued vs multivalued).** AWS classifies each condition key as single-valued or multivalued (`ArrayOfString`) in the service -authorization reference. For DynamoDB, `dynamodb:Attributes` and -`dynamodb:LeadingKeys` are multivalued; everything else extenddb resolves -(`dynamodb:Select`, `dynamodb:ReturnValues`, tag keys, etc.) is single-valued. +authorization reference. For DynamoDB, `dynamodb:Attributes`, +`dynamodb:LeadingKeys` and `aws:TagKeys` are multivalued; everything else +extenddb resolves (`dynamodb:Select`, `dynamodb:ReturnValues`, +`aws:RequestTag/`, etc.) is single-valued. A **bare** operator (one without a `ForAllValues:`/`ForAnyValue:` qualifier) applied to a multivalued key **never matches** — verified against real AWS IAM (BR-7085), and independent of how many values are present. Only the set-operator diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 2d6469b7..80c1f671 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -76,6 +76,8 @@ adaptation when switching between ExtendDB and the real service. |------|----------|------| | TagResource / UntagResource | Validates resource ARN exists, returns `ResourceNotFoundException` for missing tables | Matches DynamoDB — validates resource ARN and returns `ResourceNotFoundException` for missing tables. | | `ResourceArn` naming a backup (`table//backup/`) | Backups take no tags: `TagResource`, `UntagResource`, and `ListTagsOfResource` take table and stream ARNs, and the authorization reference lists no `aws:ResourceTag` key for the backup resource. The service's reply to a backup ARN was not measured | `TagResource`, `UntagResource`, and `ListTagsOfResource` refuse it with `ValidationException` ("One or more parameter values were invalid: Provided Arn is not a DynamoDB resource arn: "), the reply already given for an ARN whose resource is not `table/...`. So are `table//backup`, `table//backup/`, and any longer path under a backup. The account and region checks come first (another account's backup ARN stays `AccessDeniedException`, another region's stays the region `ValidationException`) and the table is not looked up. Earlier builds accepted a backup ARN and stored its tags; such tags stay in storage but nothing reads them: `ListTagsOfResource` and `UntagResource` refuse the ARN, and authorization ignores them (see "Tag conditions on backups" under Backup and Restore). `TagResource` and `UntagResource` are still authorized against `table/*`, so a policy statement on `table/*/backup/*` does not apply to them. Measured on PostgreSQL and SQLite; the check is in the engine, the same for every backend | +| Tag condition keys on tag operations | `aws:RequestTag/` and `aws:TagKeys` are populated for `TagResource` and `CreateTable`, and `aws:TagKeys` for `UntagResource` (service authorization reference, dynamodb v1.5). Whether `CreateTable` with `Tags` is also authorized as `dynamodb:TagResource` was not measured | The same three operations populate the same keys, and every other operation leaves both absent. The operator semantics (set operators, `Null`, a bare negated operator on an absent `aws:TagKeys`, tag name case) come from `iam simulate-custom-policy` runs, not from DynamoDB requests. A request that names one tag under two spellings with differing values fails closed: a `Deny` that reads the key blocks and an `Allow` that reads it does not grant; the service's answer was not measured. A condition value `${aws:TagKeys}` stays literal text, as on IAM and on a table resource policy (measured 2026-10-27). Tag names in `aws:RequestTag/` match without regard to case on both (`Étag` and `étag` match on the service), but ExtendDB's Unicode case folding also equates the sharp s with `ss` (`Straße` and `STRASSE`), which the service does not (measured on IAM and on a table resource policy, 2026-10-27). An `Allow` gated on `aws:RequestTag/STRASSE` grants a request carrying `Straße` on ExtendDB where the service would not (inferred from the same measurement; the Allow direction was not run) | +| A tag key repeated in one `TagResource` or `CreateTable` request | `ValidationException` "Duplicate Tag Keys provided as input: Duplicate Tag Key found " on both operations (measured 2026-10-27), and nothing is stored | `TagResource` accepts it and stores the last value, and authorization reads that value. `CreateTable` answers HTTP 500 `InternalServerError` on SQLite and PostgreSQL and creates nothing, because the tags are inserted with a plain `INSERT`. Measured on SQLite and PostgreSQL on 2026-10-27 | ## Secondary Indexes diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index bd74efb1..7ed2eb48 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -451,9 +451,12 @@ caller omits the key. ExtendDB resolves `aws:PrincipalArn`, `aws:PrincipalAccoun `aws:RequestedRegion`, `aws:CurrentTime`, `aws:EpochTime`, `aws:ViaAWSService`, `aws:PrincipalIsAWSService`, `aws:PrincipalTag/` and `aws:ResourceTag/` (tag keys matched case-insensitively) on a -data-plane request. Every other `aws:` global key is absent, which is what the -service does for a key it does not populate for this caller and for an unknown -key. +data-plane request. On the operations that carry a tag set it also resolves +`aws:RequestTag/` and `aws:TagKeys`: `TagResource` and `CreateTable` populate +both from `Tags`, `UntagResource` populates `aws:TagKeys` from `TagKeys`, and +every other operation leaves both absent. Every other `aws:` global key is +absent, which is what the service does for a key it does not populate for this +caller and for an unknown key. #### Global condition key resolution @@ -478,9 +481,12 @@ Resolved (evaluated against the request value): `aws:EpochTime`. - `aws:ViaAWSService` and `aws:PrincipalIsAWSService` (both false for a direct caller). -- `aws:PrincipalTag/`, `aws:RequestTag/` (trust path), - `aws:ResourceTag/`, `aws:TagKeys`, +- `aws:PrincipalTag/`, `aws:ResourceTag/`, `sts:ExternalId` (trust path). +- `aws:RequestTag/` and `aws:TagKeys`: from the session tags on the trust + path, and from the request's tags on a DynamoDB request that carries a tag set + (`TagResource` and `CreateTable` populate both keys, `UntagResource` populates + `aws:TagKeys`). Absent on every other operation. - Role-session only (absent for an IAM user, matching the service): `aws:TokenIssueTime` (the session issue time) and `aws:MultiFactorAuthPresent` (`false` for a session assumed without MFA). diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 23c1cb6f..9e3a131b 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -635,6 +635,24 @@ To get the intended behaviour, write the condition on `aws:ResourceTag/`. --- +### v0.1.15: IAM tag condition keys apply to TagResource, UntagResource and CreateTable + +**What changed.** `aws:TagKeys` and `aws:RequestTag/` now resolve on the three operations that carry a tag set, as the AWS service authorization reference lists them. `TagResource` and `CreateTable` populate both keys from `Tags`: `aws:RequestTag/` holds the value of the tag named ``, and `aws:TagKeys` lists the tag keys in request order. `UntagResource` populates `aws:TagKeys` from `TagKeys` and leaves `aws:RequestTag/` absent. Every other operation leaves both keys absent, `ListTagsOfResource` included, and so does a request whose tag list is empty. Before this release both keys were absent on every data-plane request, so a statement that named either key did not depend on the tags of a tag operation. + +`aws:TagKeys` is a multivalued key. A bare operator on it, one with no `ForAnyValue:` or `ForAllValues:` prefix, never matches while the key is present, as for `dynamodb:Attributes` and `dynamodb:LeadingKeys`. Tag names in `aws:RequestTag/` match without regard to case, under Unicode case folding. That folding also equates the sharp s with `ss`, which the service does not (measured 2026-10-27), so a `Deny` on `aws:RequestTag/STRASSE` applies to a tag named `Straße` on ExtendDB and not on the service, and an `Allow` gated on `aws:RequestTag/STRASSE` grants a request carrying `Straße` on ExtendDB where the service would not (inferred from the same measurement; the Allow direction was not run). A request that names one tag under two spellings that fold to one name, with differing values, fails the statement closed: a `Deny` that reads the key blocks and an `Allow` that reads it does not grant. A condition value `${aws:TagKeys}` stays literal text, because a multivalued key has no single value to put there. + +**Who is affected.** Any deployment with a policy statement that names `aws:TagKeys` or `aws:RequestTag/` in a condition: inline user, group and role policies, user and role permissions boundaries, and session policies. Role trust policies already resolved both keys from the session tags of the `AssumeRole` call. One trust-policy case changes: a condition value `${aws:TagKeys}` stays literal text there too, where it used to expand to one session tag key in hash order. A statement that was inert on tag operations now applies to `TagResource`, `UntagResource` and `CreateTable`: + +- A `Deny` written to block a tag key or tag value now blocks the requests that carry it. `ForAnyValue:StringEquals` on `aws:TagKeys` and `StringEquals` on `aws:RequestTag/` did not match before. +- An `Allow` gated on `ForAllValues:StringEquals` on `aws:TagKeys` used to grant for any tag keys, because the absent key satisfied it. It now grants only when every tag key in the request is in the list. +- `Null` with value `true` on `aws:TagKeys` matched every request before. It no longer matches a request that carries tag keys, and `Null` with value `false` now matches it. +- A bare negated operator on `aws:TagKeys` (`StringNotEquals`, `StringNotLike`) matched on the absent key before. It no longer matches a request that carries tag keys. It still matches every operation that leaves the key absent, as IAM does (measured with `iam simulate-custom-policy` on 2026-10-24), so an `Allow` gated on it stops granting the tag requests and keeps granting the other operations. +- A `Deny` gated on a bare negated operator on `aws:RequestTag/` (`StringNotEquals`) matched on the absent key before, so it blocked every request its `Action` covered. It now stops blocking a `TagResource` or `CreateTable` request whose tag carries an allowed value, and still blocks a request that omits the tag. + +No catalog migration is required. Before upgrading a deployment that restricts tagging, check the policies of each account for the statements above. + +--- + ## License Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. diff --git a/tests/raw_signed_request.py b/tests/raw_signed_request.py new file mode 100644 index 00000000..2fe02dfc --- /dev/null +++ b/tests/raw_signed_request.py @@ -0,0 +1,48 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Send a SigV4-signed DynamoDB request whose body no client library builds. + +boto3 validates and reshapes the parameters before it signs, so it cannot send +a tag written as ``["k", "v"]``. ``signed_post`` signs the bytes it is given with +botocore's signer and posts them unchanged, with the credentials of the caller +rather than the ones in the environment. +""" + +from __future__ import annotations + +import json +from typing import Any + +import requests +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.credentials import Credentials + + +def signed_post( + endpoint: str, + access_key: str, + secret_key: str, + region: str, + operation: str, + body: Any, + session_token: str | None = None, +) -> requests.Response: + """POST ``body`` as JSON to ``operation`` under the DynamoDB JSON-1.0 protocol.""" + payload = json.dumps(body, separators=(",", ":")).encode("utf-8") + headers = { + "X-Amz-Target": f"DynamoDB_20120810.{operation}", + "Content-Type": "application/x-amz-json-1.0", + } + request = AWSRequest(method="POST", url=endpoint, data=payload, headers=headers) + credentials = Credentials(access_key, secret_key, session_token) + SigV4Auth(credentials, "dynamodb", region).add_auth(request) + # D4: the self-signed certificate from `extenddb init` cannot be verified. + return requests.post( + endpoint, + data=payload, + headers=dict(request.headers), + verify=not endpoint.startswith("https://"), + timeout=30, + ) diff --git a/tests/test_tag_request_allow_conditions.py b/tests/test_tag_request_allow_conditions.py new file mode 100644 index 00000000..313b1631 --- /dev/null +++ b/tests/test_tag_request_allow_conditions.py @@ -0,0 +1,282 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""An Allow gated on a bare negated operator over a multivalued key. + +IAM matches a bare negated operator (`StringNotEquals`, `StringNotLike`, +`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. + +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. + +Prerequisites: + - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD +""" + +from __future__ import annotations + +import uuid +from typing import Any, Callable + +import pytest +from botocore.exceptions import ClientError + +from conftest import wait_for_active +from test_tag_request_conditions import ( + ALLOW_ALL, + _error_code, + _make_client, + _require_auth_env, + _user_with_key, +) +from management_helpers import ManagementClient + +PRINCIPAL_KINDS = ["user", "role_session", "permissions_boundary", "session_policy"] + + +def _allow_unless(cond: dict) -> dict: + """A policy whose only grant is `dynamodb:*` gated on `cond`.""" + return { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*", "Condition": cond} + ], + } + + +ALLOW_UNLESS_SSN = _allow_unless({"StringNotEquals": {"dynamodb:Attributes": "ssn"}}) +ALLOW_UNLESS_SECRET_TAG = _allow_unless({"StringNotEquals": {"aws:TagKeys": "secret"}}) + + +@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: + import os + + 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"reqtag-allow-{acct_id}") + assert resp.status_code == 201, resp.text + yield acct_id + mgmt.delete_account(acct_id) + + +def _ssn_operations(table: str) -> dict[str, Callable[[Any], Any]]: + """Each operation names the attribute `ssn`, on a key of its own.""" + key = {"pk": {"S": f"k-{uuid.uuid4().hex[:8]}"}} + return { + "put_item": lambda c: c.put_item(TableName=table, Item={**key, "ssn": {"N": "1"}}), + "update_item": lambda c: c.update_item( + TableName=table, + Key=key, + UpdateExpression="SET ssn = :v", + ExpressionAttributeValues={":v": {"N": "1"}}, + ), + "delete_item": lambda c: c.delete_item(TableName=table, Key=key), + "get_item_attributes_to_get": lambda c: c.get_item( + TableName=table, Key=key, AttributesToGet=["pk", "ssn"] + ), + "batch_write_item": lambda c: c.batch_write_item( + RequestItems={table: [{"PutRequest": {"Item": {**key, "ssn": {"N": "1"}}}}]} + ), + "transact_write_items": lambda c: c.transact_write_items( + TransactItems=[ + { + "Update": { + "TableName": table, + "Key": key, + "UpdateExpression": "SET ssn = :v", + "ExpressionAttributeValues": {":v": {"N": "1"}}, + } + } + ] + ), + } + + +class TestBareNegatedAllow: + """An Allow gated on a bare negated operator, on four policy types.""" + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.endpoint = auth_env[0] + self.mgmt = mgmt + self.account_id = account_id + self.region = region + self.admin_name = f"adm-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, self.admin_name) + resp = self.mgmt.put_user_policy(self.account_id, self.admin_name, "full", ALLOW_ALL) + assert resp.status_code == 204, resp.text + self.admin_client = _make_client(self.endpoint, ak, sk, self.region) + self.caller_arn = f"arn:aws:iam::{self.account_id}:user/{self.admin_name}" + self.table = f"rta-{uuid.uuid4().hex[:8]}" + self.admin_client.create_table( + TableName=self.table, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(self.admin_client, self.table) + self.arn = f"arn:aws:dynamodb:{self.region}:{self.account_id}:table/{self.table}" + self.roles: list[str] = [] + yield + for role in self.roles: + self.mgmt.delete_role(self.account_id, role) + try: + self.admin_client.delete_table(TableName=self.table) + except ClientError: + pass + self.mgmt.delete_user(self.account_id, self.admin_name) + + def _trust_for_caller(self) -> dict: + return { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": self.caller_arn}, + "Action": "sts:AssumeRole", + } + ], + } + + def _role(self, trust: dict) -> str: + role = f"r-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + self.roles.append(role) + return role + + def _session_client(self, role: str, **assume_kwargs) -> Any: + resp = self.mgmt.assume_role( + self.account_id, role, self.caller_arn, "sess", **assume_kwargs + ) + assert resp.status_code == 201, resp.text + creds = resp.json() + return _make_client( + self.endpoint, + creds["access_key_id"], + creds["secret_access_key"], + self.region, + session_token=creds["session_token"], + ) + + def _client_holding(self, kind: str, policy: dict) -> Any: + """A client whose effective permissions are `policy`, carried as `kind`.""" + if kind == "user": + name = f"u-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, name) + assert self.mgmt.put_user_policy(self.account_id, name, "p", policy).status_code == 204 + return _make_client(self.endpoint, ak, sk, self.region) + if kind == "permissions_boundary": + name = f"ub-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, name) + assert ( + self.mgmt.put_user_policy(self.account_id, name, "p", ALLOW_ALL).status_code + == 204 + ) + assert self.mgmt.set_user_boundary(self.account_id, name, policy).status_code == 204 + return _make_client(self.endpoint, ak, sk, self.region) + if kind == "role_session": + role = self._role(self._trust_for_caller()) + assert self.mgmt.put_role_policy(self.account_id, role, "p", policy).status_code == 204 + return self._session_client(role) + if kind == "session_policy": + role = self._role(self._trust_for_caller()) + assert ( + self.mgmt.put_role_policy(self.account_id, role, "full", ALLOW_ALL).status_code + == 204 + ) + return self._session_client(role, session_policy=policy) + raise AssertionError(kind) + + def _assert_denied(self, call: Callable[[], Any]) -> None: + with pytest.raises(ClientError) as exc: + call() + assert _error_code(exc.value) == "AccessDeniedException", exc.value.response + + # -- dynamodb:Attributes: the Allow grants none of the operations -------- + + @pytest.mark.parametrize("kind", PRINCIPAL_KINDS) + @pytest.mark.parametrize("operation", sorted(_ssn_operations("t"))) + def test_allow_unless_ssn_does_not_grant(self, kind: str, operation: str): + # IAM case a_allow_attr_putitem_ne_present_named, + # a_allow_attr_updateitem_ne_present_named and + # i_role_allow_attr_put_ne_present_named: implicitDeny. The service + # populates dynamodb:Attributes on these operations, and a bare operator + # on the present key does not match, so the Allow grants nothing. + client = self._client_holding(kind, ALLOW_UNLESS_SSN) + self._assert_denied(lambda: _ssn_operations(self.table)[operation](client)) + scan = self.admin_client.scan(TableName=self.table) + assert scan["Count"] == 0, "a refused write stores nothing" + + # -- aws:TagKeys: the Allow grants an operation with no tag set --------- + + @pytest.mark.parametrize("kind", PRINCIPAL_KINDS) + def test_allow_unless_secret_tag_grants_an_untagged_operation(self, kind: str): + # IAM case G-aws-TagKeys-bare-StringNotEquals-absent (the Deny fires) and + # IAM case a_allow_tagkeys_tagresource_ne_absent, i_role_allow_tagkeys_* + # (the Allow grants): an absent aws:TagKeys matches a bare negated + # operator. GetItem carries no tag set. + client = self._client_holding(kind, ALLOW_UNLESS_SECRET_TAG) + client.get_item(TableName=self.table, Key={"pk": {"S": "k"}}) + + @pytest.mark.parametrize("kind", PRINCIPAL_KINDS) + def test_allow_unless_secret_tag_does_not_grant_a_tagged_request(self, kind: str): + # IAM case a_allow_tagkeys_tagresource_ne_present_named and + # _present_other: implicitDeny. A bare operator on a present multivalued + # key does not match, whichever keys the request names. + client = self._client_holding(kind, ALLOW_UNLESS_SECRET_TAG) + for tags in ( + [{"Key": "secret", "Value": "x"}], + [{"Key": "colour", "Value": "red"}], + ): + self._assert_denied(lambda: client.tag_resource(ResourceArn=self.arn, Tags=tags)) + resp = self.admin_client.list_tags_of_resource(ResourceArn=self.arn) + assert resp.get("Tags", []) == [], "a refused TagResource stores nothing" + + # -- the trust path keeps its own rule ---------------------------------- + + def test_trust_policy_bare_negated_tagkeys_refuses_an_untagged_assume_role(self): + # The AssumeRole trust context does not take the absent-key answer for + # aws:TagKeys: with no session tags the Allow does not match and the + # call is refused, as before this change. + trust = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": self.caller_arn}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Condition": {"StringNotEquals": {"aws:TagKeys": "env"}}, + } + ], + } + role = self._role(trust) + resp = self.mgmt.assume_role(self.account_id, role, self.caller_arn, "sess") + assert resp.status_code == 403, resp.text + resp = self.mgmt.assume_role( + self.account_id, role, self.caller_arn, "sess", session_tags={"env": "prod"} + ) + assert resp.status_code == 403, resp.text diff --git a/tests/test_tag_request_array_conditions.py b/tests/test_tag_request_array_conditions.py new file mode 100644 index 00000000..72ef8d96 --- /dev/null +++ b/tests/test_tag_request_array_conditions.py @@ -0,0 +1,244 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""A tag sent as a two-element array reaches the tag condition keys. + +`TagResource` and `CreateTable` read each tag as an object +(`{"Key": "k", "Value": "v"}`) or as a two-element array (`["k", "v"]`) and +store both. No SDK sends the array form, so these tests sign the request body +themselves and post it unchanged (`raw_signed_request.signed_post`). A Deny on +`aws:TagKeys` or `aws:RequestTag/` has to refuse a request in the array form +as it refuses the object form, and a refused request stores nothing. + +A `Tags` value the engine cannot parse populates neither key. The engine refuses +the body before it writes anything. The last tests in each group assert that the +body is refused and nothing is stored, and leave the error name unpinned. + +Prerequisites: + - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD + +Run: + EXTENDDB_TEST_ENDPOINT=https://127.0.0.1:18443 \ + EXTENDDB_ADMIN_USER=admin \ + EXTENDDB_ADMIN_PASSWORD= \ + pytest tests/test_tag_request_array_conditions.py -v +""" + +from __future__ import annotations + +import os +import uuid +from typing import Any + +import pytest +from botocore.exceptions import ClientError + +from conftest import wait_for_active +from management_helpers import ManagementClient +from raw_signed_request import signed_post +from test_tag_request_conditions import ( + ALLOW_ALL, + _deny, + _error_code, + _make_client, + _require_auth_env, + _user_with_key, +) + +TAG_KEYS_DENY = {"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}} +REQUEST_TAG_DENY = {"StringEquals": {"aws:RequestTag/env": "prod"}} + +# (condition, Tags) pairs the Deny has to refuse. +REFUSED = [ + pytest.param(TAG_KEYS_DENY, [["secret", "x"]], id="tag-keys-array"), + pytest.param( + TAG_KEYS_DENY, + [{"Key": "colour", "Value": "red"}, ["secret", "x"]], + id="tag-keys-object-then-array", + ), + pytest.param( + TAG_KEYS_DENY, + [["secret", "x"], {"Key": "colour", "Value": "red"}], + id="tag-keys-array-then-object", + ), + pytest.param(REQUEST_TAG_DENY, [["env", "prod"]], id="request-tag-array"), +] + +# (condition, Tags, stored tags) the same Deny has to allow. +ALLOWED = [ + pytest.param(TAG_KEYS_DENY, [["colour", "red"]], {"colour": "red"}, id="tag-keys-other-key"), + pytest.param(REQUEST_TAG_DENY, [["env", "dev"]], {"env": "dev"}, id="request-tag-other-value"), +] + +# `Tags` values the engine refuses. +MALFORMED = [ + pytest.param([{"Key": "secret"}], id="no-value"), + pytest.param([{"Key": "secret", "Value": None}], id="null-value"), + pytest.param([["secret"]], id="one-element-array"), + pytest.param([{"Key": "colour", "Value": "red"}, {"Key": "secret"}], id="valid-then-no-value"), +] + + +def _create_table_body(name: str, tags: Any) -> dict: + return { + "TableName": name, + "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}], + "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}], + "BillingMode": "PAY_PER_REQUEST", + "Tags": tags, + } + + +@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"reqtag-array-{acct_id}") + assert resp.status_code == 201, resp.text + yield acct_id + mgmt.delete_account(acct_id) + + +class TestArrayTagConditions: + """A Deny on a tag condition key applies to a tag sent as an array.""" + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.endpoint = auth_env[0] + self.mgmt = mgmt + self.account_id = account_id + self.region = region + self.admin_name = f"adm-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, self.admin_name) + resp = self.mgmt.put_user_policy(self.account_id, self.admin_name, "full", ALLOW_ALL) + assert resp.status_code == 204, resp.text + self.admin_client = _make_client(self.endpoint, ak, sk, self.region) + self.table = f"rta-{uuid.uuid4().hex[:8]}" + self.admin_client.create_table( + TableName=self.table, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(self.admin_client, self.table) + self.arn = self._table_arn(self.table) + yield + try: + self.admin_client.delete_table(TableName=self.table) + except ClientError: + pass + self.mgmt.delete_user(self.account_id, self.admin_name) + + def _table_arn(self, table: str) -> str: + return f"arn:aws:dynamodb:{self.region}:{self.account_id}:table/{table}" + + def _restricted(self, policy: dict) -> tuple[str, str]: + """The access key and secret of a user holding `policy`.""" + name = f"u-{uuid.uuid4().hex[:8]}" + creds = _user_with_key(self.mgmt, self.account_id, name) + resp = self.mgmt.put_user_policy(self.account_id, name, "p", policy) + assert resp.status_code == 204, resp.text + return creds + + def _post(self, creds: tuple[str, str], operation: str, body: Any) -> tuple[int, str]: + """Sign `body` as it is, post it, and return the status and error name.""" + resp = signed_post(self.endpoint, creds[0], creds[1], self.region, operation, body) + try: + code = resp.json().get("__type", "").rsplit("#", 1)[-1] + except ValueError: + code = "" + return resp.status_code, code + + def _stored_tags(self, arn: str | None = None) -> dict[str, str]: + resp = self.admin_client.list_tags_of_resource(ResourceArn=arn or self.arn) + return {t["Key"]: t["Value"] for t in resp.get("Tags", [])} + + def _drop_table(self, name: str) -> None: + try: + wait_for_active(self.admin_client, name) + self.admin_client.delete_table(TableName=name) + except ClientError: + pass + + # -- TagResource ------------------------------------------------------ + + @pytest.mark.parametrize("condition,tags", REFUSED) + def test_tag_resource_refuses_the_array_form(self, condition, tags): + creds = self._restricted(_deny(condition)) + status, code = self._post(creds, "TagResource", {"ResourceArn": self.arn, "Tags": tags}) + assert (status, code) == (400, "AccessDeniedException") + assert self._stored_tags() == {}, "a refused TagResource stores nothing" + + @pytest.mark.parametrize("condition,tags,stored", ALLOWED) + def test_tag_resource_allows_an_array_tag_the_deny_does_not_name( + self, condition, tags, stored + ): + creds = self._restricted(_deny(condition)) + status, code = self._post(creds, "TagResource", {"ResourceArn": self.arn, "Tags": tags}) + assert status == 200, code + assert self._stored_tags() == stored + + @pytest.mark.parametrize("tags", MALFORMED) + def test_tag_resource_refuses_a_malformed_entry_and_stores_nothing(self, tags): + creds = self._restricted(_deny(TAG_KEYS_DENY)) + status, _ = self._post(creds, "TagResource", {"ResourceArn": self.arn, "Tags": tags}) + assert status == 400 + assert self._stored_tags() == {} + + # -- CreateTable ------------------------------------------------------ + + @pytest.mark.parametrize("condition,tags", REFUSED) + def test_create_table_refuses_the_array_form(self, condition, tags): + creds = self._restricted(_deny(condition, actions="dynamodb:CreateTable")) + name = f"ct-{uuid.uuid4().hex[:8]}" + try: + status, code = self._post(creds, "CreateTable", _create_table_body(name, tags)) + assert (status, code) == (400, "AccessDeniedException") + with pytest.raises(ClientError) as exc: + self.admin_client.describe_table(TableName=name) + assert _error_code(exc.value) == "ResourceNotFoundException" + finally: + self._drop_table(name) + + @pytest.mark.parametrize("condition,tags,stored", ALLOWED) + def test_create_table_allows_an_array_tag_the_deny_does_not_name( + self, condition, tags, stored + ): + creds = self._restricted(_deny(condition, actions="dynamodb:CreateTable")) + name = f"ct-{uuid.uuid4().hex[:8]}" + try: + status, code = self._post(creds, "CreateTable", _create_table_body(name, tags)) + assert status == 200, code + wait_for_active(self.admin_client, name) + assert self._stored_tags(self._table_arn(name)) == stored + finally: + self._drop_table(name) + + @pytest.mark.parametrize("tags", MALFORMED) + def test_create_table_refuses_a_malformed_entry_and_creates_nothing(self, tags): + creds = self._restricted(_deny(TAG_KEYS_DENY, actions="dynamodb:CreateTable")) + name = f"ct-{uuid.uuid4().hex[:8]}" + try: + status, _ = self._post(creds, "CreateTable", _create_table_body(name, tags)) + assert status == 400 + with pytest.raises(ClientError) as exc: + self.admin_client.describe_table(TableName=name) + assert _error_code(exc.value) == "ResourceNotFoundException" + finally: + self._drop_table(name) diff --git a/tests/test_tag_request_conditions.py b/tests/test_tag_request_conditions.py new file mode 100644 index 00000000..32636a79 --- /dev/null +++ b/tests/test_tag_request_conditions.py @@ -0,0 +1,699 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Condition keys resolved from the tags a request carries. + +`aws:TagKeys` and `aws:RequestTag/` on the operations the AWS service +authorization reference (dynamodb v1.5) lists them on: `TagResource` and +`CreateTable` populate both, `UntagResource` populates `aws:TagKeys`, and every +other operation populates neither. + +The operator semantics come from `iam simulate-custom-policy` runs on +2026-10-24: which operator forms a Deny fires under, which context values match, +and what happens when the key is absent. Three tests encode ExtendDB's own +behaviour where no IAM measurement exists. A tag key repeated in one request +reads its last value, which TagResource stores +(`test_request_tag_duplicate_key_reads_its_last_value`). A request that names one +tag under two spellings with differing values fails closed +(`test_request_tag_case_variant_keys_with_differing_values_are_refused` and +`test_ambiguous_request_tag_as_a_policy_variable_is_refused`), because IAM +refuses a simulated context of that shape (not measured on the service). + +Prerequisites: + - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD + +Run: + EXTENDDB_TEST_ENDPOINT=http://localhost:18646 \ + EXTENDDB_ADMIN_USER=admin \ + EXTENDDB_ADMIN_PASSWORD= \ + pytest tests/test_tag_request_conditions.py -v +""" + +from __future__ import annotations + +import os +import uuid +from typing import Any + +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 + +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: these tests require EXTENDDB_TEST_ENDPOINT, " + "EXTENDDB_ADMIN_USER and EXTENDDB_ADMIN_PASSWORD, which " + "devtools/run-tests sets." + ) + return endpoint, admin_user, admin_pass + + +def _make_client( + endpoint_url: str, + access_key: str, + secret_key: str, + region: str, + session_token: str | None = None, +) -> 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}), + ) + if session_token: + kwargs["aws_session_token"] = session_token + if endpoint_url.startswith("https://"): + kwargs["verify"] = False + return boto3.client(**kwargs) + + +def _deny(cond: dict, actions: Any = "dynamodb:TagResource") -> dict: + return { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}, + { + "Sid": "DenyByRequestTags", + "Effect": "Deny", + "Action": actions, + "Resource": "*", + "Condition": cond, + }, + ], + } + + +def _error_code(exc: ClientError) -> str: + return exc.response["Error"]["Code"] + + +@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"reqtag-{acct_id}") + assert resp.status_code == 201, resp.text + yield acct_id + mgmt.delete_account(acct_id) + + +def _user_with_key(mgmt: ManagementClient, account_id: str, name: str) -> tuple[str, str]: + resp = mgmt.create_user(account_id, name, "TestPass123!") + assert resp.status_code == 201, resp.text + resp = mgmt.create_access_key(account_id, name) + assert resp.status_code == 201, resp.text + creds = resp.json() + return creds["access_key_id"], creds["secret_access_key"] + + +class TestTagRequestConditions: + """A Deny gated on the tags a request carries applies to the request.""" + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.endpoint = auth_env[0] + self.mgmt = mgmt + self.account_id = account_id + self.region = region + self.admin_name = f"adm-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, self.admin_name) + resp = self.mgmt.put_user_policy(self.account_id, self.admin_name, "full", ALLOW_ALL) + assert resp.status_code == 204, resp.text + self.admin_client = _make_client(self.endpoint, ak, sk, self.region) + self.table = f"rt-{uuid.uuid4().hex[:8]}" + self.admin_client.create_table( + TableName=self.table, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(self.admin_client, self.table) + self.arn = f"arn:aws:dynamodb:{self.region}:{self.account_id}:table/{self.table}" + yield + try: + self.admin_client.delete_table(TableName=self.table) + except ClientError: + pass + self.mgmt.delete_user(self.account_id, self.admin_name) + + def _restricted(self, policy: dict) -> Any: + """A user holding `policy`, with a client signed for it.""" + name = f"u-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, name) + resp = self.mgmt.put_user_policy(self.account_id, name, "p", policy) + assert resp.status_code == 204, resp.text + return _make_client(self.endpoint, ak, sk, self.region), name + + def _stored_tags(self) -> dict[str, str]: + resp = self.admin_client.list_tags_of_resource(ResourceArn=self.arn) + return {t["Key"]: t["Value"] for t in resp.get("Tags", [])} + + def _assert_denied(self, call, *args, **kwargs): + with pytest.raises(ClientError) as exc: + call(*args, **kwargs) + assert _error_code(exc.value) == "AccessDeniedException", exc.value.response + + # -- aws:TagKeys on TagResource ------------------------------------- + + def test_tagkeys_deny_refuses_the_named_key(self): + client, _ = self._restricted( + _deny({"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}) + ) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "secret", "Value": "x"}], + ) + assert "secret" not in self._stored_tags(), "a refused TagResource stores nothing" + + def test_tagkeys_deny_allows_another_key(self): + client, _ = self._restricted( + _deny({"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}) + ) + client.tag_resource(ResourceArn=self.arn, Tags=[{"Key": "colour", "Value": "red"}]) + assert self._stored_tags()["colour"] == "red" + + def test_tagkeys_deny_refuses_a_set_containing_the_named_key(self): + client, _ = self._restricted( + _deny({"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}) + ) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "colour", "Value": "red"}, {"Key": "secret", "Value": "x"}], + ) + assert self._stored_tags() == {}, "neither tag of a refused request is stored" + + def test_tagkeys_values_compare_case_sensitively(self): + # IAM case A-deny-ForAnyValue-StringEquals-ctx-Secret-upper: allowed. + client, _ = self._restricted( + _deny({"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}) + ) + client.tag_resource(ResourceArn=self.arn, Tags=[{"Key": "Secret", "Value": "x"}]) + assert "Secret" in self._stored_tags() + + def test_tagkeys_ignore_case_operator_refuses_the_case_variant(self): + # IAM case A-deny-ForAnyValue-StringEqualsIgnoreCase-ctx-Secret-upper: + # explicitDeny. + client, _ = self._restricted( + _deny({"ForAnyValue:StringEqualsIgnoreCase": {"aws:TagKeys": "secret"}}) + ) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "Secret", "Value": "x"}], + ) + + def test_tagkeys_deny_refuses_a_duplicated_named_key(self): + # IAM case A-deny-ForAnyValue-StringEquals-ctx-dup-secret: explicitDeny. + client, _ = self._restricted( + _deny({"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}) + ) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "secret", "Value": "a"}, {"Key": "secret", "Value": "b"}], + ) + + def test_tagkeys_for_all_values_allows_a_set_outside_the_list(self): + # IAM case A-deny-ForAllValues-StringEquals-ctx-both: allowed, because one + # key is outside the policy list. + client, _ = self._restricted( + _deny({"ForAllValues:StringEquals": {"aws:TagKeys": ["colour"]}}) + ) + client.tag_resource( + ResourceArn=self.arn, + Tags=[{"Key": "colour", "Value": "red"}, {"Key": "shape", "Value": "round"}], + ) + assert set(self._stored_tags()) == {"colour", "shape"} + + def test_tagkeys_for_all_values_refuses_a_set_inside_the_list(self): + client, _ = self._restricted( + _deny({"ForAllValues:StringEquals": {"aws:TagKeys": ["colour"]}}) + ) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "colour", "Value": "red"}], + ) + + def test_bare_operator_on_a_present_tagkeys_does_not_refuse(self): + # IAM case G-aws-TagKeys-bare-StringNotEquals-present and + # IAM case A-deny-plain-StringEquals-ctx-secret: both allowed. An + # unqualified operator on a present multivalued key never matches. + for cond in [ + {"StringEquals": {"aws:TagKeys": "secret"}}, + {"StringNotEquals": {"aws:TagKeys": "secret"}}, + ]: + client, _ = self._restricted(_deny(cond)) + client.tag_resource( + ResourceArn=self.arn, Tags=[{"Key": "secret", "Value": "x"}] + ) + self.admin_client.untag_resource(ResourceArn=self.arn, TagKeys=["secret"]) + + # -- aws:RequestTag/ on TagResource ----------------------------- + + def test_request_tag_deny_refuses_the_named_value(self): + client, _ = self._restricted(_deny({"StringEquals": {"aws:RequestTag/env": "prod"}})) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "env", "Value": "prod"}], + ) + assert self._stored_tags() == {} + + def test_request_tag_deny_allows_another_value(self): + client, _ = self._restricted(_deny({"StringEquals": {"aws:RequestTag/env": "prod"}})) + client.tag_resource(ResourceArn=self.arn, Tags=[{"Key": "env", "Value": "dev"}]) + assert self._stored_tags()["env"] == "dev" + + def test_request_tag_values_compare_case_sensitively(self): + # IAM case B-deny-StringEquals-ctx-Prod-upper: allowed. + client, _ = self._restricted(_deny({"StringEquals": {"aws:RequestTag/env": "prod"}})) + client.tag_resource(ResourceArn=self.arn, Tags=[{"Key": "env", "Value": "Prod"}]) + assert self._stored_tags()["env"] == "Prod" + + def test_request_tag_sub_key_matches_case_insensitively(self): + # IAM case C-subkey-policy-Env-ctx-env and C-subkey-policy-env-ctx-Env: + # both explicitDeny. The tag name in the condition key is matched + # without regard to case. + client, _ = self._restricted(_deny({"StringEquals": {"aws:RequestTag/Env": "prod"}})) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "env", "Value": "prod"}], + ) + + def test_request_tag_deny_allows_a_request_naming_another_tag(self): + # IAM case B-deny-StringEquals-ctx-absent: allowed. A request that does not + # carry the key leaves it absent, and a positive operator on an absent + # key does not match. + client, _ = self._restricted(_deny({"StringEquals": {"aws:RequestTag/env": "prod"}})) + client.tag_resource(ResourceArn=self.arn, Tags=[{"Key": "owner", "Value": "prod"}]) + assert self._stored_tags()["owner"] == "prod" + + def test_request_tag_duplicate_key_reads_its_last_value(self): + # TagResource upserts in request order on SQLite, PostgreSQL and MongoDB, + # so the last value is the one stored; authorization reads the same one. + client, _ = self._restricted(_deny({"StringEquals": {"aws:RequestTag/env": "prod"}})) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "env", "Value": "dev"}, {"Key": "env", "Value": "prod"}], + ) + + def test_request_tag_case_variant_keys_with_differing_values_are_refused(self): + # The service (measured on 2026-10-24) and the engine both store + # Env=prod and env=dev as two tags, so a request naming the tag under two + # spellings with differing values has no single value for + # aws:RequestTag/env. The key then fails closed: the Deny blocks in + # either order and nothing is stored. IAM refuses a simulated context of + # this shape (not measured on the service), so the service's own answer + # for it is unmeasured. + client, _ = self._restricted(_deny({"StringEquals": {"aws:RequestTag/env": "prod"}})) + for tags in ( + [{"Key": "Env", "Value": "prod"}, {"Key": "env", "Value": "dev"}], + [{"Key": "env", "Value": "dev"}, {"Key": "Env", "Value": "prod"}], + ): + self._assert_denied(client.tag_resource, ResourceArn=self.arn, Tags=tags) + assert self._stored_tags() == {}, "a refused request stores neither spelling" + # The same two spellings carrying one value read as that value. + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "Env", "Value": "prod"}, {"Key": "ENV", "Value": "prod"}], + ) + client.tag_resource( + ResourceArn=self.arn, + Tags=[{"Key": "Env", "Value": "dev"}, {"Key": "ENV", "Value": "dev"}], + ) + assert self._stored_tags() == {"Env": "dev", "ENV": "dev"} + + def test_request_tag_negated_deny_refuses_a_request_without_the_key(self): + # IAM case B-deny-StringNotEquals-ctx-absent: explicitDeny. The absent-key + # rule for a negated operator on a single-valued key. + client, _ = self._restricted( + _deny({"StringNotEquals": {"aws:RequestTag/env": "prod"}}) + ) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "owner", "Value": "x"}], + ) + + def test_request_tag_is_absent_on_untag_resource(self): + # UntagResource names keys and no values, so the reference gives it + # aws:TagKeys only. A Deny on aws:RequestTag/ with a positive + # operator does not apply to it. + self.admin_client.tag_resource( + ResourceArn=self.arn, Tags=[{"Key": "env", "Value": "prod"}] + ) + client, _ = self._restricted( + _deny({"StringEquals": {"aws:RequestTag/env": "prod"}}, actions="dynamodb:*") + ) + client.untag_resource(ResourceArn=self.arn, TagKeys=["env"]) + assert "env" not in self._stored_tags() + + # -- aws:TagKeys on UntagResource ------------------------------------ + + def test_untag_resource_tagkeys_deny_refuses_the_named_key(self): + self.admin_client.tag_resource( + ResourceArn=self.arn, Tags=[{"Key": "secret", "Value": "x"}] + ) + client, _ = self._restricted( + _deny( + {"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}, + actions="dynamodb:UntagResource", + ) + ) + self._assert_denied(client.untag_resource, ResourceArn=self.arn, TagKeys=["secret"]) + assert self._stored_tags()["secret"] == "x", "a refused UntagResource removes nothing" + + def test_untag_resource_tagkeys_deny_allows_another_key(self): + self.admin_client.tag_resource( + ResourceArn=self.arn, Tags=[{"Key": "colour", "Value": "red"}] + ) + client, _ = self._restricted( + _deny( + {"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}, + actions="dynamodb:UntagResource", + ) + ) + client.untag_resource(ResourceArn=self.arn, TagKeys=["colour"]) + assert "colour" not in self._stored_tags() + + # -- CreateTable ------------------------------------------------------ + + def test_create_table_tagkeys_deny_refuses_the_named_key(self): + client, _ = self._restricted( + _deny( + {"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}, + actions="dynamodb:CreateTable", + ) + ) + name = f"ct-{uuid.uuid4().hex[:8]}" + self._assert_denied( + client.create_table, + TableName=name, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + Tags=[{"Key": "secret", "Value": "x"}], + ) + with pytest.raises(ClientError) as exc: + self.admin_client.describe_table(TableName=name) + assert _error_code(exc.value) == "ResourceNotFoundException" + + def test_create_table_without_tags_is_allowed_under_the_same_deny(self): + # IAM case F-deny-tagkeys-ctx-absent-CreateTable: allowed. CreateTable with + # no Tags leaves aws:TagKeys absent. + client, _ = self._restricted( + _deny( + {"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}, + actions="dynamodb:CreateTable", + ) + ) + name = f"ct-{uuid.uuid4().hex[:8]}" + try: + client.create_table( + TableName=name, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(self.admin_client, name) + finally: + try: + self.admin_client.delete_table(TableName=name) + except ClientError: + pass + + def test_create_table_request_tag_deny_refuses_the_named_value(self): + client, _ = self._restricted( + _deny( + {"StringEquals": {"aws:RequestTag/env": "prod"}}, + actions="dynamodb:CreateTable", + ) + ) + name = f"ct-{uuid.uuid4().hex[:8]}" + self._assert_denied( + client.create_table, + TableName=name, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + Tags=[{"Key": "env", "Value": "prod"}], + ) + + # -- the keys are absent on operations that carry no tag set ---------- + + def test_tagkeys_is_absent_on_an_item_operation(self): + # IAM case G-aws-TagKeys-bare-StringNotEquals-absent: explicitDeny, and + # A-deny-ForAnyValue-StringEquals-ctx-absent: allowed. GetItem carries + # no tag set, so aws:TagKeys is absent for it. + allow_client, _ = self._restricted( + _deny( + {"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}, + actions="dynamodb:GetItem", + ) + ) + allow_client.get_item(TableName=self.table, Key={"pk": {"S": "k"}}) + + deny_client, _ = self._restricted( + _deny({"Null": {"aws:TagKeys": "true"}}, actions="dynamodb:GetItem") + ) + self._assert_denied( + deny_client.get_item, TableName=self.table, Key={"pk": {"S": "k"}} + ) + + def test_bare_negated_tagkeys_deny_refuses_an_operation_with_no_tags(self): + # IAM case G-aws-TagKeys-bare-StringNotEquals-absent: explicitDeny. + client, _ = self._restricted( + _deny({"StringNotEquals": {"aws:TagKeys": "secret"}}, actions="dynamodb:GetItem") + ) + self._assert_denied(client.get_item, TableName=self.table, Key={"pk": {"S": "k"}}) + + def test_for_all_values_tagkeys_deny_refuses_an_operation_with_no_tags(self): + # IAM case A-deny-ForAllValues-StringEquals-ctx-absent: explicitDeny. + # ForAllValues on an absent key holds vacuously. + client, _ = self._restricted( + _deny( + {"ForAllValues:StringEquals": {"aws:TagKeys": "secret"}}, + actions="dynamodb:GetItem", + ) + ) + self._assert_denied(client.get_item, TableName=self.table, Key={"pk": {"S": "k"}}) + + def test_null_false_tagkeys_deny_refuses_a_tagged_request(self): + # IAM case A-deny-Null-false-ctx-secret: explicitDeny. + client, _ = self._restricted(_deny({"Null": {"aws:TagKeys": "false"}})) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "colour", "Value": "red"}], + ) + + # -- the policy types that carry the condition ------------------------ + + def test_permissions_boundary_deny_refuses_the_named_key(self): + # IAM case E-boundary-deny-tagkeys-ctx-secret: explicitDeny. + name = f"ub-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, name) + resp = self.mgmt.put_user_policy(self.account_id, name, "p", ALLOW_ALL) + assert resp.status_code == 204, resp.text + resp = self.mgmt.set_user_boundary( + self.account_id, + name, + _deny({"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}), + ) + assert resp.status_code == 204, resp.text + client = _make_client(self.endpoint, ak, sk, self.region) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "secret", "Value": "x"}], + ) + client.tag_resource(ResourceArn=self.arn, Tags=[{"Key": "colour", "Value": "red"}]) + + def test_role_policy_deny_refuses_the_named_key(self): + role = f"r-{uuid.uuid4().hex[:8]}" + caller = f"arn:aws:iam::{self.account_id}:user/{self.admin_name}" + trust = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": caller}, + "Action": "sts:AssumeRole", + } + ], + } + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + try: + resp = self.mgmt.put_role_policy( + self.account_id, + role, + "p", + _deny({"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}), + ) + assert resp.status_code == 204, resp.text + resp = self.mgmt.assume_role(self.account_id, role, caller, "sess") + assert resp.status_code == 201, resp.text + creds = resp.json() + client = _make_client( + self.endpoint, + creds["access_key_id"], + creds["secret_access_key"], + self.region, + session_token=creds["session_token"], + ) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "secret", "Value": "x"}], + ) + client.tag_resource( + ResourceArn=self.arn, Tags=[{"Key": "colour", "Value": "red"}] + ) + finally: + self.mgmt.delete_role(self.account_id, role) + + def test_session_policy_deny_refuses_the_named_key(self): + role = f"r-{uuid.uuid4().hex[:8]}" + caller = f"arn:aws:iam::{self.account_id}:user/{self.admin_name}" + trust = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": caller}, + "Action": "sts:AssumeRole", + } + ], + } + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + try: + assert ( + self.mgmt.put_role_policy( + self.account_id, role, "full", ALLOW_ALL + ).status_code + == 204 + ) + resp = self.mgmt.assume_role( + self.account_id, + role, + caller, + "sess", + session_policy=_deny( + {"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}} + ), + ) + assert resp.status_code == 201, resp.text + creds = resp.json() + client = _make_client( + self.endpoint, + creds["access_key_id"], + creds["secret_access_key"], + self.region, + session_token=creds["session_token"], + ) + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "secret", "Value": "x"}], + ) + finally: + self.mgmt.delete_role(self.account_id, role) + + # -- policy variables ------------------------------------------------- + + def test_request_tag_expands_as_a_policy_variable(self): + # IAM case D-var-principaltag-eq-requesttag-match: allowed, -differ: + # implicitDeny. An Allow that holds only when the request tag equals + # another resolved key. + name = f"pv-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, name) + policy = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}, + { + "Sid": "DenyWhenOwnerIsNotTheCaller", + "Effect": "Deny", + "Action": "dynamodb:TagResource", + "Resource": "*", + "Condition": { + "StringNotEquals": {"aws:RequestTag/owner": "${aws:username}"} + }, + }, + ], + } + assert ( + self.mgmt.put_user_policy(self.account_id, name, "p", policy).status_code == 204 + ) + client = _make_client(self.endpoint, ak, sk, self.region) + client.tag_resource(ResourceArn=self.arn, Tags=[{"Key": "owner", "Value": name}]) + assert self._stored_tags()["owner"] == name + self._assert_denied( + client.tag_resource, + ResourceArn=self.arn, + Tags=[{"Key": "owner", "Value": "someone-else"}], + ) + + def test_ambiguous_request_tag_as_a_policy_variable_is_refused(self): + # IAM case V (2026-10-24): a Deny `StringEquals aws:ResourceTag/env + # ${aws:RequestTag/env}` refuses env=prod on a table tagged env=prod and + # does not apply when the request carries no env tag. A request naming + # the tag under two spellings with differing values has no value to + # expand the variable to, so the statement fails closed as it does when + # the key is the condition key: refused in either order, nothing stored. + self.admin_client.tag_resource( + ResourceArn=self.arn, Tags=[{"Key": "env", "Value": "prod"}] + ) + client, _ = self._restricted( + _deny({"StringEquals": {"aws:ResourceTag/env": "${aws:RequestTag/env}"}}) + ) + self._assert_denied( + client.tag_resource, ResourceArn=self.arn, Tags=[{"Key": "env", "Value": "prod"}] + ) + for tags in ( + [{"Key": "Env", "Value": "prod"}, {"Key": "env", "Value": "dev"}], + [{"Key": "env", "Value": "dev"}, {"Key": "Env", "Value": "prod"}], + ): + self._assert_denied(client.tag_resource, ResourceArn=self.arn, Tags=tags) + assert self._stored_tags() == {"env": "prod"}, tags + client.tag_resource(ResourceArn=self.arn, Tags=[{"Key": "team", "Value": "x"}]) + assert self._stored_tags() == {"env": "prod", "team": "x"} diff --git a/tests/test_tag_request_repeated_keys.py b/tests/test_tag_request_repeated_keys.py new file mode 100644 index 00000000..741365a3 --- /dev/null +++ b/tests/test_tag_request_repeated_keys.py @@ -0,0 +1,134 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""A tag key repeated within one TagResource request reads its last value. + +ExtendDB accepts a request that names a tag key twice and stores the last value +on SQLite, PostgreSQL and MongoDB: TagResource walks the list in request order +and upserts each key. Cassandra was not run; it writes one INSERT per tag, and +the order of two writes to one key was not checked. +Authorization reads the same value, so a Deny on `aws:RequestTag/` applies +when the last value is the one it names and does not apply when only an earlier +value is. The hosted service refuses a repeated key with `ValidationException` +("Duplicate Tag Keys provided as input"), measured on 2026-10-27, and ExtendDB +does not; that difference is outside these tests, which pin that authorization +and the engine read one value. + +Prerequisites: + - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD + +Run: + EXTENDDB_TEST_ENDPOINT=https://127.0.0.1:18443 \ + EXTENDDB_ADMIN_USER=admin \ + EXTENDDB_ADMIN_PASSWORD= \ + pytest tests/test_tag_request_repeated_keys.py -v +""" + +from __future__ import annotations + +import os +import uuid +from typing import Any + +import pytest +from botocore.exceptions import ClientError + +from conftest import wait_for_active +from management_helpers import ManagementClient +from test_tag_request_conditions import ( + ALLOW_ALL, + _deny, + _error_code, + _make_client, + _require_auth_env, + _user_with_key, +) + +DENY_PROD = {"StringEquals": {"aws:RequestTag/env": "prod"}} + + +@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"reqtag-dup-{acct_id}") + assert resp.status_code == 201, resp.text + yield acct_id + mgmt.delete_account(acct_id) + + +class TestRepeatedTagKey: + """Authorization reads the value TagResource stores for a repeated key.""" + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.endpoint = auth_env[0] + self.mgmt = mgmt + self.account_id = account_id + self.region = region + self.admin_name = f"adm-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, self.admin_name) + resp = self.mgmt.put_user_policy(self.account_id, self.admin_name, "full", ALLOW_ALL) + assert resp.status_code == 204, resp.text + self.admin_client = _make_client(self.endpoint, ak, sk, self.region) + self.table = f"rtd-{uuid.uuid4().hex[:8]}" + self.admin_client.create_table( + TableName=self.table, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(self.admin_client, self.table) + self.arn = f"arn:aws:dynamodb:{self.region}:{self.account_id}:table/{self.table}" + yield + try: + self.admin_client.delete_table(TableName=self.table) + except ClientError: + pass + self.mgmt.delete_user(self.account_id, self.admin_name) + + def _restricted(self, policy: dict) -> Any: + """A client signed for a user holding `policy`.""" + name = f"u-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, name) + resp = self.mgmt.put_user_policy(self.account_id, name, "p", policy) + assert resp.status_code == 204, resp.text + return _make_client(self.endpoint, ak, sk, self.region) + + def _stored_tags(self) -> dict[str, str]: + resp = self.admin_client.list_tags_of_resource(ResourceArn=self.arn) + return {t["Key"]: t["Value"] for t in resp.get("Tags", [])} + + def test_deny_on_an_earlier_value_does_not_apply(self): + client = self._restricted(_deny(DENY_PROD)) + client.tag_resource( + ResourceArn=self.arn, + Tags=[{"Key": "env", "Value": "prod"}, {"Key": "env", "Value": "dev"}], + ) + assert self._stored_tags() == {"env": "dev"} + + def test_deny_on_the_last_value_applies(self): + client = self._restricted(_deny(DENY_PROD)) + with pytest.raises(ClientError) as exc: + client.tag_resource( + ResourceArn=self.arn, + Tags=[{"Key": "env", "Value": "dev"}, {"Key": "env", "Value": "prod"}], + ) + assert _error_code(exc.value) == "AccessDeniedException", exc.value.response + assert self._stored_tags() == {} diff --git a/tests/test_tag_request_variable_conditions.py b/tests/test_tag_request_variable_conditions.py new file mode 100644 index 00000000..f92fdf42 --- /dev/null +++ b/tests/test_tag_request_variable_conditions.py @@ -0,0 +1,167 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""A policy variable that names `aws:TagKeys` stays literal text. + +`aws:TagKeys` is the set of tag keys a request names, so it has no single value +to put where `${aws:TagKeys}` stands in a condition value. The value keeps its +text, and no tag key equals that text. + +The expected answers come from two measurements on 2026-10-27: + + - `iam simulate-custom-policy`, a Deny on `TagResource` whose condition + compares `aws:ResourceTag/team` (or `aws:RequestTag/env`) with + `${aws:TagKeys}`: not applied for any order of the request's tag keys. The + same policy with `StringNotEquals` is applied for every order, which is the + comparison against the literal text. + - a resource policy carrying the same Deny on a DynamoDB table, with + `TagResource` requests listing the tag keys in both orders: not refused. + +Prerequisites: + - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD + +Run: + EXTENDDB_TEST_ENDPOINT=https://127.0.0.1:18443 \ + EXTENDDB_ADMIN_USER=admin \ + EXTENDDB_ADMIN_PASSWORD= \ + pytest tests/test_tag_request_variable_conditions.py -v +""" + +from __future__ import annotations + +import os +import uuid +from typing import Any + +import pytest +from botocore.exceptions import ClientError + +from conftest import wait_for_active +from management_helpers import ManagementClient +from test_tag_request_conditions import ( + ALLOW_ALL, + _deny, + _error_code, + _make_client, + _require_auth_env, + _user_with_key, +) + +TAG_ORDERS = [ + pytest.param(["env", "owner"], id="env-first"), + pytest.param(["owner", "env"], id="env-second"), +] + +# A Deny on TagResource that compares a tag with the variable. The table carries +# team=env, and each request tag has its key as its value, so `aws:ResourceTag/team` +# and `aws:RequestTag/env` both read `env`, the first tag key of one order. +VARIABLE_DENIES = [ + pytest.param( + {"StringEquals": {"aws:ResourceTag/team": "${aws:TagKeys}"}}, id="resource-tag" + ), + pytest.param( + {"StringEquals": {"aws:RequestTag/env": "${aws:TagKeys}"}}, id="request-tag" + ), +] + + +@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"reqtag-var-{acct_id}") + assert resp.status_code == 201, resp.text + yield acct_id + mgmt.delete_account(acct_id) + + +def _tags(keys: list[str]) -> list[dict[str, str]]: + return [{"Key": key, "Value": key} for key in keys] + + +class TestTagKeysPolicyVariable: + """A condition value `${aws:TagKeys}` is compared as text.""" + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.endpoint = auth_env[0] + self.mgmt = mgmt + self.account_id = account_id + self.region = region + self.admin_name = f"adm-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, self.admin_name) + resp = self.mgmt.put_user_policy(self.account_id, self.admin_name, "full", ALLOW_ALL) + assert resp.status_code == 204, resp.text + self.admin_client = _make_client(self.endpoint, ak, sk, self.region) + self.table = f"rtv-{uuid.uuid4().hex[:8]}" + self.admin_client.create_table( + TableName=self.table, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + Tags=[{"Key": "team", "Value": "env"}], + ) + wait_for_active(self.admin_client, self.table) + self.arn = f"arn:aws:dynamodb:{self.region}:{self.account_id}:table/{self.table}" + yield + try: + self.admin_client.delete_table(TableName=self.table) + except ClientError: + pass + self.mgmt.delete_user(self.account_id, self.admin_name) + + def _restricted(self, policy: dict) -> Any: + """A client signed for a user holding `policy`.""" + name = f"u-{uuid.uuid4().hex[:8]}" + ak, sk = _user_with_key(self.mgmt, self.account_id, name) + resp = self.mgmt.put_user_policy(self.account_id, name, "p", policy) + assert resp.status_code == 204, resp.text + return _make_client(self.endpoint, ak, sk, self.region) + + def _stored_tags(self) -> dict[str, str]: + resp = self.admin_client.list_tags_of_resource(ResourceArn=self.arn) + return {t["Key"]: t["Value"] for t in resp.get("Tags", [])} + + @pytest.mark.parametrize("condition", VARIABLE_DENIES) + @pytest.mark.parametrize("keys", TAG_ORDERS) + def test_variable_naming_tagkeys_applies_for_no_tag_order(self, condition, keys): + client = self._restricted(_deny(condition)) + client.tag_resource(ResourceArn=self.arn, Tags=_tags(keys)) + assert self._stored_tags() == {"team": "env", **{key: key for key in keys}} + + def test_literal_value_on_the_same_tag_applies(self): + # The same Deny with the text `env` for the variable refuses the request, + # so the table's `team` tag is read and a Deny on it can fire. + client = self._restricted(_deny({"StringEquals": {"aws:ResourceTag/team": "env"}})) + with pytest.raises(ClientError) as exc: + client.tag_resource(ResourceArn=self.arn, Tags=_tags(["colour"])) + assert _error_code(exc.value) == "AccessDeniedException", exc.value.response + assert self._stored_tags() == {"team": "env"} + + @pytest.mark.parametrize("keys", [*TAG_ORDERS, pytest.param(["colour"], id="other-key")]) + def test_negated_comparison_with_the_variable_applies_for_every_tag_order(self, keys): + # `env` is not equal to the text `${aws:TagKeys}`, so the negated + # comparison is true whatever the request's tag keys are. + client = self._restricted( + _deny({"StringNotEquals": {"aws:ResourceTag/team": "${aws:TagKeys}"}}) + ) + with pytest.raises(ClientError) as exc: + client.tag_resource(ResourceArn=self.arn, Tags=_tags(keys)) + assert _error_code(exc.value) == "AccessDeniedException", exc.value.response + assert self._stored_tags() == {"team": "env"}