Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6c142e9
fix(auth): resolve aws:TagKeys and aws:RequestTag on the tag operations
LeeroyHannigan Oct 24, 2026
8d25d13
test(auth): cover the tag condition keys on the three operations
LeeroyHannigan Oct 24, 2026
ca4754c
fix(auth): scope the absent-key negated rule to aws:TagKeys and fail …
LeeroyHannigan Oct 24, 2026
9e6b5a2
test(auth): cover an Allow gated on a bare negated operator and ambig…
LeeroyHannigan Oct 24, 2026
fd3690c
test(auth): cite the IAM measurements by date rather than by local file
LeeroyHannigan Oct 24, 2026
50f8f8c
fix(auth): fail closed when a policy variable names an ambiguous requ…
LeeroyHannigan Oct 24, 2026
b7464c3
refactor(auth): move the request tag lookup into its own module
LeeroyHannigan Oct 24, 2026
7f7cfc8
fix(auth): resolve tag condition keys for array-form tags
LeeroyHannigan Oct 27, 2026
6628741
fix(auth): leave a policy variable naming a multivalued key literal
LeeroyHannigan Oct 27, 2026
f6b5b8d
fix(auth): fold request tag names once per request
LeeroyHannigan Oct 27, 2026
81633da
test(auth): pin the tag key cases the request tag change relies on
LeeroyHannigan Oct 27, 2026
c137988
docs(auth): document the tag condition keys on tag operations
LeeroyHannigan Oct 27, 2026
bcedddc
test(auth): pin the request tag name case the service matches
LeeroyHannigan Oct 27, 2026
98c3cbf
docs(auth): correct release notes, dates and backend scope
LeeroyHannigan Oct 27, 2026
1c1bcae
test(auth): trust policies keep ${aws:TagKeys} as literal text
LeeroyHannigan Oct 27, 2026
4657ebe
test(auth): pin the sharp s fold in request tag names
LeeroyHannigan Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 84 additions & 24 deletions crates/auth/src/policy/condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ use super::matcher::wildcard_match;
/// - Base (bare) operators on a **single-valued** key: key must be present and
/// its value must match at least one policy value.
/// - Base (bare) operators on a **multivalued** key (`dynamodb:Attributes`,
/// `dynamodb:LeadingKeys`): never match. AWS IAM requires a
/// `ForAllValues:`/`ForAnyValue:` qualifier for multivalued keys; a bare
/// operator on such a key is a no-op regardless of value count (BR-7085).
/// `dynamodb:LeadingKeys`, `aws:TagKeys`): never match while the key is
/// present. AWS IAM requires a `ForAllValues:`/`ForAnyValue:` qualifier for
/// multivalued keys; a bare operator on a present multivalued key is a no-op
/// regardless of value count (BR-7085). A bare NEGATED operator on an
/// **absent** multivalued key matches on IAM; here it matches only when the
/// context's `bare_negated_matches_absent_multivalued_key` says so for the
/// key, which the `DynamoDB` request context does for `aws:TagKeys` alone.
/// - `IfExists`: passes if the key is absent; otherwise evaluates as the base
/// operator (and so never matches a present multivalued key).
/// - `Null`: checks key presence/absence.
Expand Down Expand Up @@ -109,18 +113,32 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext
Some(vals) => evaluate_single_value_condition(inner, &vals, &expanded_values),
},
other => match context_values {
// A negated operator on an absent single-valued key evaluates true
// in AWS IAM: the key has no value, so it cannot equal the policy
// value, so "not equals" holds. This makes a Deny gated on
// StringNotEquals apply when the caller omits the key (the
// confused-deputy guard). Measured on live IAM/STS and DynamoDB.
// Bare operators on multivalued keys
// never match, present or absent (BR-7085), so the rule is scoped
// to single-valued keys.
None => is_negative_operator(other) && !is_multivalued,
// Bare operator on a multivalued key never matches (BR-7085). This is
// fail-safe: a bare Deny becomes a no-op (matching AWS, which forces the
// author to use ForAnyValue:), and a bare Allow allowlist stops granting.
// A negated operator on an absent key evaluates true in AWS IAM:
// the key has no value, so it cannot equal the policy value, so
// "not equals" holds. This makes a Deny gated on StringNotEquals
// apply when the caller omits the key (the confused-deputy guard).
// Measured on live IAM/STS and DynamoDB for single-valued keys, and
// on IAM for the absent multivalued keys `aws:TagKeys`,
// `dynamodb:LeadingKeys` and `dynamodb:Attributes`. For a
// multivalued key the context decides whether that answer applies:
// it is right only where ExtendDB leaves the key absent on the same
// requests the service does. ExtendDB leaves `dynamodb:Attributes`
// absent on writes, on `AttributesToGet` reads and on batch and
// transaction targets, and `dynamodb:LeadingKeys` absent on query
// shapes it cannot pin to a partition value, where the service
// populates them; matching there would grant an Allow the service
// refuses, so those keys keep the no-match rule. `aws:TagKeys` is
// populated on exactly the three operations the service reference
// lists it for, so its absent-key answer is IAM's.
None => {
is_negative_operator(other)
&& (!is_multivalued
|| context.bare_negated_matches_absent_multivalued_key(&condition.key))
}
// Bare operator on a present multivalued key never matches
// (BR-7085). This is fail-safe: a bare Deny becomes a no-op
// (matching AWS, which forces the author to use ForAnyValue:), and a
// bare Allow allowlist stops granting.
Some(_) if is_multivalued => false,
Some(vals) => evaluate_single_value_condition(other, &vals, &expanded_values),
},
Expand Down Expand Up @@ -153,14 +171,15 @@ pub enum ConditionMatch {
/// Evaluate one condition three-valued.
///
/// Returns [`ConditionMatch::Unresolvable`] when the key is unresolved and the
/// context reports it as service-populated (case 3). Otherwise returns `Match`
/// or `NoMatch` from the ordinary evaluation, so cases 1 and 2 are unchanged.
/// context reports it as service-populated (case 3), and when a condition value
/// reads such a key through a `${name}` policy variable, since the variable has
/// no value to expand to. Otherwise returns `Match` or `NoMatch` from the
/// ordinary evaluation, so cases 1 and 2 are unchanged.
pub fn evaluate_condition_match(
condition: &Condition,
context: &impl ConditionContext,
) -> ConditionMatch {
if context.resolve_key(&condition.key).is_none() && context.is_unresolvable_key(&condition.key)
{
if is_unresolvable(&condition.key, context) || reads_unresolvable_variable(condition, context) {
return ConditionMatch::Unresolvable;
}
if evaluate_condition(condition, context) {
Expand All @@ -170,6 +189,35 @@ pub fn evaluate_condition_match(
}
}

/// Whether `key` is unresolved and the context reports it as one the service
/// populates.
fn is_unresolvable(key: &str, context: &impl ConditionContext) -> bool {
context.resolve_key(key).is_none() && context.is_unresolvable_key(key)
}

/// Whether a value of `condition` names, as a `${name}` policy variable, a key
/// [`is_unresolvable`] reports.
fn reads_unresolvable_variable(condition: &Condition, context: &impl ConditionContext) -> bool {
condition
.values
.iter()
.any(|value| policy_variable_names(value).any(|name| is_unresolvable(name, context)))
}

/// The names of the `${name}` policy variables in `value`, in the order
/// [`expand_policy_variables`] reads them. A `${` with no closing brace names
/// nothing, as it expands to itself.
fn policy_variable_names(value: &str) -> impl Iterator<Item = &str> {
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
Expand Down Expand Up @@ -198,8 +246,15 @@ pub fn evaluate_statement_conditions(
/// Expand IAM policy variables in a string value.
///
/// Replaces `${variable}` patterns with their resolved values from the context.
/// Supported variables: `aws:PrincipalTag/*` and any key the context can
/// resolve. Unresolvable variables are left as-is (IAM behavior).
/// Supported variables: `aws:PrincipalTag/*` and any single-valued key the
/// context can resolve. A variable that names an unresolvable key stays
/// literal (IAM behavior), and so does one that names a multivalued key
/// (`aws:TagKeys`, `dynamodb:LeadingKeys`, `dynamodb:Attributes`): a set of
/// values has no one value to expand to. IAM leaves such a variable as text
/// (measured 2026-10-27: a Deny on `aws:ResourceTag/team` equal to
/// `${aws:TagKeys}` applies to no request, whatever order the request lists
/// its tag keys in, and the same Deny under `StringNotEquals` applies to
/// every request).
fn expand_policy_variables(value: &str, context: &impl ConditionContext) -> String {
if !value.contains("${") {
return value.to_owned();
Expand All @@ -213,13 +268,18 @@ fn expand_policy_variables(value: &str, context: &impl ConditionContext) -> Stri
let after_start = &rest[start + 2..];
if let Some(end) = after_start.find('}') {
let var_name = &after_start[..end];
if let Some(vals) = context.resolve_key(var_name) {
// Use the first value for single-valued expansion.
let resolved = if context.is_multivalued_key(var_name) {
None
} else {
context.resolve_key(var_name)
};
if let Some(vals) = resolved {
// A single-valued key resolves to one value.
if let Some(v) = vals.first() {
result.push_str(v);
}
} else {
// Unresolvable — leave the variable literal.
// Unresolvable or multivalued: leave the variable literal.
result.push_str(&rest[start..start + 3 + end]);
}
rest = &after_start[end + 1..];
Expand Down
10 changes: 9 additions & 1 deletion crates/auth/src/policy/condition/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,20 @@ impl ConditionContext for TestContext {
fn is_multivalued_key(&self, key: &str) -> bool {
// Mirror the real RequestContext classification so tests exercise the
// production arity rule.
matches!(key, "dynamodb:LeadingKeys" | "dynamodb:Attributes")
matches!(
key,
"dynamodb:LeadingKeys" | "dynamodb:Attributes" | "aws:TagKeys"
)
}

fn is_unresolvable_key(&self, key: &str) -> bool {
self.unresolvable.contains(key)
}

fn bare_negated_matches_absent_multivalued_key(&self, key: &str) -> bool {
// Mirror RequestContext: only aws:TagKeys takes IAM's absent-key answer.
key == "aws:TagKeys"
}
}

fn cond(op: ConditionOperator, key: &str, values: Vec<&str>) -> Condition {
Expand Down
133 changes: 122 additions & 11 deletions crates/auth/src/policy/condition/tests/multivalued.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -375,17 +424,79 @@ fn for_any_value_string_not_equals_one_outside_set() {
}

#[test]
fn bare_negative_op_multivalued_absent_no_match() {
// BR-7085 preserved: a bare negated operator on an ABSENT multivalued key
// still never matches. The negated-absent rule is scoped to single-valued
// keys; multivalued keys require ForAllValues/ForAnyValue.
fn bare_negated_op_absent_tagkeys_matches() {
// A bare negated operator on an ABSENT aws:TagKeys matches, as on an
// absent single-valued key. Measured on IAM with simulate-custom-policy on
// 2026-10-24: a Deny carrying a bare StringNotEquals,
// StringNotEqualsIgnoreCase or StringNotLike on the key fires when the key
// is absent, and an Allow gated the same way grants. ExtendDB populates
// aws:TagKeys on exactly the operations the service does, so its absence
// is the service's absence and IAM's answer applies.
let ctx = TestContext::new();
assert!(!evaluate_condition(
&cond(
ConditionOperator::StringNotEquals,
"dynamodb:Attributes",
vec!["ssn"]
for op in [
ConditionOperator::StringNotEquals,
ConditionOperator::StringNotEqualsIgnoreCase,
ConditionOperator::StringNotLike,
] {
assert!(
evaluate_condition(&cond(op, "aws:TagKeys", vec!["secret"]), &ctx),
"a bare negated operator on absent aws:TagKeys matches, as on IAM"
);
}
assert!(
!evaluate_condition(
&cond(
ConditionOperator::StringEquals,
"aws:TagKeys",
vec!["secret"]
),
&ctx
),
&ctx
));
"a bare positive operator on absent aws:TagKeys does not match"
);
}

#[test]
fn bare_negated_op_absent_dynamodb_keys_do_not_match() {
// IAM matches a bare negated operator on absent dynamodb:Attributes and
// dynamodb:LeadingKeys too, but ExtendDB leaves both absent on requests
// where the service populates them (Attributes on writes, AttributesToGet
// reads and batch and transaction targets; LeadingKeys on query shapes the
// extractor cannot pin). Matching there would grant an Allow the service
// refuses for the present key, so for these two keys an absent key keeps
// the no-match rule (BR-7085).
let ctx = TestContext::new();
for key in ["dynamodb:Attributes", "dynamodb:LeadingKeys"] {
for op in [
ConditionOperator::StringNotEquals,
ConditionOperator::StringNotEqualsIgnoreCase,
ConditionOperator::StringNotLike,
ConditionOperator::StringEquals,
] {
assert!(
!evaluate_condition(&cond(op, key, vec!["ssn"]), &ctx),
"a bare operator on absent {key} does not match"
);
}
}
}

#[test]
fn bare_op_multivalued_present_never_matches() {
// BR-7085, measured again on IAM 2026-10-24: with the key PRESENT, neither
// a bare positive nor a bare negative operator matches. The author must
// qualify with ForAllValues or ForAnyValue.
for key in ["dynamodb:Attributes", "dynamodb:LeadingKeys", "aws:TagKeys"] {
let ctx = TestContext::new().with(key, vec!["colour"]);
for op in [
ConditionOperator::StringEquals,
ConditionOperator::StringNotEquals,
ConditionOperator::StringNotEqualsIgnoreCase,
] {
assert!(
!evaluate_condition(&cond(op, key, vec!["ssn"]), &ctx),
"a bare operator on present {key} does not match"
);
}
}
}
Loading
Loading