From 6c142e9e5a7b2fe6a556acf418442fd198922899 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Sat, 24 Oct 2026 12:27:15 +0000 Subject: [PATCH 01/16] fix(auth): resolve aws:TagKeys and aws:RequestTag on the tag operations The DynamoDB request context resolved neither aws:TagKeys nor aws:RequestTag/, so a statement gated on the tags a request carries read both as absent and never applied. A principal could set tag keys and values its identity policy, permissions boundary or session policy named. Resolve both from the request body for the operations the AWS service authorization reference lists them on: Tags on TagResource and CreateTable populate aws:RequestTag/ and aws:TagKeys; TagKeys on UntagResource populates aws:TagKeys alone. Every other operation populates neither. A duplicated tag key reads its last value, which is the value each backend's upsert stores, so authorization and the engine agree. aws:TagKeys is ArrayOfString, so it joins the multivalued set. A bare negated operator on an absent multivalued key matches on IAM, measured with simulate-custom-policy on aws:TagKeys, dynamodb:LeadingKeys and dynamodb:Attributes, so the absent-key rule no longer excludes multivalued keys. A bare operator on a present multivalued key still never matches. --- crates/auth/src/policy/condition.rs | 36 +-- crates/auth/src/policy/condition/tests.rs | 5 +- .../src/policy/condition/tests/multivalued.rs | 61 ++++- crates/auth/src/policy/context.rs | 43 ++- crates/server/src/lib.rs | 1 + crates/server/src/request_helpers.rs | 10 +- crates/server/src/request_tags.rs | 255 ++++++++++++++++++ 7 files changed, 381 insertions(+), 30 deletions(-) create mode 100644 crates/server/src/request_tags.rs diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index a48f6084..ffb13fd8 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -18,9 +18,11 @@ 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** key matches whatever the key's arity (measured on IAM). /// - `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 +111,22 @@ 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, and on IAM for an absent + // multivalued key (`aws:TagKeys`, `dynamodb:LeadingKeys`, + // `dynamodb:Attributes`): a bare `StringNotEquals`, + // `StringNotEqualsIgnoreCase` or `StringNotLike` Deny on any of the + // three fires when the key is absent, so the rule is not scoped to + // single-valued keys. A bare POSITIVE operator still never matches + // a present multivalued key (below). + None => is_negative_operator(other), + // 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), }, diff --git a/crates/auth/src/policy/condition/tests.rs b/crates/auth/src/policy/condition/tests.rs index b3c90cc9..8fa11f34 100644 --- a/crates/auth/src/policy/condition/tests.rs +++ b/crates/auth/src/policy/condition/tests.rs @@ -46,7 +46,10 @@ 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 { diff --git a/crates/auth/src/policy/condition/tests/multivalued.rs b/crates/auth/src/policy/condition/tests/multivalued.rs index 17f28a0e..0efc7be8 100644 --- a/crates/auth/src/policy/condition/tests/multivalued.rs +++ b/crates/auth/src/policy/condition/tests/multivalued.rs @@ -375,17 +375,56 @@ 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_negative_op_multivalued_absent_matches() { + // A bare negated operator on an ABSENT multivalued key matches, the same as + // on an absent single-valued key. Measured on IAM with + // simulate-custom-policy on 2026-10-24 for all three multivalued keys + // (aws:TagKeys, dynamodb:LeadingKeys, dynamodb:Attributes): a Deny carrying + // a bare StringNotEquals, StringNotEqualsIgnoreCase or StringNotLike on the + // key fires when the key is absent from the request. BR-7085 covers the + // PRESENT case, which still never matches a bare operator. let ctx = TestContext::new(); - assert!(!evaluate_condition( - &cond( + for key in ["dynamodb:Attributes", "dynamodb:LeadingKeys", "aws:TagKeys"] { + assert!( + evaluate_condition( + &cond(ConditionOperator::StringNotEquals, key, vec!["ssn"]), + &ctx + ), + "a bare StringNotEquals on absent {key} matches, as on IAM" + ); + assert!( + evaluate_condition( + &cond(ConditionOperator::StringNotLike, key, vec!["ss*"]), + &ctx + ), + "a bare StringNotLike on absent {key} matches, as on IAM" + ); + assert!( + !evaluate_condition( + &cond(ConditionOperator::StringEquals, key, vec!["ssn"]), + &ctx + ), + "a bare positive 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, - "dynamodb:Attributes", - vec!["ssn"] - ), - &ctx - )); + 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..b8fad655 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -193,6 +193,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 +257,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, 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 +305,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: params.request_tags.filter(|t| !t.is_empty()), + request_tag_keys: params.request_tag_keys.filter(|k| !k.is_empty()), } } } @@ -304,6 +323,15 @@ 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. + self.request_tags + .as_ref() + .and_then(|tags| tag_lookup(tags, sub, super::casefold::case_fold)) } 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 +377,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,12 +403,15 @@ 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" ) } 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..e7fd3a78 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -153,12 +153,20 @@ pub(crate) async fn authorize_request( } // Request-level condition keys every evaluation below carries: the - // deployment region, aws:SecureTransport, aws:SourceIp and aws:UserAgent. + // 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. + let (request_tags, request_tag_keys) = + crate::request_tags::tag_condition_keys(operation, input); 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), + request_tags: request_tags.clone(), + request_tag_keys: request_tag_keys.clone(), ..Default::default() }; diff --git a/crates/server/src/request_tags.rs b/crates/server/src/request_tags.rs new file mode 100644 index 00000000..3f039c03 --- /dev/null +++ b/crates/server/src/request_tags.rs @@ -0,0 +1,255 @@ +// 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, which is the value +/// the request would store: every backend's `tag_resource` walks the tag list +/// in request order and upserts each key, so the last occurrence wins. Reading +/// it the same way keeps authorization and the engine on one value. +/// +/// A tag entry with no string `Key` contributes nothing. The engine rejects +/// such a body with `ValidationException` after authorization, so nothing is +/// written for the entry either way. +pub(crate) fn tag_condition_keys( + operation: &str, + input: &Value, +) -> (Option>, Option>) { + match operation { + "TagResource" | "CreateTable" => { + let Some(tags) = input.get("Tags").and_then(Value::as_array) 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 { + let Some(key) = tag.get("Key").and_then(Value::as_str) else { + continue; + }; + // A missing or non-string Value is read as the empty string: + // the empty tag value is one the service accepts, and a body + // that carries a non-string there is rejected by the engine + // after authorization. + let value = tag.get("Value").and_then(Value::as_str).unwrap_or_default(); + keys.push(key.to_owned()); + values.insert(key.to_owned(), value.to_owned()); + } + (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; + + fn keys_of(out: &(Option>, Option>)) -> Vec { + out.1.clone().unwrap_or_default() + } + + #[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() { + let out = tag_condition_keys("ListTagsOfResource", &json!({"ResourceArn": "arn"})); + 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 missing_value_field_reads_as_empty() { + let (tags, keys) = tag_condition_keys("TagResource", &json!({"Tags": [{"Key": "env"}]})); + assert_eq!(tags.expect("tags present").get("env"), Some(&String::new())); + assert_eq!(keys.expect("keys present"), vec!["env"]); + } + + #[test] + fn entry_without_a_key_contributes_nothing() { + let out = tag_condition_keys( + "TagResource", + &json!({"Tags": [{"Value": "prod"}, {"Key": "env", "Value": "prod"}]}), + ); + assert_eq!(keys_of(&out), vec!["env"]); + } + + #[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"]); + } + + #[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); + } +} From 8d25d134f0a420c88fcd2d49fdee4fc3597006df Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Sat, 24 Oct 2026 12:27:15 +0000 Subject: [PATCH 02/16] test(auth): cover the tag condition keys on the three operations Thirty wire tests for aws:TagKeys and aws:RequestTag/ on TagResource, UntagResource and CreateTable, each expected answer taken from a measurement of IAM's evaluation of the two keys. Covers every operator form, the absent key, duplicate keys, key and value case variants, identity policies, permissions boundaries, role policies, session policies and a policy variable, and asserts that a refused request stores and removes nothing. Seventeen of the thirty fail before the resolution change and thirty pass after. --- tests/test_tag_request_conditions.py | 642 +++++++++++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 tests/test_tag_request_conditions.py diff --git a/tests/test_tag_request_conditions.py b/tests/test_tag_request_conditions.py new file mode 100644 index 00000000..100d50a4 --- /dev/null +++ b/tests/test_tag_request_conditions.py @@ -0,0 +1,642 @@ +# 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. + +Every expected answer here comes from a measurement of IAM's evaluation of the +two keys with `iam simulate-custom-policy` on 2026-10-24 +(`wave-2026-10-23/probes/B19-1.out` and `B19-2.out`): which operator forms a +Deny fires under, which context values match, and what happens when the key is +absent. + +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): + # B19-1 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): + # B19-1 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): + # B19-1 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): + # B19-1 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): + # B19-2 G-aws-TagKeys-bare-StringNotEquals-present and + # B19-1 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): + # B19-1 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): + # B19-1 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): + # B19-1 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): + # Each backend's tag write upserts in request order, 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_negated_deny_refuses_a_request_without_the_key(self): + # B19-1 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): + # B19-1 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): + # B19-2 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): + # B19-2 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): + # B19-1 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): + # B19-1 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): + # B19-1 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): + # B19-1 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"}], + ) From ca4754c837bd602010748b628ac0964147b859a7 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Sat, 24 Oct 2026 16:07:49 +0000 Subject: [PATCH 03/16] fix(auth): scope the absent-key negated rule to aws:TagKeys and fail closed on ambiguous request tags A bare negated operator on an absent multivalued key matches on IAM. ExtendDB now applies that answer only where its absence of the key agrees with the service's: ConditionContext gains bare_negated_matches_absent_multivalued_key, default false, and the DynamoDB request context returns true for aws:TagKeys alone. dynamodb:Attributes stays absent on writes, AttributesToGet reads and batch and transaction targets, and dynamodb:LeadingKeys on query shapes the extractor cannot pin, where the service populates both, so for those two keys an absent key keeps the no-match rule and an Allow gated on a bare negated operator over them grants nothing. The AssumeRole trust context keeps the default, so it answers as before. aws:RequestTag/ on a request that names the tag under two or more spellings that fold to one name with differing values resolves to nothing and is reported unresolvable, so a Deny on it blocks and an Allow on it does not grant. The engine stores every spelling as its own tag. Principal and resource tag lookup are unchanged. Unit tests: the replaced multivalued test now pins both halves of the rule, and RequestContext's resolution of the two keys has tests of its own. --- crates/auth/src/policy/condition.rs | 30 +++- crates/auth/src/policy/condition/tests.rs | 5 + .../src/policy/condition/tests/multivalued.rs | 77 ++++++--- crates/auth/src/policy/context.rs | 103 +++++++++++- .../src/policy/context/request_tag_tests.rs | 159 ++++++++++++++++++ 5 files changed, 330 insertions(+), 44 deletions(-) create mode 100644 crates/auth/src/policy/context/request_tag_tests.rs diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index ffb13fd8..ddf06d8e 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -22,7 +22,9 @@ use super::matcher::wildcard_match; /// 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** key matches whatever the key's arity (measured on IAM). +/// **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. @@ -115,14 +117,24 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext // 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, and on IAM for an absent - // multivalued key (`aws:TagKeys`, `dynamodb:LeadingKeys`, - // `dynamodb:Attributes`): a bare `StringNotEquals`, - // `StringNotEqualsIgnoreCase` or `StringNotLike` Deny on any of the - // three fires when the key is absent, so the rule is not scoped to - // single-valued keys. A bare POSITIVE operator still never matches - // a present multivalued key (below). - None => is_negative_operator(other), + // 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 diff --git a/crates/auth/src/policy/condition/tests.rs b/crates/auth/src/policy/condition/tests.rs index 8fa11f34..665ca0ee 100644 --- a/crates/auth/src/policy/condition/tests.rs +++ b/crates/auth/src/policy/condition/tests.rs @@ -55,6 +55,11 @@ impl ConditionContext for TestContext { 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 0efc7be8..32375c2b 100644 --- a/crates/auth/src/policy/condition/tests/multivalued.rs +++ b/crates/auth/src/policy/condition/tests/multivalued.rs @@ -375,37 +375,60 @@ fn for_any_value_string_not_equals_one_outside_set() { } #[test] -fn bare_negative_op_multivalued_absent_matches() { - // A bare negated operator on an ABSENT multivalued key matches, the same as - // on an absent single-valued key. Measured on IAM with - // simulate-custom-policy on 2026-10-24 for all three multivalued keys - // (aws:TagKeys, dynamodb:LeadingKeys, dynamodb:Attributes): a Deny carrying - // a bare StringNotEquals, StringNotEqualsIgnoreCase or StringNotLike on the - // key fires when the key is absent from the request. BR-7085 covers the - // PRESENT case, which still never matches a bare operator. +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(); - for key in ["dynamodb:Attributes", "dynamodb:LeadingKeys", "aws:TagKeys"] { - assert!( - evaluate_condition( - &cond(ConditionOperator::StringNotEquals, key, vec!["ssn"]), - &ctx - ), - "a bare StringNotEquals on absent {key} matches, as on IAM" - ); + for op in [ + ConditionOperator::StringNotEquals, + ConditionOperator::StringNotEqualsIgnoreCase, + ConditionOperator::StringNotLike, + ] { assert!( - evaluate_condition( - &cond(ConditionOperator::StringNotLike, key, vec!["ss*"]), - &ctx - ), - "a bare StringNotLike on absent {key} matches, as on IAM" + 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, key, vec!["ssn"]), - &ctx + } + assert!( + !evaluate_condition( + &cond( + ConditionOperator::StringEquals, + "aws:TagKeys", + vec!["secret"] ), - "a bare positive operator on absent {key} does not match" - ); + &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" + ); + } } } diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index b8fad655..f703dfaf 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -43,6 +43,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. @@ -312,6 +329,48 @@ impl RequestContext { request_tag_keys: params.request_tag_keys.filter(|k| !k.is_empty()), } } + + /// 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 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, so the service's own answer for a request of + /// this shape is not measured. + fn request_tag_values(&self, sub_key: &str) -> RequestTagLookup<'_> { + let Some(tags) = self.request_tags.as_ref() else { + return RequestTagLookup::Absent; + }; + let want = super::casefold::case_fold(sub_key); + let mut found: Option<&str> = None; + for (k, v) in tags { + if super::casefold::case_fold(k) != want { + continue; + } + match found { + None => found = Some(v.as_str()), + Some(seen) if seen == v.as_str() => {} + Some(_) => return RequestTagLookup::Ambiguous, + } + } + found.map_or(RequestTagLookup::Absent, RequestTagLookup::Value) + } +} + +/// The outcome of resolving `aws:RequestTag/` on a `DynamoDB` request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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, } impl ConditionContext for RequestContext { @@ -328,10 +387,14 @@ impl ConditionContext for RequestContext { // 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. - self.request_tags - .as_ref() - .and_then(|tags| tag_lookup(tags, sub, super::casefold::case_fold)) + // 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 @@ -416,12 +479,33 @@ impl ConditionContext for RequestContext { } 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`. @@ -548,6 +632,9 @@ impl ConditionContext for AssumeRoleContext { } } +#[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..be166d84 --- /dev/null +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -0,0 +1,159 @@ +// 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 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")); +} From 9e6b5a23f115f4113614b620b03252ad375b2bad Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Sat, 24 Oct 2026 16:28:51 +0000 Subject: [PATCH 04/16] test(auth): cover an Allow gated on a bare negated operator and ambiguous request tags tests/test_tag_request_allow_conditions.py: an Allow whose only grant is gated on bare StringNotEquals dynamodb:Attributes does not grant PutItem, UpdateItem, DeleteItem, GetItem with AttributesToGet, BatchWriteItem or TransactWriteItems naming the attribute; an Allow gated on bare StringNotEquals aws:TagKeys grants GetItem and does not grant TagResource with a tag set; each as a user policy, a role policy, a permissions boundary and a session policy. A trust policy whose only statement is an Allow gated that way refuses AssumeRole with and without session tags. tests/test_tag_request_conditions.py: a Deny on aws:RequestTag/env refuses a request naming Env=prod and env=dev in either order and stores nothing; two spellings carrying one value read as that value. --- tests/test_tag_request_allow_conditions.py | 283 +++++++++++++++++++++ tests/test_tag_request_conditions.py | 26 ++ 2 files changed, 309 insertions(+) create mode 100644 tests/test_tag_request_allow_conditions.py diff --git a/tests/test_tag_request_allow_conditions.py b/tests/test_tag_request_allow_conditions.py new file mode 100644 index 00000000..f279bb3e --- /dev/null +++ b/tests/test_tag_request_allow_conditions.py @@ -0,0 +1,283 @@ +# 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 (`wave-2026-10-23/probes/B19-2.out` group G, `H19-1.out` groups A, +B and I, `iam simulate-custom-policy`, 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): + # H19-1 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): + # B19-2 G-aws-TagKeys-bare-StringNotEquals-absent (the Deny fires) and + # H19-1 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): + # H19-1 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_conditions.py b/tests/test_tag_request_conditions.py index 100d50a4..51b86a49 100644 --- a/tests/test_tag_request_conditions.py +++ b/tests/test_tag_request_conditions.py @@ -330,6 +330,32 @@ def test_request_tag_duplicate_key_reads_its_last_value(self): Tags=[{"Key": "env", "Value": "dev"}, {"Key": "env", "Value": "prod"}], ) + def test_request_tag_case_variant_keys_with_differing_values_are_refused(self): + # The engine stores Env=prod and env=dev as two tags + # (probes/records/P19-2.out), 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, 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): # B19-1 B-deny-StringNotEquals-ctx-absent: explicitDeny. The absent-key # rule for a negated operator on a single-valued key. From fd3690c8bbf8c0bdde520efc6653b0733f69e9b1 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Sat, 24 Oct 2026 17:03:26 +0000 Subject: [PATCH 05/16] test(auth): cite the IAM measurements by date rather than by local file The tag condition tests named result files that exist only where the measurements were taken. The comments now give the method and date, and keep each simulated case's label next to the answer it encodes. --- tests/test_tag_request_allow_conditions.py | 19 +++++----- tests/test_tag_request_conditions.py | 41 +++++++++++----------- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/tests/test_tag_request_allow_conditions.py b/tests/test_tag_request_allow_conditions.py index f279bb3e..313b1631 100644 --- a/tests/test_tag_request_allow_conditions.py +++ b/tests/test_tag_request_allow_conditions.py @@ -5,12 +5,11 @@ IAM matches a bare negated operator (`StringNotEquals`, `StringNotLike`, `StringNotEqualsIgnoreCase`) on an absent multivalued key and never on a -present one (`wave-2026-10-23/probes/B19-2.out` group G, `H19-1.out` groups A, -B and I, `iam simulate-custom-policy`, 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 +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. @@ -222,7 +221,7 @@ def _assert_denied(self, call: Callable[[], Any]) -> None: @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): - # H19-1 a_allow_attr_putitem_ne_present_named, + # 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 @@ -236,8 +235,8 @@ def test_allow_unless_ssn_does_not_grant(self, kind: str, operation: str): @pytest.mark.parametrize("kind", PRINCIPAL_KINDS) def test_allow_unless_secret_tag_grants_an_untagged_operation(self, kind: str): - # B19-2 G-aws-TagKeys-bare-StringNotEquals-absent (the Deny fires) and - # H19-1 a_allow_tagkeys_tagresource_ne_absent, i_role_allow_tagkeys_* + # 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) @@ -245,7 +244,7 @@ def test_allow_unless_secret_tag_grants_an_untagged_operation(self, kind: str): @pytest.mark.parametrize("kind", PRINCIPAL_KINDS) def test_allow_unless_secret_tag_does_not_grant_a_tagged_request(self, kind: str): - # H19-1 a_allow_tagkeys_tagresource_ne_present_named and + # 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) diff --git a/tests/test_tag_request_conditions.py b/tests/test_tag_request_conditions.py index 51b86a49..8e3d917a 100644 --- a/tests/test_tag_request_conditions.py +++ b/tests/test_tag_request_conditions.py @@ -9,8 +9,7 @@ other operation populates neither. Every expected answer here comes from a measurement of IAM's evaluation of the -two keys with `iam simulate-custom-policy` on 2026-10-24 -(`wave-2026-10-23/probes/B19-1.out` and `B19-2.out`): which operator forms a +two keys with `iam simulate-custom-policy` on 2026-10-24: which operator forms a Deny fires under, which context values match, and what happens when the key is absent. @@ -213,7 +212,7 @@ def test_tagkeys_deny_refuses_a_set_containing_the_named_key(self): assert self._stored_tags() == {}, "neither tag of a refused request is stored" def test_tagkeys_values_compare_case_sensitively(self): - # B19-1 A-deny-ForAnyValue-StringEquals-ctx-Secret-upper: allowed. + # IAM case A-deny-ForAnyValue-StringEquals-ctx-Secret-upper: allowed. client, _ = self._restricted( _deny({"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}) ) @@ -221,7 +220,7 @@ def test_tagkeys_values_compare_case_sensitively(self): assert "Secret" in self._stored_tags() def test_tagkeys_ignore_case_operator_refuses_the_case_variant(self): - # B19-1 A-deny-ForAnyValue-StringEqualsIgnoreCase-ctx-Secret-upper: + # IAM case A-deny-ForAnyValue-StringEqualsIgnoreCase-ctx-Secret-upper: # explicitDeny. client, _ = self._restricted( _deny({"ForAnyValue:StringEqualsIgnoreCase": {"aws:TagKeys": "secret"}}) @@ -233,7 +232,7 @@ def test_tagkeys_ignore_case_operator_refuses_the_case_variant(self): ) def test_tagkeys_deny_refuses_a_duplicated_named_key(self): - # B19-1 A-deny-ForAnyValue-StringEquals-ctx-dup-secret: explicitDeny. + # IAM case A-deny-ForAnyValue-StringEquals-ctx-dup-secret: explicitDeny. client, _ = self._restricted( _deny({"ForAnyValue:StringEquals": {"aws:TagKeys": "secret"}}) ) @@ -244,7 +243,7 @@ def test_tagkeys_deny_refuses_a_duplicated_named_key(self): ) def test_tagkeys_for_all_values_allows_a_set_outside_the_list(self): - # B19-1 A-deny-ForAllValues-StringEquals-ctx-both: allowed, because one + # 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"]}}) @@ -266,8 +265,8 @@ def test_tagkeys_for_all_values_refuses_a_set_inside_the_list(self): ) def test_bare_operator_on_a_present_tagkeys_does_not_refuse(self): - # B19-2 G-aws-TagKeys-bare-StringNotEquals-present and - # B19-1 A-deny-plain-StringEquals-ctx-secret: both allowed. An + # 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"}}, @@ -296,13 +295,13 @@ def test_request_tag_deny_allows_another_value(self): assert self._stored_tags()["env"] == "dev" def test_request_tag_values_compare_case_sensitively(self): - # B19-1 B-deny-StringEquals-ctx-Prod-upper: allowed. + # 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): - # B19-1 C-subkey-policy-Env-ctx-env and C-subkey-policy-env-ctx-Env: + # 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"}})) @@ -313,7 +312,7 @@ def test_request_tag_sub_key_matches_case_insensitively(self): ) def test_request_tag_deny_allows_a_request_naming_another_tag(self): - # B19-1 B-deny-StringEquals-ctx-absent: allowed. A request that does not + # 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"}})) @@ -331,8 +330,8 @@ def test_request_tag_duplicate_key_reads_its_last_value(self): ) def test_request_tag_case_variant_keys_with_differing_values_are_refused(self): - # The engine stores Env=prod and env=dev as two tags - # (probes/records/P19-2.out), so a request naming the tag under two + # 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 @@ -357,7 +356,7 @@ def test_request_tag_case_variant_keys_with_differing_values_are_refused(self): assert self._stored_tags() == {"Env": "dev", "ENV": "dev"} def test_request_tag_negated_deny_refuses_a_request_without_the_key(self): - # B19-1 B-deny-StringNotEquals-ctx-absent: explicitDeny. The absent-key + # 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"}}) @@ -432,7 +431,7 @@ def test_create_table_tagkeys_deny_refuses_the_named_key(self): assert _error_code(exc.value) == "ResourceNotFoundException" def test_create_table_without_tags_is_allowed_under_the_same_deny(self): - # B19-1 F-deny-tagkeys-ctx-absent-CreateTable: allowed. CreateTable with + # IAM case F-deny-tagkeys-ctx-absent-CreateTable: allowed. CreateTable with # no Tags leaves aws:TagKeys absent. client, _ = self._restricted( _deny( @@ -475,7 +474,7 @@ def test_create_table_request_tag_deny_refuses_the_named_value(self): # -- the keys are absent on operations that carry no tag set ---------- def test_tagkeys_is_absent_on_an_item_operation(self): - # B19-2 G-aws-TagKeys-bare-StringNotEquals-absent: explicitDeny, and + # 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( @@ -494,14 +493,14 @@ def test_tagkeys_is_absent_on_an_item_operation(self): ) def test_bare_negated_tagkeys_deny_refuses_an_operation_with_no_tags(self): - # B19-2 G-aws-TagKeys-bare-StringNotEquals-absent: explicitDeny. + # 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): - # B19-1 A-deny-ForAllValues-StringEquals-ctx-absent: explicitDeny. + # IAM case A-deny-ForAllValues-StringEquals-ctx-absent: explicitDeny. # ForAllValues on an absent key holds vacuously. client, _ = self._restricted( _deny( @@ -512,7 +511,7 @@ def test_for_all_values_tagkeys_deny_refuses_an_operation_with_no_tags(self): self._assert_denied(client.get_item, TableName=self.table, Key={"pk": {"S": "k"}}) def test_null_false_tagkeys_deny_refuses_a_tagged_request(self): - # B19-1 A-deny-Null-false-ctx-secret: explicitDeny. + # IAM case A-deny-Null-false-ctx-secret: explicitDeny. client, _ = self._restricted(_deny({"Null": {"aws:TagKeys": "false"}})) self._assert_denied( client.tag_resource, @@ -523,7 +522,7 @@ def test_null_false_tagkeys_deny_refuses_a_tagged_request(self): # -- the policy types that carry the condition ------------------------ def test_permissions_boundary_deny_refuses_the_named_key(self): - # B19-1 E-boundary-deny-tagkeys-ctx-secret: explicitDeny. + # 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) @@ -635,7 +634,7 @@ def test_session_policy_deny_refuses_the_named_key(self): # -- policy variables ------------------------------------------------- def test_request_tag_expands_as_a_policy_variable(self): - # B19-1 D-var-principaltag-eq-requesttag-match: allowed, -differ: + # 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]}" From 50f8f8c85d7dad6b00a45334bb9d3ade36497c94 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Sat, 24 Oct 2026 17:45:15 +0000 Subject: [PATCH 06/16] fix(auth): fail closed when a policy variable names an ambiguous request tag A request that names a tag under two spellings with differing values has no single value for aws:RequestTag/, and a condition on that key fails closed. A condition value reading the same key as ${aws:RequestTag/} expanded to the literal text instead, so a Deny comparing a resource tag with the request tag let the request through and stored the tag. A condition whose values name, as a policy variable, a key the context reports unresolvable now evaluates unresolvable as well. --- crates/auth/src/policy/condition.rs | 38 +++++++++-- .../src/policy/context/request_tag_tests.rs | 67 +++++++++++++++++++ tests/test_tag_request_conditions.py | 25 +++++++ 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index ddf06d8e..ab930fdf 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -171,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) { @@ -188,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 diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index be166d84..5268a688 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -138,6 +138,73 @@ fn ambiguous_request_tag_fails_the_statement_closed() { ); } +#[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 principal_and_resource_tag_lookup_keep_the_exact_first_rule() { // Only aws:RequestTag changed. The neighbouring tag keys still resolve a diff --git a/tests/test_tag_request_conditions.py b/tests/test_tag_request_conditions.py index 8e3d917a..d7f8f681 100644 --- a/tests/test_tag_request_conditions.py +++ b/tests/test_tag_request_conditions.py @@ -665,3 +665,28 @@ def test_request_tag_expands_as_a_policy_variable(self): 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"} From b7464c39febe0ea97a05a2cd280a3cbf19cdf138 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Sat, 24 Oct 2026 17:45:15 +0000 Subject: [PATCH 07/16] refactor(auth): move the request tag lookup into its own module request_tag_values and RequestTagLookup move unchanged to policy/context/request_tags.rs. --- crates/auth/src/policy/context.rs | 46 ++--------------- .../auth/src/policy/context/request_tags.rs | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 42 deletions(-) create mode 100644 crates/auth/src/policy/context/request_tags.rs diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index f703dfaf..30eda38b 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::RequestTagLookup; + /// Trait for resolving condition keys during policy evaluation. /// /// Implemented by `RequestContext` (`DynamoDB` operations) and @@ -329,48 +331,6 @@ impl RequestContext { request_tag_keys: params.request_tag_keys.filter(|k| !k.is_empty()), } } - - /// 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 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, so the service's own answer for a request of - /// this shape is not measured. - fn request_tag_values(&self, sub_key: &str) -> RequestTagLookup<'_> { - let Some(tags) = self.request_tags.as_ref() else { - return RequestTagLookup::Absent; - }; - let want = super::casefold::case_fold(sub_key); - let mut found: Option<&str> = None; - for (k, v) in tags { - if super::casefold::case_fold(k) != want { - continue; - } - match found { - None => found = Some(v.as_str()), - Some(seen) if seen == v.as_str() => {} - Some(_) => return RequestTagLookup::Ambiguous, - } - } - found.map_or(RequestTagLookup::Absent, RequestTagLookup::Value) - } -} - -/// The outcome of resolving `aws:RequestTag/` on a `DynamoDB` request. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -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, } impl ConditionContext for RequestContext { @@ -632,6 +592,8 @@ impl ConditionContext for AssumeRoleContext { } } +mod request_tags; + #[cfg(test)] mod request_tag_tests; 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..840f7a1a --- /dev/null +++ b/crates/auth/src/policy/context/request_tags.rs @@ -0,0 +1,50 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Resolution of `aws:RequestTag/` against the tags a request carries. + +use super::RequestContext; + +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 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, 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<'_> { + let Some(tags) = self.request_tags.as_ref() else { + return RequestTagLookup::Absent; + }; + let want = crate::policy::casefold::case_fold(sub_key); + let mut found: Option<&str> = None; + for (k, v) in tags { + if crate::policy::casefold::case_fold(k) != want { + continue; + } + match found { + None => found = Some(v.as_str()), + Some(seen) if seen == v.as_str() => {} + Some(_) => return RequestTagLookup::Ambiguous, + } + } + found.map_or(RequestTagLookup::Absent, RequestTagLookup::Value) + } +} + +/// 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, +} From 7f7cfc80f2a0a916e356cab7ad32491b46aec84d Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 27 Oct 2026 11:13:05 +0000 Subject: [PATCH 08/16] fix(auth): resolve tag condition keys for array-form tags TagResource and CreateTable deserialize Tags into Vec, and serde accepts a tag as an object ({"Key": "k", "Value": "v"}) or as a two-element array (["k", "v"]). The engine stores both. tag_condition_keys read each entry with tag.get("Key") on the raw JSON, so an array entry added nothing to aws:TagKeys or aws:RequestTag/, and a Deny on either key did not apply to a request that sent its tags as arrays. tag_condition_keys now deserializes Tags into the same Vec as the engine. A Tags value that does not parse populates neither key, and the engine refuses that body before it writes anything. An entry without a Value used to answer AccessDeniedException under a Deny on its key and now answers the engine's SerializationException. The unit tests cover the array form on TagResource and CreateTable, a list that mixes both forms, and a set of bodies read through the engine's own input types, comparing the tags and the UntagResource keys. The wire test tests/test_tag_request_array_conditions.py signs the raw body itself, because no SDK sends the array form, and sends it under a Deny on aws:TagKeys and under a Deny on aws:RequestTag/env for both operations. It asserts AccessDeniedException and that nothing is stored. --- crates/server/src/request_tags.rs | 185 +++++++++++++--- tests/raw_signed_request.py | 48 ++++ tests/test_tag_request_array_conditions.py | 244 +++++++++++++++++++++ 3 files changed, 451 insertions(+), 26 deletions(-) create mode 100644 tests/raw_signed_request.py create mode 100644 tests/test_tag_request_array_conditions.py diff --git a/crates/server/src/request_tags.rs b/crates/server/src/request_tags.rs index 3f039c03..8a4cc77e 100644 --- a/crates/server/src/request_tags.rs +++ b/crates/server/src/request_tags.rs @@ -32,31 +32,33 @@ use serde_json::Value; /// in request order and upserts each key, so the last occurrence wins. Reading /// it the same way keeps authorization and the engine on one value. /// -/// A tag entry with no string `Key` contributes nothing. The engine rejects -/// such a body with `ValidationException` after authorization, so nothing is -/// written for the entry either way. +/// `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(tags) = input.get("Tags").and_then(Value::as_array) else { + let Some(raw) = input.get("Tags") else { + return (None, None); + }; + // The type the engine deserializes `Tags` into, so both readers + // accept the same shapes. + let Ok(tags) = serde_json::from_value::>(raw.clone()) + 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 { - let Some(key) = tag.get("Key").and_then(Value::as_str) else { - continue; - }; - // A missing or non-string Value is read as the empty string: - // the empty tag value is one the service accepts, and a body - // that carries a non-string there is rejected by the engine - // after authorization. - let value = tag.get("Value").and_then(Value::as_str).unwrap_or_default(); - keys.push(key.to_owned()); - values.insert(key.to_owned(), value.to_owned()); + keys.push(tag.key.clone()); + values.insert(tag.key, tag.value); } (Some(values), Some(keys)) } @@ -79,10 +81,6 @@ mod tests { use super::*; use serde_json::json; - fn keys_of(out: &(Option>, Option>)) -> Vec { - out.1.clone().unwrap_or_default() - } - #[test] fn tag_resource_populates_both_keys() { let (tags, keys) = tag_condition_keys( @@ -198,19 +196,64 @@ mod tests { } #[test] - fn missing_value_field_reads_as_empty() { - let (tags, keys) = tag_condition_keys("TagResource", &json!({"Tags": [{"Key": "env"}]})); - assert_eq!(tags.expect("tags present").get("env"), Some(&String::new())); - assert_eq!(keys.expect("keys present"), vec!["env"]); + 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 entry_without_a_key_contributes_nothing() { - let out = tag_condition_keys( + fn array_and_object_entries_in_one_list_both_count() { + let (tags, keys) = tag_condition_keys( "TagResource", - &json!({"Tags": [{"Value": "prod"}, {"Key": "env", "Value": "prod"}]}), + &json!({"Tags": [{"Key": "colour", "Value": "red"}, ["secret", "x"]]}), ); - assert_eq!(keys_of(&out), vec!["env"]); + 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] @@ -234,6 +277,96 @@ mod tests { 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); 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_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) From 662874113c2e48f5492b3e3d3499cfc80c4a10d8 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 27 Oct 2026 13:07:34 +0000 Subject: [PATCH 09/16] fix(auth): leave a policy variable naming a multivalued key literal expand_policy_variables replaced ${name} with the first value of any key the context resolved. A multivalued key (aws:TagKeys, dynamodb:LeadingKeys, dynamodb:Attributes) has no single value, and its first value is whichever one the request lists first. A Deny on aws:ResourceTag/team equal to ${aws:TagKeys} applied to a TagResource request listing [env, owner] on a table tagged team=env, and did not apply to one listing [owner, env]. A variable that names a multivalued key now stays literal text, as a variable that names an unresolvable key does. IAM leaves it as text too. Measured on 2026-10-27, two samples per row: - iam simulate-custom-policy evaluates the Deny on TagResource as not applied for [env, owner], [owner, env] and [colour], and evaluates the same Deny under StringNotEquals as applied for all three. For GetItem with ${dynamodb:LeadingKeys} or ${dynamodb:Attributes} in that position, it evaluates the Deny as not applied for both orders of two values. - A resource policy carrying the Deny, attached to a DynamoDB table, answers TagResource with 200 for [env, owner] and for [owner, env]. The same policy with the text env in place of the variable answers AccessDeniedException, and a ForAnyValue:StringEquals test of aws:TagKeys against env refuses a request that names env. The unit tests evaluate the condition for both orders through the test context and through RequestContext. The wire test tests/test_tag_request_variable_conditions.py sends TagResource with the tag keys in both orders under a Deny on aws:ResourceTag/team and under one on aws:RequestTag/env, and with three key lists under the StringNotEquals form. --- crates/auth/src/policy/condition.rs | 22 ++- .../src/policy/condition/tests/multivalued.rs | 49 +++++ .../src/policy/context/request_tag_tests.rs | 25 +++ tests/test_tag_request_variable_conditions.py | 167 ++++++++++++++++++ 4 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 tests/test_tag_request_variable_conditions.py diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index ab930fdf..a70c93a2 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -246,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(); @@ -261,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/multivalued.rs b/crates/auth/src/policy/condition/tests/multivalued.rs index 32375c2b..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] diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index 5268a688..a0106c82 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -205,6 +205,31 @@ fn ambiguous_request_tag_as_a_policy_variable_fails_the_statement_closed() { ); } +#[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:?}" + ); + } +} + #[test] fn principal_and_resource_tag_lookup_keep_the_exact_first_rule() { // Only aws:RequestTag changed. The neighbouring tag keys still resolve a 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"} From f6b5b8d89a6acd17d4c0e7daab8f73c8bdbd55d9 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 27 Oct 2026 13:30:49 +0000 Subject: [PATCH 10/16] fix(auth): fold request tag names once per request Resolving aws:RequestTag/ folded every tag name in the request and compared each with the folded policy name. A condition looks the name up several times per evaluation, and a refused request evaluates its Deny statements a second time to name the explicit deny. A TagResource body can carry as many tags as the 16 MB body limit allows, so the time to refuse a request grew with tags times statements. RequestContext::build now indexes the request's tags by folded name once, and a lookup folds the policy's name and probes the index. The index holds one value for a name, or marks the name ambiguous when the spellings that fold to it carry differing values, which is the answer the scan gave. authorize_request moves the tag set into the params of the branches that authorize once, where it cloned the set for each. tag_condition_keys reads Tags through the reference, where it copied the whole array before deserializing it. Debug build, SQLite, median of three requests that each end in AccessDeniedException, 100,000 tags (2.89 MB body). The 30 statements are Deny statements that each read an aws:RequestTag name the request does not carry: before: 0 statements 704.8 ms, 30 statements 4283.2 ms after: 0 statements 607.7 ms, 30 statements 514.3 ms A unit test looks up 1,500 absent tag names, twice each, against 20,000 tags and requires the 3,000 lookups to finish within 2 seconds. The existing tests cover the lookup semantics. --- crates/auth/src/policy/context.rs | 12 +-- .../src/policy/context/request_tag_tests.rs | 33 ++++++++ .../auth/src/policy/context/request_tags.rs | 77 +++++++++++++++---- crates/server/src/request_helpers.rs | 34 ++++---- crates/server/src/request_tags.rs | 5 +- 5 files changed, 124 insertions(+), 37 deletions(-) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 30eda38b..be764624 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; -use request_tags::RequestTagLookup; +use request_tags::{RequestTagIndex, RequestTagLookup}; /// Trait for resolving condition keys during policy evaluation. /// @@ -276,10 +276,10 @@ 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, 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 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. @@ -327,7 +327,7 @@ impl RequestContext { // 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: params.request_tags.filter(|t| !t.is_empty()), + request_tags: RequestTagIndex::from_tags(params.request_tags), request_tag_keys: params.request_tag_keys.filter(|k| !k.is_empty()), } } diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index a0106c82..2633d116 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -230,6 +230,39 @@ fn policy_variable_naming_aws_tagkeys_is_left_literal_for_every_tag_order() { } } +#[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 diff --git a/crates/auth/src/policy/context/request_tags.rs b/crates/auth/src/policy/context/request_tags.rs index 840f7a1a..e5bd76d9 100644 --- a/crates/auth/src/policy/context/request_tags.rs +++ b/crates/auth/src/policy/context/request_tags.rs @@ -3,8 +3,66 @@ //! 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. /// @@ -19,22 +77,9 @@ impl RequestContext { /// variants of one tag name, 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<'_> { - let Some(tags) = self.request_tags.as_ref() else { - return RequestTagLookup::Absent; - }; - let want = crate::policy::casefold::case_fold(sub_key); - let mut found: Option<&str> = None; - for (k, v) in tags { - if crate::policy::casefold::case_fold(k) != want { - continue; - } - match found { - None => found = Some(v.as_str()), - Some(seen) if seen == v.as_str() => {} - Some(_) => return RequestTagLookup::Ambiguous, - } - } - found.map_or(RequestTagLookup::Absent, RequestTagLookup::Value) + self.request_tags + .as_ref() + .map_or(RequestTagLookup::Absent, |index| index.lookup(sub_key)) } } diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index e7fd3a78..86350140 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -158,16 +158,24 @@ pub(crate) async fn authorize_request( // 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 = || 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_tags.clone(), - request_tag_keys: request_tag_keys.clone(), - ..Default::default() + 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 @@ -236,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); @@ -303,7 +311,7 @@ pub(crate) async fn authorize_request( operation, &resource_arn, false, - base_params(), + base_params(request_tags, request_tag_keys), ) .await?; } else { @@ -316,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(), @@ -363,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( @@ -386,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 index 8a4cc77e..eef8cfb9 100644 --- a/crates/server/src/request_tags.rs +++ b/crates/server/src/request_tags.rs @@ -49,8 +49,9 @@ pub(crate) fn tag_condition_keys( return (None, None); }; // The type the engine deserializes `Tags` into, so both readers - // accept the same shapes. - let Ok(tags) = serde_json::from_value::>(raw.clone()) + // 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); }; From 81633da434e08ef702e0ac291b9e746e3776fd04 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 27 Oct 2026 13:35:02 +0000 Subject: [PATCH 11/16] test(auth): pin the tag key cases the request tag change relies on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four statements about the tag condition keys had no test that fails without them. ListTagsOfResource populates neither key. Its unit test used a body without Tags, so an arm that read Tags for ListTagsOfResource passed. The body now carries Tags and TagKeys. An empty tag value is a present key whose value is the empty string, and a tag name folds under full Unicode case folding, so Straße and STRASSE are one name. Two unit tests in request_tag_tests.rs resolve both through RequestContext. A tag key repeated in a TagResource request reads its last value. tests/test_tag_request_repeated_keys.py sends [prod, dev] under a Deny on aws:RequestTag/env equal to prod and expects the request to be allowed with dev stored. It sends [dev, prod] and expects AccessDeniedException with nothing stored. The comment on tag_condition_keys said every backend stores the last value of a repeated key. TagResource does on SQLite, PostgreSQL and MongoDB, measured on SQLite and PostgreSQL with two samples per order. CreateTable with a repeated key answers HTTP 500 and creates nothing on SQLite and PostgreSQL, because it inserts the tags with a plain INSERT. The hosted service refuses a repeated key on TagResource and on CreateTable with ValidationException "Duplicate Tag Keys provided as input" (measured 2026-10-27, two samples per row). The comment now says so. --- .../src/policy/context/request_tag_tests.rs | 40 ++++++ crates/server/src/request_tags.rs | 25 +++- tests/test_tag_request_repeated_keys.py | 132 ++++++++++++++++++ 3 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 tests/test_tag_request_repeated_keys.py diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index 2633d116..074e3e50 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -230,6 +230,46 @@ fn policy_variable_naming_aws_tagkeys_is_left_literal_for_every_tag_order() { } } +#[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_fold_beyond_ascii() { + // Full case folding maps the sharp s to ss, so Straße and STRASSE are one + // tag name, as they are for a principal tag or a resource tag. The + // service's answer for a non-ASCII request tag name was not measured. + let ctx = ctx_with(&[("Straße", "x")], &["Straße"]); + for key in [ + "aws:RequestTag/Straße", + "aws:RequestTag/STRASSE", + "aws:RequestTag/strasse", + ] { + assert_eq!(ctx.resolve_key(key), Some(vec!["x"]), "{key}"); + } + let ctx = ctx_with(&[("STRASSE", "x")], &["STRASSE"]); + assert_eq!(ctx.resolve_key("aws:RequestTag/straße"), Some(vec!["x"])); + // Two spellings that fold to one name and carry differing values are ambiguous. + let ctx = ctx_with(&[("Straße", "x"), ("STRASSE", "y")], &["Straße", "STRASSE"]); + assert_eq!(ctx.resolve_key("aws:RequestTag/strasse"), None); + assert!(ctx.is_unresolvable_key("aws:RequestTag/strasse")); +} + #[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 diff --git a/crates/server/src/request_tags.rs b/crates/server/src/request_tags.rs index eef8cfb9..896c2929 100644 --- a/crates/server/src/request_tags.rs +++ b/crates/server/src/request_tags.rs @@ -27,10 +27,16 @@ use serde_json::Value; /// 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, which is the value -/// the request would store: every backend's `tag_resource` walks the tag list -/// in request order and upserts each key, so the last occurrence wins. Reading -/// it the same way keeps authorization and the engine on one value. +/// 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 @@ -136,7 +142,16 @@ mod tests { #[test] fn list_tags_of_resource_populates_neither_key() { - let out = tag_condition_keys("ListTagsOfResource", &json!({"ResourceArn": "arn"})); + // 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()); } diff --git a/tests/test_tag_request_repeated_keys.py b/tests/test_tag_request_repeated_keys.py new file mode 100644 index 00000000..5e84cd43 --- /dev/null +++ b/tests/test_tag_request_repeated_keys.py @@ -0,0 +1,132 @@ +# 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: +every backend's TagResource walks the list in request order and upserts each key. +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() == {} From c137988a7aa9e8b77bdfe3b62db756d76d62f594 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 27 Oct 2026 13:41:01 +0000 Subject: [PATCH 12/16] docs(auth): document the tag condition keys on tag operations aws:TagKeys and aws:RequestTag/ now resolve on TagResource, UntagResource and CreateTable. The admin guide still listed aws:RequestTag/ as a trust-path key and every other aws: key as absent on a DynamoDB request, and the upgrade manual had no entry for the change. The upgrade manual gets a behaviour-change entry. It names the operations that populate each key and lists the statement shapes whose answer changes. A Deny on either key that did not match now applies. An Allow gated on ForAllValues on aws:TagKeys no longer grants for every tag key. Null on aws:TagKeys changes answer. A bare negated operator on aws:TagKeys stops matching a request that carries tag keys, and one on aws:RequestTag/ stops matching a request whose tag has an allowed value. The admin guide lists both keys among the keys resolved on a DynamoDB request, with the operations that populate them. The differences document gets a row for the tag condition keys and a row for a tag key repeated in one request, which the service refuses with ValidationException and ExtendDB accepts on TagResource. The design note on multivalued keys now includes aws:TagKeys. The docstring of tests/test_tag_request_conditions.py said every expected answer was an IAM measurement. It now names the three tests that encode ExtendDB's own behaviour where no measurement exists. --- docs/design/05-component-auth.md | 12 +++++++----- docs/differences-from-dynamodb.md | 2 ++ docs/manuals/05-admin-guide.md | 16 +++++++++++----- docs/manuals/07-upgrade-manual.md | 18 ++++++++++++++++++ tests/test_tag_request_conditions.py | 18 ++++++++++++------ 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/design/05-component-auth.md b/docs/design/05-component-auth.md index fd65f9bf..100491d3 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 (and all trust-policy keys) + /// are single-valued. 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..83cbf7a3 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) | +| 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), 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 | ## 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..30c7e738 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.14: 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. 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 and are not affected. 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/test_tag_request_conditions.py b/tests/test_tag_request_conditions.py index d7f8f681..91bab7b9 100644 --- a/tests/test_tag_request_conditions.py +++ b/tests/test_tag_request_conditions.py @@ -8,10 +8,16 @@ `CreateTable` populate both, `UntagResource` populates `aws:TagKeys`, and every other operation populates neither. -Every expected answer here comes from a measurement of IAM's evaluation of the -two keys with `iam simulate-custom-policy` on 2026-10-24: which operator forms a -Deny fires under, which context values match, and what happens when the key is -absent. +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. Prerequisites: - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT @@ -320,8 +326,8 @@ def test_request_tag_deny_allows_a_request_naming_another_tag(self): assert self._stored_tags()["owner"] == "prod" def test_request_tag_duplicate_key_reads_its_last_value(self): - # Each backend's tag write upserts in request order, so the last value - # is the one stored; authorization reads the same one. + # 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, From bcedddc6af539f5f6a37f79224c10f64b9809545 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 27 Oct 2026 13:50:51 +0000 Subject: [PATCH 13/16] test(auth): pin the request tag name case the service matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous test commit pinned Straße and STRASSE as one request tag name, and the comment on request_tag_values said names fold as the principal-tag and resource-tag keys do. Measured on 2026-10-27, two samples per row, the service does not fold the sharp s to ss in aws:RequestTag/: - iam simulate-custom-policy applies a Deny on aws:RequestTag/STRASSE to a context tag named STRASSE and to one named strasse, and does not apply it to one named Straße. - A resource policy carrying the same Deny, attached to a DynamoDB table, answers TagResource with AccessDeniedException for STRASSE and strasse, and with 200 for Straße. - iam simulate-custom-policy applies a Deny on aws:RequestTag/Étag to a context tag named étag, so names that differ in the case of one non-ASCII letter match on the service. The lookup keeps full case folding, so it matches that case and differs from the service on the sharp s. This commit leaves the lookup as it is. The test now asserts the Étag and étag case, which the service and the lookup agree on, and no longer asserts the sharp s. The comment on request_tag_values, the upgrade manual and the differences document state the difference. Matching the service means folding one character at a time instead of using full case folding, and the policy simulator accepts only Latin-1 characters in a policy, so characters outside that range cannot be checked there. --- .../src/policy/context/request_tag_tests.rs | 37 ++++++++++--------- .../auth/src/policy/context/request_tags.rs | 6 ++- docs/differences-from-dynamodb.md | 2 +- docs/manuals/07-upgrade-manual.md | 2 +- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index 074e3e50..ac1d18d8 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -250,24 +250,25 @@ fn an_empty_request_tag_value_is_a_present_key() { } #[test] -fn request_tag_names_fold_beyond_ascii() { - // Full case folding maps the sharp s to ss, so Straße and STRASSE are one - // tag name, as they are for a principal tag or a resource tag. The - // service's answer for a non-ASCII request tag name was not measured. - let ctx = ctx_with(&[("Straße", "x")], &["Straße"]); - for key in [ - "aws:RequestTag/Straße", - "aws:RequestTag/STRASSE", - "aws:RequestTag/strasse", - ] { - assert_eq!(ctx.resolve_key(key), Some(vec!["x"]), "{key}"); - } - let ctx = ctx_with(&[("STRASSE", "x")], &["STRASSE"]); - assert_eq!(ctx.resolve_key("aws:RequestTag/straße"), Some(vec!["x"])); - // Two spellings that fold to one name and carry differing values are ambiguous. - let ctx = ctx_with(&[("Straße", "x"), ("STRASSE", "y")], &["Straße", "STRASSE"]); - assert_eq!(ctx.resolve_key("aws:RequestTag/strasse"), None); - assert!(ctx.is_unresolvable_key("aws:RequestTag/strasse")); +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 + // not asserted here. + 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] diff --git a/crates/auth/src/policy/context/request_tags.rs b/crates/auth/src/policy/context/request_tags.rs index e5bd76d9..04f6697a 100644 --- a/crates/auth/src/policy/context/request_tags.rs +++ b/crates/auth/src/policy/context/request_tags.rs @@ -67,7 +67,11 @@ 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 engine + /// 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 diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 83cbf7a3..8886bd94 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -76,7 +76,7 @@ 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) | +| 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). 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) | | 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), 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 | ## Secondary Indexes diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 30c7e738..de49d655 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -639,7 +639,7 @@ To get the intended behaviour, write the condition on `aws:ResourceTag/`. **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. 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. +`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. 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 and are not affected. A statement that was inert on tag operations now applies to `TagResource`, `UntagResource` and `CreateTable`: From 98c3cbf4df094c6cb15f608d513f3ee319584e0f Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 27 Oct 2026 15:37:36 +0000 Subject: [PATCH 14/16] docs(auth): correct release notes, dates and backend scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgrade manual filed the tag condition key entry under v0.1.14, but the v0.1.14 tag (b5bffa38, 2026-10-23) predates this change, so the entry now sits under v0.1.15. The same entry said role trust policies are not affected. The trust-policy context reports aws:TagKeys as multivalued, so a condition value ${aws:TagKeys} also stays literal text there, where it used to expand to one session tag key in hash order. The entry and the design note on ConditionContext::is_multivalued_key now describe that case. The sharp s difference was described for a Deny only. Under Unicode case folding ExtendDB reads Straße and STRASSE as one tag name, so an Allow gated on aws:RequestTag/STRASSE also grants a request carrying Straße where the service would not. That follows from the 2026-10-27 measurement; the Allow direction was not run. The upgrade manual and the differences document add the Allow direction, marked as inferred. Four statements in the differences document said (measured) or Measured on SQLite and PostgreSQL without a date. They now carry 2026-10-27. The comments in request_tags.rs and test_tag_request_conditions.py stated that IAM refuses a request context carrying two case variants of one tag name, with no source. They now mark that claim as not measured on the service. The docstring of test_tag_request_repeated_keys.py applied the upsert in request order to every backend. It now names SQLite, PostgreSQL and MongoDB and notes that Cassandra was not run. --- crates/auth/src/policy/context/request_tags.rs | 4 ++-- docs/design/05-component-auth.md | 4 ++-- docs/differences-from-dynamodb.md | 4 ++-- docs/manuals/07-upgrade-manual.md | 6 +++--- tests/test_tag_request_conditions.py | 5 +++-- tests/test_tag_request_repeated_keys.py | 6 ++++-- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/auth/src/policy/context/request_tags.rs b/crates/auth/src/policy/context/request_tags.rs index 04f6697a..598771e1 100644 --- a/crates/auth/src/policy/context/request_tags.rs +++ b/crates/auth/src/policy/context/request_tags.rs @@ -78,8 +78,8 @@ impl RequestContext { /// 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, so the service's own answer for a request of - /// this shape is not measured. + /// 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() diff --git a/docs/design/05-component-auth.md b/docs/design/05-component-auth.md index 100491d3..2f1bcbb5 100755 --- a/docs/design/05-component-auth.md +++ b/docs/design/05-component-auth.md @@ -542,8 +542,8 @@ 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`, `dynamodb:LeadingKeys` and - /// `aws:TagKeys` as multivalued; all other keys (and all trust-policy keys) - /// are single-valued. + /// `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; } ``` diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 8886bd94..80c1f671 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -76,8 +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). 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) | -| 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), 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 | +| 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/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index de49d655..9e3a131b 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -635,13 +635,13 @@ To get the intended behaviour, write the condition on `aws:ResourceTag/`. --- -### v0.1.14: IAM tag condition keys apply to TagResource, UntagResource and CreateTable +### 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. 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. +`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 and are not affected. A statement that was inert on tag operations now applies to `TagResource`, `UntagResource` and `CreateTable`: +**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. diff --git a/tests/test_tag_request_conditions.py b/tests/test_tag_request_conditions.py index 91bab7b9..32636a79 100644 --- a/tests/test_tag_request_conditions.py +++ b/tests/test_tag_request_conditions.py @@ -17,7 +17,7 @@ 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. +refuses a simulated context of that shape (not measured on the service). Prerequisites: - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT @@ -341,7 +341,8 @@ def test_request_tag_case_variant_keys_with_differing_values_are_refused(self): # 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, so the service's own answer for it is unmeasured. + # 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"}], diff --git a/tests/test_tag_request_repeated_keys.py b/tests/test_tag_request_repeated_keys.py index 5e84cd43..741365a3 100644 --- a/tests/test_tag_request_repeated_keys.py +++ b/tests/test_tag_request_repeated_keys.py @@ -3,8 +3,10 @@ """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: -every backend's TagResource walks the list in request order and upserts each key. +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` From 1c1bcaedbc9bb427f55dab7ad7de351bfb117b29 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 27 Oct 2026 15:44:34 +0000 Subject: [PATCH 15/16] test(auth): trust policies keep ${aws:TagKeys} as literal text expand_policy_variables leaves a ${name} variable as text when the context reports the key as multivalued. RequestContext has a test for ${aws:TagKeys}. AssumeRoleContext also reports aws:TagKeys as multivalued and has none, so a trust policy could have started expanding the variable to the first session tag key in hash order without a failure. The new unit test builds an AssumeRoleContext with the session tags env and owner and two principal tags, one holding each key, and compares each principal tag with ${aws:TagKeys}. An expansion would pick one session tag key, in an order the test cannot choose, and match the principal tag that holds it. The statement matches neither. It matches a caller whose principal tag is the text ${aws:TagKeys}. --- .../src/policy/context/request_tag_tests.rs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index ac1d18d8..bce020d6 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -230,6 +230,69 @@ fn policy_variable_naming_aws_tagkeys_is_left_literal_for_every_tag_order() { } } +/// 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; From 4657ebe5f53b7eb8d1720bad88f7fa84b6ead45f Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 27 Oct 2026 15:50:00 +0000 Subject: [PATCH 16/16] test(auth): pin the sharp s fold in request tag names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request tag lookup folds names with full Unicode case folding, which maps the sharp s to ss, so a tag named Straße and a policy key naming STRASSE are one name, in either direction. The service does not equate them: a Deny on aws:RequestTag/STRASSE applies to a tag named STRASSE or strasse and does not apply to one named Straße (iam simulate-custom-policy and a resource policy on a DynamoDB table, 2026-10-27, two samples each). The upgrade manual and the differences document list this as a known difference. The new unit test asserts the ExtendDB side through request_tag_values: a tag Straße=1 resolves to 1 for the policy key STRASSE, and a tag STRASSE=1 resolves to 1 for the policy key Straße. It stays until the folding rule is decided, and a change to the rule fails it. The comment on request_tag_names_match_beyond_ascii_case now points to the new test where it said the difference was not asserted. --- .../src/policy/context/request_tag_tests.rs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/auth/src/policy/context/request_tag_tests.rs b/crates/auth/src/policy/context/request_tag_tests.rs index bce020d6..38b91e40 100644 --- a/crates/auth/src/policy/context/request_tag_tests.rs +++ b/crates/auth/src/policy/context/request_tag_tests.rs @@ -322,7 +322,7 @@ fn request_tag_names_match_beyond_ascii_case() { // 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 - // not asserted here. + // 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"])); @@ -334,6 +334,29 @@ fn request_tag_names_match_beyond_ascii_case() { 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