From 2c71ddcd79ae3d19e45611ec1880a299243abc79 Mon Sep 17 00:00:00 2001 From: Amol Navare Date: Thu, 15 Oct 2026 19:58:27 -0700 Subject: [PATCH 01/58] fix(server): honor explicit Deny in role trust policies evaluate_trust_policy() skipped every statement whose Effect was not "Allow", so Deny statements in a role's trust policy were silently ignored. An administrator relying on a Deny to revoke a principal's ability to assume a role got no protection: a broad `Allow *` still granted assumption to a principal an accompanying Deny was meant to block. Rework evaluation to follow IAM order, matching the identity-policy evaluator (policy/evaluator.rs, phase 1): scan for a matching explicit Deny first and block assumption if one is found, then look for a matching Allow. Factor the decision into a store-free `trust_decision` helper plus `statement_matches`/`effect_of` so the Deny-over-Allow ordering is unit-testable, and add tests covering explicit-deny override (both statement orders), principal-scoped Deny, conditional Deny via aws:PrincipalTag, and the existing Allow/implicit-deny paths. Record the decision in ADR-0022. The AssumeRole endpoint is admin-only (Basic Auth) with an admin-supplied caller ARN, which bounds real-world exposure, but the authorization logic was incorrect. --- crates/server/src/management/assume_role.rs | 228 ++++++++++++++++-- docs/adr/0022-trust-policy-deny-over-allow.md | 107 ++++++++ docs/adr/README.md | 3 +- 3 files changed, 320 insertions(+), 18 deletions(-) create mode 100644 docs/adr/0022-trust-policy-deny-over-allow.md diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index af4a41397..f06d50b49 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -272,14 +272,25 @@ async fn generate_and_store_session( /// Evaluate a trust policy to determine if `caller_arn` is allowed to assume /// the role. /// -/// Checks each Statement for `Effect: Allow` with a matching Principal and -/// passing conditions. Principal matching supports: +/// Follows IAM evaluation order: an explicit `Deny` that matches the caller +/// wins over any `Allow`, exactly as the identity-policy evaluator does +/// (`policy/evaluator.rs`, phase 1). Only if no matching `Deny` is found does +/// a matching `Allow` grant assumption. See ADR-0022. +/// +/// A statement "matches" when its Principal matches the caller and all of its +/// conditions pass. Principal matching supports: /// - `"*"` (any caller) /// - `{"AWS": "arn:..."}` (single ARN) /// - `{"AWS": ["arn:...", ...]}` (list of ARNs) /// /// Conditions are evaluated using the policy engine's `AssumeRoleContext`, -/// which supports `aws:PrincipalTag/*` and `sts:ExternalId` keys. +/// which supports `aws:PrincipalTag/*` and `sts:ExternalId` keys. A statement +/// whose `Condition` block fails to parse is treated as non-matching: it can +/// neither grant via `Allow` nor block via `Deny`. Treating an unparseable +/// `Deny` as "does not match" is the fail-open direction for that one +/// statement, but a malformed trust policy is an administrator error surfaced +/// at role creation, and matching the parse-failure handling of the `Allow` +/// path keeps the two consistent. async fn evaluate_trust_policy( trust_policy: &Value, caller_arn: &str, @@ -296,30 +307,52 @@ async fn evaluate_trust_policy( external_id: external_id.map(String::from), }; - for stmt in statements { - let effect = stmt.get("Effect").and_then(|e| e.as_str()).unwrap_or(""); - if effect != "Allow" { - continue; - } + trust_decision(statements, caller_arn, &context) +} - let principal = &stmt["Principal"]; - if !principal_matches(principal, caller_arn) { - continue; +/// Decide assumption from already-resolved statements and context. +/// +/// Split out from `evaluate_trust_policy` so the Deny-over-Allow ordering is +/// unit-testable without a `CatalogStore` (the store is only needed to build +/// the context's principal tags). +fn trust_decision(statements: &[Value], caller_arn: &str, context: &AssumeRoleContext) -> bool { + // Phase 1: explicit Deny. Any matching Deny statement blocks assumption + // outright, regardless of any Allow present in the same policy. + for stmt in statements { + if effect_of(stmt) == "Deny" && statement_matches(stmt, caller_arn, context) { + return false; } + } - let conditions_pass = match parse_conditions(&stmt["Condition"]) { - Ok(conds) => conds.iter().all(|c| evaluate_condition(c, &context)), - Err(_) => false, - }; - - if conditions_pass { + // Phase 2: Allow. With no matching Deny, a matching Allow grants assumption. + for stmt in statements { + if effect_of(stmt) == "Allow" && statement_matches(stmt, caller_arn, context) { return true; } } + // Implicit deny: no statement granted assumption. false } +/// The `Effect` string of a statement, or `""` when absent/non-string. +fn effect_of(stmt: &Value) -> &str { + stmt.get("Effect").and_then(|e| e.as_str()).unwrap_or("") +} + +/// Whether a trust-policy statement matches the caller: its Principal matches +/// `caller_arn` and every condition passes. Independent of the statement's +/// `Effect`, so both the Deny and Allow phases share one matching definition. +fn statement_matches(stmt: &Value, caller_arn: &str, context: &AssumeRoleContext) -> bool { + if !principal_matches(&stmt["Principal"], caller_arn) { + return false; + } + match parse_conditions(&stmt["Condition"]) { + Ok(conds) => conds.iter().all(|c| evaluate_condition(c, context)), + Err(_) => false, + } +} + /// Check if a Principal field matches the given caller ARN. fn principal_matches(principal: &Value, caller_arn: &str) -> bool { match principal { @@ -371,3 +404,164 @@ async fn fetch_caller_tags( } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + fn ctx() -> AssumeRoleContext { + AssumeRoleContext { + principal_tags: HashMap::new(), + external_id: None, + } + } + + fn ctx_with_tags(tags: &[(&str, &str)]) -> AssumeRoleContext { + AssumeRoleContext { + principal_tags: tags + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(), + external_id: None, + } + } + + const CALLER: &str = "arn:aws:iam::123456789012:user/alice"; + + // ---- principal_matches ------------------------------------------------ + + #[test] + fn principal_wildcard_string_matches_any() { + assert!(principal_matches(&json!("*"), CALLER)); + } + + #[test] + fn principal_aws_single_arn_matches_exact() { + assert!(principal_matches(&json!({"AWS": CALLER}), CALLER)); + assert!(!principal_matches( + &json!({"AWS": "arn:aws:iam::123456789012:user/bob"}), + CALLER + )); + } + + #[test] + fn principal_aws_wildcard_matches_any() { + assert!(principal_matches(&json!({"AWS": "*"}), CALLER)); + } + + #[test] + fn principal_aws_list_matches_any_member() { + let p = json!({"AWS": ["arn:aws:iam::123456789012:user/bob", CALLER]}); + assert!(principal_matches(&p, CALLER)); + let p2 = json!({"AWS": ["arn:aws:iam::123456789012:user/bob"]}); + assert!(!principal_matches(&p2, CALLER)); + } + + #[test] + fn principal_missing_aws_does_not_match() { + assert!(!principal_matches( + &json!({"Service": "ec2.amazonaws.com"}), + CALLER + )); + assert!(!principal_matches(&json!(null), CALLER)); + } + + // ---- effect_of -------------------------------------------------------- + + #[test] + fn effect_of_reads_string_or_defaults_empty() { + assert_eq!(effect_of(&json!({"Effect": "Allow"})), "Allow"); + assert_eq!(effect_of(&json!({"Effect": "Deny"})), "Deny"); + assert_eq!(effect_of(&json!({})), ""); + assert_eq!(effect_of(&json!({"Effect": 5})), ""); + } + + // ---- trust_decision: core regression --------------------------------- + + #[test] + fn allow_grants_when_principal_matches() { + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}})]; + assert!(trust_decision(&stmts, CALLER, &ctx())); + } + + #[test] + fn no_matching_allow_is_implicit_deny() { + let stmts = vec![json!({ + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:user/bob"} + })]; + assert!(!trust_decision(&stmts, CALLER, &ctx())); + } + + /// The ticket regression: an explicit Deny must override a broad Allow. + /// Before the fix, Deny statements were skipped entirely and the caller + /// was granted assumption via the `Allow *`. + #[test] + fn explicit_deny_overrides_allow_regardless_of_order() { + // Deny listed after Allow. + let allow_then_deny = vec![ + json!({"Effect": "Allow", "Principal": "*"}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}}), + ]; + assert!( + !trust_decision(&allow_then_deny, CALLER, &ctx()), + "explicit Deny after Allow must block assumption" + ); + + // Deny listed before Allow. + let deny_then_allow = vec![ + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}}), + json!({"Effect": "Allow", "Principal": "*"}), + ]; + assert!( + !trust_decision(&deny_then_allow, CALLER, &ctx()), + "explicit Deny before Allow must block assumption" + ); + } + + #[test] + fn deny_targeting_other_principal_does_not_block() { + let stmts = vec![ + json!({"Effect": "Allow", "Principal": "*"}), + json!({"Effect": "Deny", "Principal": {"AWS": "arn:aws:iam::123456789012:user/bob"}}), + ]; + assert!( + trust_decision(&stmts, CALLER, &ctx()), + "a Deny aimed at a different principal must not block this caller" + ); + } + + #[test] + fn conditional_deny_only_blocks_when_condition_matches() { + // Deny alice only when her PrincipalTag/team == "contractors". + let stmts = vec![ + json!({"Effect": "Allow", "Principal": "*"}), + json!({ + "Effect": "Deny", + "Principal": {"AWS": CALLER}, + "Condition": {"StringEquals": {"aws:PrincipalTag/team": "contractors"}} + }), + ]; + + // Tag matches → Deny applies → blocked. + assert!(!trust_decision( + &stmts, + CALLER, + &ctx_with_tags(&[("team", "contractors")]) + )); + + // Tag does not match → Deny inert → Allow grants. + assert!(trust_decision( + &stmts, + CALLER, + &ctx_with_tags(&[("team", "employees")]) + )); + } + + #[test] + fn empty_statement_list_denies() { + assert!(!trust_decision(&[], CALLER, &ctx())); + } +} diff --git a/docs/adr/0022-trust-policy-deny-over-allow.md b/docs/adr/0022-trust-policy-deny-over-allow.md new file mode 100644 index 000000000..70fc68002 --- /dev/null +++ b/docs/adr/0022-trust-policy-deny-over-allow.md @@ -0,0 +1,107 @@ +# ADR-0022: Role trust-policy evaluation follows IAM Deny-over-Allow order + +- Status: Accepted +- Date: 2026-10-15 +- Deciders: @navamol + +## Context + +`AssumeRole` (`crates/server/src/management/assume_role.rs`) decides whether a +caller may assume a role by evaluating the role's trust policy against the +caller ARN. The original `evaluate_trust_policy()` iterated the statements and +skipped any whose `Effect` was not `"Allow"`: + +```rust +if effect != "Allow" { + continue; +} +``` + +A matching `Allow` returned `true`; otherwise the function returned `false` +(implicit deny). The consequence is that `Deny` statements in a trust policy +were never evaluated; they behaved as no-ops. An administrator who added a +`Deny` for a specific principal alongside a broad `Allow` (for example +`Allow Principal "*"` plus `Deny Principal `) got no protection: the denied +principal was still granted assumption through the `Allow`. + +This diverges from IAM semantics and, more importantly, from ExtendDB's own +identity-policy evaluator (`crates/auth/src/policy/evaluator.rs`), which already +implements the standard order: phase 1 is an explicit-deny scan across all +policies before any `Allow` is considered. Trust-policy evaluation and +identity-policy evaluation disagreeing on how `Deny` works is a correctness and +least-privilege hazard: the behavior a trust policy's author reasonably expects +(a `Deny` denies) is the behavior the identity evaluator already provides. + +The endpoint is admin-only (Basic Auth) and the +admin supplies the caller ARN, which bounds the practical blast radius, but the +authorization logic itself was incorrect. + +## Options Considered + +1. **Leave Allow-only, document the limitation.** Keep the existing behavior and + state in the docs that trust policies support only `Allow`. Cheapest, but it + leaves a silently-ignored `Deny` as a trap: policy authors would still write + `Deny` statements expecting them to work, and the two policy evaluators would + remain inconsistent. + +2. **Reject trust policies that contain `Deny` at role-creation time.** Fail + fast so no one can author a `Deny` that does nothing. Removes the trap but + also removes a legitimate, expected capability, and still leaves trust-policy + evaluation less expressive than identity-policy evaluation. + +3. **Evaluate `Deny` before `Allow` (IAM order).** Scan for a matching explicit + `Deny` first and block assumption if one is found; only then look for a + matching `Allow`. Matches IAM and the existing identity-policy evaluator. + +## Decision + +Trust-policy evaluation follows IAM order: a matching explicit `Deny` blocks +role assumption and is evaluated before any `Allow` (Option 3). With no matching +`Deny`, a matching `Allow` grants assumption; with neither, assumption is +implicitly denied. + +## Rationale + +- **Consistency with the identity-policy evaluator.** `policy/evaluator.rs` + already does explicit-deny-first across identity policies, permissions + boundaries, and session policies. Trust policies evaluating `Deny` differently + was an inconsistency a reader would have to discover by accident. +- **Principle of least astonishment / least privilege.** A trust policy that + says `Deny` must deny. Silently ignoring it fails safe only by luck (when + there is no broad `Allow`) and fails open in exactly the case an author writes + a `Deny` to guard against. +- **Matches the semantics callers already know.** ExtendDB speaks the DynamoDB / + IAM wire contract; explicit-deny-wins is the IAM evaluation rule operators + expect. +- **Not an auth-model change.** This corrects behavior to the documented and + already-implemented-elsewhere model. It does not introduce a new policy + language, condition key, or evaluation phase, so it is a bug fix rather than an + RFC-level change to the auth model. + +## Consequences + +- **Easier:** trust-policy authors can rely on `Deny`, and the two policy + evaluators now share one mental model (explicit deny wins). +- **Behavioral change to be aware of:** a trust policy that already contained a + matching `Deny` alongside a broad `Allow` previously granted assumption and + will now block it. This is the intended fix, but it is a behavior change for + any deployment that (perhaps unknowingly) relied on `Deny` being ignored. There + is no change for trust policies that contain only `Allow` statements. +- **Follow-up:** the decision logic was factored into a store-free + `trust_decision` helper (plus `statement_matches` / `effect_of`) so the + Deny-over-Allow ordering is unit-tested directly. A statement whose + `Condition` block fails to parse is treated as non-matching in both the `Deny` + and `Allow` phases, matching the pre-existing `Allow`-path handling. +- **Commitment:** trust-policy and identity-policy evaluation should stay aligned + on evaluation order; a future change to one should consider the other. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3b06a97b1..2deec1259 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -47,4 +47,5 @@ decision, write a new ADR. | [0018](0018-cassandra-stream-implementation.md) | Cassandra: DynamoDB Streams implementation | Accepted | | [0019](0019-cassandra-logical-backup-restore.md) | Cassandra: logical backup and restore | Accepted | | [0020](0020-cassandra-occ-update-item.md) | Cassandra: optimistic concurrency control for UpdateItem and PutItem | Accepted | -| [0021](0021-cassandra-lwt-delete-atomicity-gap.md) | Cassandra: LWT delete atomicity gap and required worker/saga pattern | Accepted | \ No newline at end of file +| [0021](0021-cassandra-lwt-delete-atomicity-gap.md) | Cassandra: LWT delete atomicity gap and required worker/saga pattern | Accepted | +| [0022](0022-trust-policy-deny-over-allow.md) | Role trust-policy evaluation follows IAM Deny-over-Allow order | Accepted | \ No newline at end of file From b17b3b8815f0c9b879c7903a4489ad89eb1e2b20 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 16:32:15 +0000 Subject: [PATCH 02/58] fix(auth): match IAM on trust-policy Deny evaluation and validate at write The Deny-over-Allow reorder in 9952f5ff left several Deny shapes inert and accepted documents IAM rejects. Measured against live IAM, STS and DynamoDB, this corrects the trust evaluator and adds write-time validation. Evaluation (crates/server/src/management/assume_role.rs): - Decide per requested STS action. A plain AssumeRole exercises sts:AssumeRole; passing session tags also exercises sts:TagSession. Every exercised action needs a matching Allow and no matching Deny. This fixes the over-deny on a TagSession-only or SetSourceIdentity-only Deny, the fail-open on a TagSession-only Allow, and session tags passed without sts:TagSession. - Compare Effect case-insensitively, matching the identity-policy parser. - Match account principals: {"AWS": ":root"} and {"AWS": ""} match any caller in that account. - Accept a single-object Statement. - Fail closed on a Deny whose Condition does not parse, so a Deny with an operator ExtendDB does not evaluate blocks rather than being dropped. - Coerce scalar condition values to strings, matching IAM. Condition evaluator (crates/auth/src/policy/condition.rs): - A negated operator on an absent single-valued key now evaluates true, as in IAM, so a Deny gated on StringNotEquals sts:ExternalId applies when the caller omits the external id. BR-7085 (bare operators never match a multivalued key) is preserved. Shared with identity-policy evaluation. Write validation (create_role, crates/server/src/management/iam_role.rs): - Reject the documents IAM rejects for a trust policy: missing or invalid Effect, NotPrincipal, missing Principal, a bare-string or wildcard-ARN principal, missing Action, an action outside the STS trust set, a Resource field, and an unknown or malformed Condition. Accept a missing Version, a single-object Statement, numeric condition values and unknown condition keys. SQLite caller tags (crates/storage-sqlite/src/management_store/mod.rs): - Resolve tags with strip_prefix("user/"/"role/"/"assumed-role/") like the other backends. The server passes user/, so the old :user/ split never matched and tag conditions were inert on SQLite. --- crates/auth/src/policy/condition.rs | 10 +- .../src/policy/condition/tests/multivalued.rs | 16 + .../src/policy/condition/tests/operators.rs | 66 ++ crates/server/src/management/assume_role.rs | 949 ++++++++++++++++-- crates/server/src/management/iam_role.rs | 11 +- .../src/management_store/mod.rs | 15 +- 6 files changed, 949 insertions(+), 118 deletions(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index b2ef1297f..53a905a92 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -109,7 +109,15 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext Some(vals) => evaluate_single_value_condition(inner, &vals, &expanded_values), }, other => match context_values { - None => false, + // 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. diff --git a/crates/auth/src/policy/condition/tests/multivalued.rs b/crates/auth/src/policy/condition/tests/multivalued.rs index 9cc034a16..cf892a096 100644 --- a/crates/auth/src/policy/condition/tests/multivalued.rs +++ b/crates/auth/src/policy/condition/tests/multivalued.rs @@ -373,3 +373,19 @@ fn for_any_value_string_not_equals_one_outside_set() { &ctx )); } + +#[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. + let ctx = TestContext::new(); + assert!(!evaluate_condition( + &cond( + ConditionOperator::StringNotEquals, + "dynamodb:Attributes", + vec!["ssn"] + ), + &ctx + )); +} diff --git a/crates/auth/src/policy/condition/tests/operators.rs b/crates/auth/src/policy/condition/tests/operators.rs index 1c0fd14bd..4a7fded91 100644 --- a/crates/auth/src/policy/condition/tests/operators.rs +++ b/crates/auth/src/policy/condition/tests/operators.rs @@ -367,3 +367,69 @@ fn date_less_than() { &ctx )); } + +// --- Negated operators on an absent single-valued key --- +// +// AWS IAM: an absent key cannot equal the policy value, so a negated operator +// ("not equals") holds. A Deny gated on StringNotEquals therefore applies when +// the caller omits the key. Measured on live IAM/STS and DynamoDB. + +#[test] +fn string_not_equals_absent_key_matches() { + let ctx = TestContext::new(); + assert!(evaluate_condition( + &cond( + ConditionOperator::StringNotEquals, + "sts:ExternalId", + vec!["secret"] + ), + &ctx + )); +} + +#[test] +fn string_not_like_absent_key_matches() { + let ctx = TestContext::new(); + assert!(evaluate_condition( + &cond(ConditionOperator::StringNotLike, "k", vec!["sec*"]), + &ctx + )); +} + +#[test] +fn arn_not_equals_absent_key_matches() { + let ctx = TestContext::new(); + assert!(evaluate_condition( + &cond( + ConditionOperator::ArnNotEquals, + "k", + vec!["arn:aws:iam::123456789012:user/x"] + ), + &ctx + )); +} + +#[test] +fn positive_op_absent_key_still_no_match() { + // Regression: positive operators on an absent key remain false. + let ctx = TestContext::new(); + assert!(!evaluate_condition( + &cond(ConditionOperator::StringEquals, "k", vec!["x"]), + &ctx + )); +} + +#[test] +fn string_not_equals_present_key_still_compares() { + // Regression: present key still evaluates normally. + let ctx = TestContext::new().with("k", vec!["secret"]); + assert!(!evaluate_condition( + &cond(ConditionOperator::StringNotEquals, "k", vec!["secret"]), + &ctx + )); + let ctx2 = TestContext::new().with("k", vec!["other"]); + assert!(evaluate_condition( + &cond(ConditionOperator::StringNotEquals, "k", vec!["secret"]), + &ctx2 + )); +} diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index f06d50b49..682c6c146 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -145,15 +145,26 @@ pub async fn assume_role( } }; + // Session tags passed on the call require sts:TagSession in the trust + // policy, so the STS action set the decision checks depends on their + // presence. + let has_session_tags = request.session_tags.as_ref().is_some_and(value_has_entries); + // Evaluate trust policy against the provided caller ARN. if !evaluate_trust_policy( &trust_policy, caller_arn, request.external_id.as_deref(), + has_session_tags, &*state.catalog_store, ) .await { + tracing::warn!( + target: "extenddb::audit::manage", + "assume-role denied: account={}, role={}, caller={}", + account_id, role_name, caller_arn, + ); return ( StatusCode::FORBIDDEN, "Caller is not authorized to assume this role", @@ -269,35 +280,30 @@ async fn generate_and_store_session( // Trust policy evaluation // --------------------------------------------------------------------------- -/// Evaluate a trust policy to determine if `caller_arn` is allowed to assume -/// the role. -/// -/// Follows IAM evaluation order: an explicit `Deny` that matches the caller -/// wins over any `Allow`, exactly as the identity-policy evaluator does -/// (`policy/evaluator.rs`, phase 1). Only if no matching `Deny` is found does -/// a matching `Allow` grant assumption. See ADR-0022. +/// Evaluate a trust policy to decide whether `caller_arn` may assume the role, +/// given the external id and whether session tags were passed. /// -/// A statement "matches" when its Principal matches the caller and all of its -/// conditions pass. Principal matching supports: -/// - `"*"` (any caller) -/// - `{"AWS": "arn:..."}` (single ARN) -/// - `{"AWS": ["arn:...", ...]}` (list of ARNs) +/// Follows IAM order per requested STS action. An `AssumeRole` call exercises +/// `sts:AssumeRole`; passing session tags additionally exercises +/// `sts:TagSession`. ExtendDB does not implement source identity, so +/// `sts:SetSourceIdentity` is never exercised. Assumption is granted only when +/// every exercised action has a matching `Allow` and no matching `Deny`, with +/// an explicit `Deny` winning over any `Allow` (ADR-0022). /// -/// Conditions are evaluated using the policy engine's `AssumeRoleContext`, -/// which supports `aws:PrincipalTag/*` and `sts:ExternalId` keys. A statement -/// whose `Condition` block fails to parse is treated as non-matching: it can -/// neither grant via `Allow` nor block via `Deny`. Treating an unparseable -/// `Deny` as "does not match" is the fail-open direction for that one -/// statement, but a malformed trust policy is an administrator error surfaced -/// at role creation, and matching the parse-failure handling of the `Allow` -/// path keeps the two consistent. +/// A statement matches an action when its `Principal` matches the caller, its +/// `Action`/`NotAction` matches the action, and its `Condition` block passes. A +/// `Deny` whose condition fails to parse fails closed: it blocks, so a +/// malformed stored `Deny` cannot silently grant assumption. Documents are +/// validated at write time (`validate_trust_policy`), so a well-formed store +/// does not reach the fail-closed path in normal operation. async fn evaluate_trust_policy( trust_policy: &Value, caller_arn: &str, external_id: Option<&str>, + has_session_tags: bool, store: &dyn extenddb_storage::CatalogStore, ) -> bool { - let Some(statements) = trust_policy.get("Statement").and_then(|s| s.as_array()) else { + let Some(statements) = statements_vec(trust_policy) else { return false; }; @@ -307,73 +313,460 @@ async fn evaluate_trust_policy( external_id: external_id.map(String::from), }; - trust_decision(statements, caller_arn, &context) + trust_decision(&statements, caller_arn, &context, has_session_tags) } -/// Decide assumption from already-resolved statements and context. -/// -/// Split out from `evaluate_trust_policy` so the Deny-over-Allow ordering is -/// unit-testable without a `CatalogStore` (the store is only needed to build -/// the context's principal tags). -fn trust_decision(statements: &[Value], caller_arn: &str, context: &AssumeRoleContext) -> bool { - // Phase 1: explicit Deny. Any matching Deny statement blocks assumption - // outright, regardless of any Allow present in the same policy. +/// The statements of a policy document as an owned list. `Statement` may be a +/// single object or an array; both are supported, matching IAM. Returns `None` +/// when `Statement` is absent or neither an object nor an array. +fn statements_vec(trust_policy: &Value) -> Option> { + match trust_policy.get("Statement") { + Some(Value::Array(arr)) => Some(arr.clone()), + Some(obj @ Value::Object(_)) => Some(vec![obj.clone()]), + _ => None, + } +} + +/// Decide assumption from resolved statements and context. Split out so the +/// per-action Deny-over-Allow ordering is unit-testable without a store. +fn trust_decision( + statements: &[Value], + caller_arn: &str, + context: &AssumeRoleContext, + has_session_tags: bool, +) -> bool { + requested_actions(has_session_tags) + .iter() + .all(|action| action_permitted(statements, caller_arn, context, action)) +} + +/// The STS actions an `AssumeRole` request exercises. +fn requested_actions(has_session_tags: bool) -> Vec<&'static str> { + let mut actions = vec!["sts:AssumeRole"]; + if has_session_tags { + actions.push("sts:TagSession"); + } + actions +} + +/// Whether a single STS action is permitted: a matching explicit `Deny` blocks +/// it; otherwise a matching `Allow` grants it; with neither it is implicitly +/// denied. +fn action_permitted( + statements: &[Value], + caller_arn: &str, + context: &AssumeRoleContext, + action: &str, +) -> bool { for stmt in statements { - if effect_of(stmt) == "Deny" && statement_matches(stmt, caller_arn, context) { + if effect_of(stmt).eq_ignore_ascii_case("Deny") + && deny_statement_blocks(stmt, caller_arn, context, action) + { return false; } } - - // Phase 2: Allow. With no matching Deny, a matching Allow grants assumption. for stmt in statements { - if effect_of(stmt) == "Allow" && statement_matches(stmt, caller_arn, context) { + if effect_of(stmt).eq_ignore_ascii_case("Allow") + && allow_statement_grants(stmt, caller_arn, context, action) + { return true; } } - - // Implicit deny: no statement granted assumption. false } -/// The `Effect` string of a statement, or `""` when absent/non-string. +/// The `Effect` string of a statement, or `""` when absent/non-string. Compared +/// case-insensitively by the caller, matching the identity-policy parser. fn effect_of(stmt: &Value) -> &str { stmt.get("Effect").and_then(|e| e.as_str()).unwrap_or("") } -/// Whether a trust-policy statement matches the caller: its Principal matches -/// `caller_arn` and every condition passes. Independent of the statement's -/// `Effect`, so both the Deny and Allow phases share one matching definition. -fn statement_matches(stmt: &Value, caller_arn: &str, context: &AssumeRoleContext) -> bool { - if !principal_matches(&stmt["Principal"], caller_arn) { +/// Whether a `Deny` blocks the action: its principal and action match and its +/// condition passes. A condition that fails to parse fails closed (blocks). +fn deny_statement_blocks( + stmt: &Value, + caller_arn: &str, + context: &AssumeRoleContext, + action: &str, +) -> bool { + if !principal_matches(&stmt["Principal"], caller_arn) || !action_matches(stmt, action) { + return false; + } + matches!( + evaluate_conditions(stmt, context), + ConditionOutcome::Pass | ConditionOutcome::Unparseable + ) +} + +/// Whether an `Allow` grants the action: principal, action and condition all +/// match. A condition that fails to parse does not grant. +fn allow_statement_grants( + stmt: &Value, + caller_arn: &str, + context: &AssumeRoleContext, + action: &str, +) -> bool { + if !principal_matches(&stmt["Principal"], caller_arn) || !action_matches(stmt, action) { return false; } - match parse_conditions(&stmt["Condition"]) { - Ok(conds) => conds.iter().all(|c| evaluate_condition(c, context)), - Err(_) => false, + matches!(evaluate_conditions(stmt, context), ConditionOutcome::Pass) +} + +/// Outcome of a statement's `Condition` block for a given context. +enum ConditionOutcome { + Pass, + Fail, + Unparseable, +} + +/// Evaluate a statement's `Condition` block. Scalar condition values (numbers, +/// booleans) are coerced to strings, matching IAM. An unknown operator or a +/// structurally invalid block is reported as `Unparseable`. +fn evaluate_conditions(stmt: &Value, context: &AssumeRoleContext) -> ConditionOutcome { + let normalized = normalize_condition_scalars(&stmt["Condition"]); + match parse_conditions(&normalized) { + Ok(conds) => { + if conds.iter().all(|c| evaluate_condition(c, context)) { + ConditionOutcome::Pass + } else { + ConditionOutcome::Fail + } + } + Err(_) => ConditionOutcome::Unparseable, + } +} + +/// Whether a statement's `Action`/`NotAction` covers `action`, case-insensitive +/// and with `*` and prefix wildcards. A statement with neither field covers no +/// action. +fn action_matches(stmt: &Value, action: &str) -> bool { + if let Some(actions) = action_list(stmt.get("Action")) { + actions.iter().any(|p| action_pattern_matches(p, action)) + } else if let Some(not_actions) = action_list(stmt.get("NotAction")) { + !not_actions + .iter() + .any(|p| action_pattern_matches(p, action)) + } else { + false } } -/// Check if a Principal field matches the given caller ARN. +/// A statement's action field as a list of patterns, or `None` when absent. +fn action_list(v: Option<&Value>) -> Option> { + match v { + Some(Value::String(s)) => Some(vec![s.clone()]), + Some(Value::Array(arr)) => Some( + arr.iter() + .filter_map(|a| a.as_str().map(ToOwned::to_owned)) + .collect(), + ), + _ => None, + } +} + +/// Match an action pattern against an action, case-insensitive. Supports `*` +/// (any action) and a trailing-`*` prefix wildcard such as `sts:*`. +fn action_pattern_matches(pattern: &str, action: &str) -> bool { + let p = pattern.to_ascii_lowercase(); + let a = action.to_ascii_lowercase(); + if p == "*" { + return true; + } + if let Some(prefix) = p.strip_suffix('*') { + return a.starts_with(prefix); + } + p == a +} + +/// Whether a Principal field matches the caller ARN. Supports `"*"`, `{"AWS": +/// ""|"*"|""|""}` and lists thereof. +/// Account-id and account-root principals match any caller in that account. +/// Objects without an `AWS` key (for example `Service` principals) never match +/// an IAM user or assumed-role caller. fn principal_matches(principal: &Value, caller_arn: &str) -> bool { match principal { - // "*" matches any caller. - Value::String(s) if s == "*" => true, - // {"AWS": "arn:..."} or {"AWS": ["arn:...", ...]} + Value::String(s) => s == "*", + Value::Object(map) => match map.get("AWS") { + Some(Value::String(arn)) => principal_entry_matches(arn, caller_arn), + Some(Value::Array(arns)) => arns + .iter() + .filter_map(Value::as_str) + .any(|a| principal_entry_matches(a, caller_arn)), + _ => false, + }, + _ => false, + } +} + +/// Whether one `AWS` principal entry matches the caller. Exact ARN, `"*"`, bare +/// account id, and account-root ARN (`arn:aws:iam:::root`) all match. +fn principal_entry_matches(entry: &str, caller_arn: &str) -> bool { + if entry == "*" || entry == caller_arn { + return true; + } + if let Some(account) = caller_account(caller_arn) { + if entry == account { + return true; + } + if let Some(root_account) = entry + .strip_prefix("arn:aws:iam::") + .and_then(|r| r.strip_suffix(":root")) + && root_account == account + { + return true; + } + } + false +} + +/// The account id field of an ARN (`arn:partition:service:region:account:...`). +fn caller_account(caller_arn: &str) -> Option<&str> { + caller_arn.split(':').nth(4).filter(|s| !s.is_empty()) +} + +/// Whether a JSON value is a non-empty object or array. +fn value_has_entries(v: &Value) -> bool { + match v { + Value::Object(o) => !o.is_empty(), + Value::Array(a) => !a.is_empty(), + _ => false, + } +} + +/// Coerce scalar condition values (numbers, booleans) to strings, matching +/// IAM's handling of condition values, so a numeric or boolean value parses and +/// evaluates as its string form rather than being rejected. +fn normalize_condition_scalars(v: &Value) -> Value { + match v { + Value::Number(n) => Value::String(n.to_string()), + Value::Bool(b) => Value::String(b.to_string()), + Value::Array(a) => Value::Array(a.iter().map(normalize_condition_scalars).collect()), + Value::Object(o) => Value::Object( + o.iter() + .map(|(k, val)| (k.clone(), normalize_condition_scalars(val))) + .collect(), + ), + other => other.clone(), + } +} + +// --------------------------------------------------------------------------- +// Trust policy write-time validation +// --------------------------------------------------------------------------- + +/// Allowed actions in an `AssumeRole` trust policy, lowercase for comparison. +const STS_TRUST_ACTIONS: [&str; 3] = ["sts:assumerole", "sts:tagsession", "sts:setsourceidentity"]; + +/// Validate a trust policy document at write time, rejecting the documents IAM +/// rejects for an `AssumeRole` trust policy and accepting those it accepts. The +/// error text is ExtendDB's own; the accepted and rejected sets match IAM. +/// +/// Rejects: missing or non-string `Effect` and values other than Allow/Deny; +/// `NotPrincipal`; a missing `Principal`; a bare string principal (including +/// `"*"`); an `AWS` principal ARN containing a wildcard; a missing +/// `Action`/`NotAction`; an action outside the STS trust set; a +/// `Resource`/`NotResource` field; and a `Condition` with an unknown operator +/// or invalid structure. Accepts a missing `Version`, a single-object +/// `Statement`, numeric condition values and unknown condition keys. +pub(crate) fn validate_trust_policy(trust_policy: &Value) -> Result<(), String> { + let Some(statements) = statements_vec(trust_policy) else { + return Err("Trust policy must contain a Statement object or array".to_owned()); + }; + if statements.is_empty() { + return Err("Trust policy Statement must not be empty".to_owned()); + } + for stmt in &statements { + validate_trust_statement(stmt)?; + } + Ok(()) +} + +/// A statement field, treating JSON null as absent. +fn non_null(v: Option<&Value>) -> Option<&Value> { + v.filter(|v| !v.is_null()) +} + +fn validate_trust_statement(stmt: &Value) -> Result<(), String> { + match stmt.get("Effect").and_then(Value::as_str) { + Some(e) if e.eq_ignore_ascii_case("Allow") || e.eq_ignore_ascii_case("Deny") => {} + Some(e) => return Err(format!("invalid Effect: {e}")), + None => return Err("statement is missing Effect".to_owned()), + } + + if non_null(stmt.get("NotPrincipal")).is_some() { + return Err("NotPrincipal is not allowed in a trust policy".to_owned()); + } + match non_null(stmt.get("Principal")) { + None => return Err("statement is missing Principal".to_owned()), + Some(p) => validate_trust_principal(p)?, + } + + match ( + non_null(stmt.get("Action")), + non_null(stmt.get("NotAction")), + ) { + (None, None) => return Err("statement is missing Action".to_owned()), + (Some(_), Some(_)) => return Err("statement has both Action and NotAction".to_owned()), + (Some(a), None) | (None, Some(a)) => validate_trust_actions(a)?, + } + + if non_null(stmt.get("Resource")).is_some() || non_null(stmt.get("NotResource")).is_some() { + return Err("Resource is not allowed in a trust policy".to_owned()); + } + + if let Some(cond) = non_null(stmt.get("Condition")) { + validate_trust_condition(cond)?; + } + + Ok(()) +} + +/// Validate a `Condition` block at write time. Accepts the full IAM operator +/// vocabulary (including operators ExtendDB's evaluator does not implement, such +/// as `IpAddress`; a `Deny` using one of those fails closed at evaluation). +/// Rejects an invalid operator name, a non-object block, and a non-object +/// operator entry. Accepts string, number and boolean values and unknown +/// condition keys, matching IAM. +fn validate_trust_condition(cond: &Value) -> Result<(), String> { + let obj = cond + .as_object() + .ok_or_else(|| "Condition must be an object".to_owned())?; + for (op_name, keys) in obj { + if !is_known_iam_condition_operator(op_name) { + return Err(format!("invalid Condition operator: {op_name}")); + } + let keys_obj = keys + .as_object() + .ok_or_else(|| format!("Condition {op_name} must be an object"))?; + for vals in keys_obj.values() { + validate_condition_values(vals)?; + } + } + Ok(()) +} + +fn validate_condition_values(v: &Value) -> Result<(), String> { + let is_scalar = |x: &Value| x.is_string() || x.is_number() || x.is_boolean(); + match v { + _ if is_scalar(v) => Ok(()), + Value::Array(arr) if arr.iter().all(is_scalar) => Ok(()), + _ => Err("condition value must be a string, number, boolean or array of those".to_owned()), + } +} + +/// Whether `name` is a known IAM condition operator (base name, after stripping +/// a `ForAllValues:`/`ForAnyValue:` prefix and an `IfExists` suffix). Used only +/// by write-time validation so the accepted operator set matches IAM even for +/// operators ExtendDB does not evaluate. +fn is_known_iam_condition_operator(name: &str) -> bool { + let base = name + .strip_prefix("ForAllValues:") + .or_else(|| name.strip_prefix("ForAnyValue:")) + .unwrap_or(name); + let base = base.strip_suffix("IfExists").unwrap_or(base); + matches!( + base, + "StringEquals" + | "StringNotEquals" + | "StringEqualsIgnoreCase" + | "StringNotEqualsIgnoreCase" + | "StringLike" + | "StringNotLike" + | "NumericEquals" + | "NumericNotEquals" + | "NumericLessThan" + | "NumericLessThanEquals" + | "NumericGreaterThan" + | "NumericGreaterThanEquals" + | "DateEquals" + | "DateNotEquals" + | "DateLessThan" + | "DateLessThanEquals" + | "DateGreaterThan" + | "DateGreaterThanEquals" + | "Bool" + | "BinaryEquals" + | "IpAddress" + | "NotIpAddress" + | "ArnEquals" + | "ArnLike" + | "ArnNotEquals" + | "ArnNotLike" + | "Null" + ) +} + +fn validate_trust_principal(principal: &Value) -> Result<(), String> { + match principal { + Value::String(_) => { + Err("Principal must be an object such as {\"AWS\": \"\"}".to_owned()) + } Value::Object(map) => { - if let Some(aws) = map.get("AWS") { - match aws { - Value::String(arn) => arn == "*" || arn == caller_arn, - Value::Array(arns) => arns - .iter() - .any(|a| a.as_str().is_some_and(|s| s == "*" || s == caller_arn)), - _ => false, + if map.is_empty() { + return Err("Principal object must not be empty".to_owned()); + } + if let Some(aws) = non_null(map.get("AWS")) { + for entry in principal_entry_strings(aws)? { + validate_aws_principal_entry(&entry)?; } - } else { - false } + Ok(()) } - _ => false, + _ => Err("invalid Principal value".to_owned()), + } +} + +fn principal_entry_strings(v: &Value) -> Result, String> { + match v { + Value::String(s) => Ok(vec![s.clone()]), + Value::Array(arr) => arr + .iter() + .map(|a| { + a.as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| "principal entry must be a string".to_owned()) + }) + .collect(), + _ => Err("principal value must be a string or array of strings".to_owned()), + } +} + +fn validate_aws_principal_entry(entry: &str) -> Result<(), String> { + if entry == "*" { + return Ok(()); } + if entry.contains('*') { + return Err(format!( + "wildcard is not allowed in a principal ARN: {entry}" + )); + } + Ok(()) +} + +fn validate_trust_actions(actions: &Value) -> Result<(), String> { + let list = match actions { + Value::String(s) => vec![s.clone()], + Value::Array(arr) => arr + .iter() + .map(|a| { + a.as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| "Action entry must be a string".to_owned()) + }) + .collect::, _>>()?, + _ => return Err("Action must be a string or array of strings".to_owned()), + }; + if list.is_empty() { + return Err("Action must not be empty".to_owned()); + } + for a in &list { + if !STS_TRUST_ACTIONS.contains(&a.to_ascii_lowercase().as_str()) { + return Err(format!( + "trust policy Action must be one of sts:AssumeRole, sts:TagSession, sts:SetSourceIdentity: {a}" + )); + } + } + Ok(()) } // --------------------------------------------------------------------------- @@ -411,6 +804,10 @@ mod tests { use serde_json::json; use std::collections::HashMap; + const ACCT: &str = "123456789012"; + const CALLER: &str = "arn:aws:iam::123456789012:user/alice"; + const BOB: &str = "arn:aws:iam::123456789012:user/bob"; + fn ctx() -> AssumeRoleContext { AssumeRoleContext { principal_tags: HashMap::new(), @@ -418,6 +815,13 @@ mod tests { } } + fn ctx_ext(id: &str) -> AssumeRoleContext { + AssumeRoleContext { + principal_tags: HashMap::new(), + external_id: Some(id.to_owned()), + } + } + fn ctx_with_tags(tags: &[(&str, &str)]) -> AssumeRoleContext { AssumeRoleContext { principal_tags: tags @@ -428,7 +832,10 @@ mod tests { } } - const CALLER: &str = "arn:aws:iam::123456789012:user/alice"; + /// Allow alice to assume (sts:AssumeRole). + fn allow_alice() -> Value { + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}) + } // ---- principal_matches ------------------------------------------------ @@ -440,10 +847,7 @@ mod tests { #[test] fn principal_aws_single_arn_matches_exact() { assert!(principal_matches(&json!({"AWS": CALLER}), CALLER)); - assert!(!principal_matches( - &json!({"AWS": "arn:aws:iam::123456789012:user/bob"}), - CALLER - )); + assert!(!principal_matches(&json!({"AWS": BOB}), CALLER)); } #[test] @@ -453,10 +857,22 @@ mod tests { #[test] fn principal_aws_list_matches_any_member() { - let p = json!({"AWS": ["arn:aws:iam::123456789012:user/bob", CALLER]}); - assert!(principal_matches(&p, CALLER)); - let p2 = json!({"AWS": ["arn:aws:iam::123456789012:user/bob"]}); - assert!(!principal_matches(&p2, CALLER)); + assert!(principal_matches(&json!({"AWS": [BOB, CALLER]}), CALLER)); + assert!(!principal_matches(&json!({"AWS": [BOB]}), CALLER)); + } + + #[test] + fn principal_account_root_matches_any_caller_in_account() { + let root = format!("arn:aws:iam::{ACCT}:root"); + assert!(principal_matches(&json!({"AWS": root}), CALLER)); + let other_root = "arn:aws:iam::999999999999:root"; + assert!(!principal_matches(&json!({"AWS": other_root}), CALLER)); + } + + #[test] + fn principal_bare_account_id_matches_any_caller_in_account() { + assert!(principal_matches(&json!({"AWS": ACCT}), CALLER)); + assert!(!principal_matches(&json!({"AWS": "999999999999"}), CALLER)); } #[test] @@ -468,7 +884,7 @@ mod tests { assert!(!principal_matches(&json!(null), CALLER)); } - // ---- effect_of -------------------------------------------------------- + // ---- effect_of (case-insensitive compare at call sites) --------------- #[test] fn effect_of_reads_string_or_defaults_empty() { @@ -478,90 +894,405 @@ mod tests { assert_eq!(effect_of(&json!({"Effect": 5})), ""); } - // ---- trust_decision: core regression --------------------------------- + #[test] + fn effect_lowercase_deny_blocks() { + // IAM accepts and enforces a lowercase "deny". + let stmts = vec![ + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), + json!({"Effect": "deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + // ---- action_matches --------------------------------------------------- + + #[test] + fn action_matches_exact_and_wildcards() { + let a = json!({"Action": "sts:AssumeRole"}); + assert!(action_matches(&a, "sts:AssumeRole")); + assert!(!action_matches(&a, "sts:TagSession")); + let star = json!({"Action": "*"}); + assert!(action_matches(&star, "sts:AssumeRole")); + let prefix = json!({"Action": "sts:*"}); + assert!(action_matches(&prefix, "sts:AssumeRole")); + let list = json!({"Action": ["sts:AssumeRole", "sts:TagSession"]}); + assert!(action_matches(&list, "sts:TagSession")); + let none = json!({"Principal": "*"}); + assert!(!action_matches(&none, "sts:AssumeRole")); + } + + #[test] + fn action_matches_not_action() { + let na = json!({"NotAction": "sts:TagSession"}); + assert!(action_matches(&na, "sts:AssumeRole")); + assert!(!action_matches(&na, "sts:TagSession")); + } + + // ---- trust_decision: Deny-over-Allow core ---------------------------- #[test] fn allow_grants_when_principal_matches() { - let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}})]; - assert!(trust_decision(&stmts, CALLER, &ctx())); + assert!(trust_decision(&[allow_alice()], CALLER, &ctx(), false)); } #[test] fn no_matching_allow_is_implicit_deny() { - let stmts = vec![json!({ - "Effect": "Allow", - "Principal": {"AWS": "arn:aws:iam::123456789012:user/bob"} - })]; - assert!(!trust_decision(&stmts, CALLER, &ctx())); + let stmts = + vec![json!({"Effect": "Allow", "Principal": {"AWS": BOB}, "Action": "sts:AssumeRole"})]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); } /// The ticket regression: an explicit Deny must override a broad Allow. - /// Before the fix, Deny statements were skipped entirely and the caller - /// was granted assumption via the `Allow *`. #[test] fn explicit_deny_overrides_allow_regardless_of_order() { - // Deny listed after Allow. let allow_then_deny = vec![ - json!({"Effect": "Allow", "Principal": "*"}), - json!({"Effect": "Deny", "Principal": {"AWS": CALLER}}), + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}), ]; - assert!( - !trust_decision(&allow_then_deny, CALLER, &ctx()), - "explicit Deny after Allow must block assumption" - ); + assert!(!trust_decision(&allow_then_deny, CALLER, &ctx(), false)); - // Deny listed before Allow. let deny_then_allow = vec![ - json!({"Effect": "Deny", "Principal": {"AWS": CALLER}}), - json!({"Effect": "Allow", "Principal": "*"}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}), + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), ]; - assert!( - !trust_decision(&deny_then_allow, CALLER, &ctx()), - "explicit Deny before Allow must block assumption" - ); + assert!(!trust_decision(&deny_then_allow, CALLER, &ctx(), false)); } #[test] fn deny_targeting_other_principal_does_not_block() { let stmts = vec![ - json!({"Effect": "Allow", "Principal": "*"}), - json!({"Effect": "Deny", "Principal": {"AWS": "arn:aws:iam::123456789012:user/bob"}}), + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), + json!({"Effect": "Deny", "Principal": {"AWS": BOB}, "Action": "sts:AssumeRole"}), ]; - assert!( - trust_decision(&stmts, CALLER, &ctx()), - "a Deny aimed at a different principal must not block this caller" - ); + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_on_account_root_blocks_caller() { + let root = format!("arn:aws:iam::{ACCT}:root"); + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": root}, "Action": "sts:AssumeRole"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_on_bare_account_id_blocks_caller() { + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": ACCT}, "Action": "sts:AssumeRole"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); } + // ---- conditional Deny ------------------------------------------------- + #[test] - fn conditional_deny_only_blocks_when_condition_matches() { - // Deny alice only when her PrincipalTag/team == "contractors". + fn conditional_deny_only_blocks_when_tag_matches() { let stmts = vec![ - json!({"Effect": "Allow", "Principal": "*"}), + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), json!({ "Effect": "Deny", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", "Condition": {"StringEquals": {"aws:PrincipalTag/team": "contractors"}} }), ]; - - // Tag matches → Deny applies → blocked. assert!(!trust_decision( &stmts, CALLER, - &ctx_with_tags(&[("team", "contractors")]) + &ctx_with_tags(&[("team", "contractors")]), + false )); - - // Tag does not match → Deny inert → Allow grants. assert!(trust_decision( &stmts, CALLER, - &ctx_with_tags(&[("team", "employees")]) + &ctx_with_tags(&[("team", "employees")]), + false )); } + #[test] + fn external_id_notequals_deny_blocks_when_external_id_absent() { + // Confused-deputy guard: Deny unless the caller presents the external + // id. With no external id the negated condition holds, so Deny blocks. + let stmts = vec![ + allow_alice(), + json!({ + "Effect": "Deny", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringNotEquals": {"sts:ExternalId": "secret"}} + }), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + assert!(!trust_decision(&stmts, CALLER, &ctx_ext("wrong"), false)); + assert!(trust_decision(&stmts, CALLER, &ctx_ext("secret"), false)); + } + + // ---- fail closed on an unparseable Deny ------------------------------- + + #[test] + fn deny_with_unparseable_condition_fails_closed() { + // An operator ExtendDB cannot evaluate must not silently drop the Deny. + let stmts = vec![ + allow_alice(), + json!({ + "Effect": "Deny", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"NotIpAddress": {"aws:SourceIp": "192.0.2.0/24"}} + }), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_with_unparseable_condition_does_not_grant() { + let stmts = vec![json!({ + "Effect": "Allow", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"NotIpAddress": {"aws:SourceIp": "192.0.2.0/24"}} + })]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + // ---- Action and session tags ----------------------------------------- + + #[test] + fn deny_tagsession_only_does_not_block_plain_assume() { + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:TagSession"}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_setsourceidentity_only_does_not_block_plain_assume() { + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:SetSourceIdentity"}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_tagsession_only_does_not_grant_plain_assume() { + let stmts = vec![ + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "sts:TagSession"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn session_tags_require_tagsession_allow() { + // Allow covers only sts:AssumeRole; passing session tags needs + // sts:TagSession, which no Allow grants, so the call is denied. + assert!(!trust_decision(&[allow_alice()], CALLER, &ctx(), true)); + // Allow covers both actions: granted. + let both = vec![json!({ + "Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"] + })]; + assert!(trust_decision(&both, CALLER, &ctx(), true)); + } + + #[test] + fn deny_tagsession_blocks_tagged_call_but_allows_plain() { + let stmts = vec![ + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": ["sts:AssumeRole", "sts:TagSession"]}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:TagSession"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), true)); + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + #[test] fn empty_statement_list_denies() { - assert!(!trust_decision(&[], CALLER, &ctx())); + assert!(!trust_decision(&[], CALLER, &ctx(), false)); + } + + // ---- statements_vec: single-object Statement -------------------------- + + #[test] + fn single_object_statement_is_one_statement() { + let doc = json!({"Version": "2012-10-17", "Statement": { + "Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole" + }}); + let stmts = statements_vec(&doc).expect("single object Statement"); + assert_eq!(stmts.len(), 1); + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn missing_statement_is_none() { + assert!(statements_vec(&json!({"Version": "2012-10-17"})).is_none()); + } + + // ---- validate_trust_policy: accept set -------------------------------- + + fn ok(doc: Value) { + assert!(validate_trust_policy(&doc).is_ok(), "should accept: {doc}"); + } + + fn rejected(doc: Value) { + assert!(validate_trust_policy(&doc).is_err(), "should reject: {doc}"); + } + + #[test] + fn validate_accepts_well_formed() { + ok(json!({"Version": "2012-10-17", "Statement": [allow_alice()]})); + } + + #[test] + fn validate_accepts_missing_version() { + ok(json!({"Statement": [allow_alice()]})); + } + + #[test] + fn validate_accepts_single_object_statement() { + ok(json!({"Version": "2012-10-17", "Statement": allow_alice()})); + } + + #[test] + fn validate_accepts_lowercase_effect() { + ok( + json!({"Statement": [{"Effect": "deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_accepts_numeric_condition_value() { + ok(json!({"Statement": [{ + "Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalTag/team": 5}} + }]})); + } + + #[test] + fn validate_accepts_unknown_condition_key() { + ok(json!({"Statement": [{ + "Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:NoSuchKey": "x"}} + }]})); + } + + #[test] + fn validate_accepts_known_iam_operator_extenddb_does_not_evaluate() { + // NotIpAddress is a valid IAM operator; IAM accepts the document. + ok(json!({"Statement": [{ + "Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"NotIpAddress": {"aws:SourceIp": "192.0.2.0/24"}} + }]})); + } + + #[test] + fn validate_accepts_aws_wildcard_principal_and_account() { + ok( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole"}]}), + ); + ok( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": ACCT}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_accepts_tagsession_and_setsourceidentity_actions() { + ok( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession", "sts:SetSourceIdentity"]}]}), + ); + } + + // ---- validate_trust_policy: reject set -------------------------------- + + #[test] + fn validate_rejects_missing_statement() { + rejected(json!({"Version": "2012-10-17"})); + } + + #[test] + fn validate_rejects_missing_effect() { + rejected( + json!({"Statement": [{"Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_rejects_invalid_effect() { + rejected( + json!({"Statement": [{"Effect": "Permit", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_rejects_not_principal() { + rejected( + json!({"Statement": [{"Effect": "Deny", "NotPrincipal": {"AWS": BOB}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_rejects_missing_principal() { + rejected(json!({"Statement": [{"Effect": "Deny", "Action": "sts:AssumeRole"}]})); + } + + #[test] + fn validate_rejects_bare_star_principal() { + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_rejects_wildcard_in_principal_arn() { + rejected(json!({"Statement": [{"Effect": "Deny", + "Principal": {"AWS": format!("arn:aws:iam::{ACCT}:user/dev-*")}, "Action": "sts:AssumeRole"}]})); + } + + #[test] + fn validate_rejects_missing_action() { + rejected(json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}}]})); + } + + #[test] + fn validate_rejects_sts_star_action() { + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "sts:*"}]}), + ); + } + + #[test] + fn validate_rejects_non_sts_action() { + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "dynamodb:GetItem"}]}), + ); + } + + #[test] + fn validate_rejects_resource_present() { + rejected( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", "Resource": "*"}]}), + ); + } + + #[test] + fn validate_rejects_unknown_condition_operator() { + rejected( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEqualz": {"aws:PrincipalTag/team": "contractors"}}}]}), + ); + } + + #[test] + fn validate_rejects_condition_not_object() { + rejected( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": "contractors"}}]}), + ); } } diff --git a/crates/server/src/management/iam_role.rs b/crates/server/src/management/iam_role.rs index 47e424ff7..e53bec101 100755 --- a/crates/server/src/management/iam_role.rs +++ b/crates/server/src/management/iam_role.rs @@ -66,11 +66,18 @@ pub async fn create_role( )); } - if body.trust_policy.get("Version").is_none() || body.trust_policy.get("Statement").is_none() { + if body.trust_policy.get("Statement").is_none() { return op_err_to_response(OpError::Validation( - "Trust policy must contain Version and Statement".to_owned(), + "Trust policy must contain a Statement".to_owned(), )); } + // Validate the trust document the way IAM validates an AssumeRole trust + // policy, so a document IAM would reject is not stored and then dropped at + // evaluation. The error text is ExtendDB's own; the accepted and rejected + // sets match IAM. + if let Err(msg) = super::assume_role::validate_trust_policy(&body.trust_policy) { + return op_err_to_response(OpError::Validation(format!("Invalid trust policy: {msg}"))); + } match state .catalog_store diff --git a/crates/storage-sqlite/src/management_store/mod.rs b/crates/storage-sqlite/src/management_store/mod.rs index 8f858413e..e9a9d5688 100644 --- a/crates/storage-sqlite/src/management_store/mod.rs +++ b/crates/storage-sqlite/src/management_store/mod.rs @@ -591,9 +591,10 @@ impl ManagementStore for SqliteCatalogStore { let account_id = account_id.to_owned(); let resource = resource.to_owned(); Box::pin(async move { - // Resolve the principal kind and name from the ARN resource part. - if let Some(rest) = resource.split(":user/").nth(1) { - let user_name = rest.trim_end_matches('/'); + // The server passes the ARN resource part, e.g. `user/`, + // `role/`, or `assumed-role//`. Match the same + // prefixes the other backends use so caller tags resolve on SQLite. + if let Some(user_name) = resource.strip_prefix("user/") { return sqlx::query_as( "SELECT tag_key, tag_value FROM iam_user_tags \ WHERE account_id = ? AND user_name = ? ORDER BY tag_key", @@ -610,12 +611,14 @@ impl ManagementStore for SqliteCatalogStore { // assumed-role/RoleName/SessionName or role/RoleName → role tags. let role_name = resource - .split(":assumed-role/") - .nth(1) - .or_else(|| resource.split(":role/").nth(1)) + .strip_prefix("assumed-role/") + .or_else(|| resource.strip_prefix("role/")) .map(|rest| rest.split('/').next().unwrap_or(rest)); if let Some(role_name) = role_name { + if role_name.is_empty() { + return Ok(Vec::new()); + } return sqlx::query_as( "SELECT tag_key, tag_value FROM iam_role_tags \ WHERE account_id = ? AND role_name = ? ORDER BY tag_key", From 32ff95477c5461105da92698477d3521b8a46595 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 17:08:12 +0000 Subject: [PATCH 03/58] test(auth): wire tests for trust-policy Deny through AssumeRole Adds TestTrustPolicyDeny to tests/test_auth_integration.py. Each expected status is the outcome IAM and STS returned for the same trust policy: Deny over Allow in both orders, a Deny on another principal, tag-conditioned Deny, the ExternalId Deny with no ExternalId sent, account root and bare account id principals, per-action matching on sts:TagSession, session tags without sts:TagSession, lowercase Effect, a single-object Statement, and the documents create_role must reject or accept. --- tests/test_auth_integration.py | 232 +++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 6ac4f2413..b72c819d0 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -743,3 +743,235 @@ def test_unauthenticated_create_table_rejected(self): ) err = exc_info.value.response["Error"] assert err["Code"] in ("UnrecognizedClientException", "InvalidSignatureException", "AccessDeniedException") + + +# --------------------------------------------------------------------------- +# Trust policy Deny evaluation and write-time validation +# --------------------------------------------------------------------------- + +# Expected outcomes match live IAM and STS. The assume +# endpoint returns 201 when the trust policy grants assumption and 403 when it +# denies. create_role returns 400 when the trust document is one IAM rejects. + +ALLOW = 201 +DENY = 403 +REJECT = 400 + + +def _trust(*statements: dict) -> dict: + return {"Version": "2012-10-17", "Statement": list(statements)} + + +def _stmt(effect: str, principal, action="sts:AssumeRole", condition=None, key="Principal") -> dict: + s: dict[str, Any] = {"Effect": effect, key: principal, "Action": action} + if condition is not None: + s["Condition"] = condition + return s + + +class TestTrustPolicyDeny: + """Trust-policy Deny evaluation matches live IAM, on every backend.""" + + @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 + + def _caller(self, tags: dict[str, str] | None = None) -> tuple[str, str]: + user = f"bbc-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_user(self.account_id, user, "TestPass123!") + assert resp.status_code == 201, resp.text + if tags: + resp = self.mgmt.tag_user(self.account_id, user, tags) + assert resp.status_code in (200, 201, 204), resp.text + return user, f"arn:aws:iam::{self.account_id}:user/{user}" + + def _assume(self, trust: dict, caller_arn: str, **assume_kwargs) -> int: + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, f"create_role: {resp.status_code} {resp.text}" + try: + resp = self.mgmt.assume_role( + self.account_id, role, caller_arn, "bb-session", **assume_kwargs + ) + return resp.status_code + finally: + self.mgmt.delete_role(self.account_id, role) + + def test_deny_overrides_allow_both_orders(self): + user, arn = self._caller() + try: + allow_then_deny = _trust(_stmt("Allow", {"AWS": "*"}), _stmt("Deny", {"AWS": arn})) + assert self._assume(allow_then_deny, arn) == DENY + deny_then_allow = _trust(_stmt("Deny", {"AWS": arn}), _stmt("Allow", {"AWS": "*"})) + assert self._assume(deny_then_allow, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_other_principal_does_not_block(self): + user, arn = self._caller() + other = f"arn:aws:iam::{self.account_id}:user/someone-else" + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}), _stmt("Deny", {"AWS": other})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_deny_matches_blocks(self): + # Exercises the caller-tag lookup in the store (all backends). + user, arn = self._caller(tags={"team": "contractors"}) + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalTag/team": "contractors"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_deny_no_match_allows(self): + # The tag resolves to "contractors", so a Deny gated on "employees" is + # inert and the Allow grants. If the store tag lookup were broken the + # tag would resolve to nothing and this would still allow, so pair it + # with test_tag_deny_matches_blocks. + user, arn = self._caller(tags={"team": "contractors"}) + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalTag/team": "employees"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_external_id_notequals_deny_blocks_when_absent(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringNotEquals": {"sts:ExternalId": "secret"}}), + ) + assert self._assume(trust, arn) == DENY + # Presenting the matching external id lifts the Deny. + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, resp.text + try: + # external_id is a separate assume parameter. + r2 = requests.post( + f"{self.mgmt.base_url}/accounts/{self.account_id}/roles/{role}/assume", + json={"caller_arn": arn, "session_name": "bb-session", + "duration_seconds": 3600, "external_id": "secret"}, + auth=self.mgmt.admin_auth, timeout=30, verify=self.mgmt.verify, + ) + assert r2.status_code == ALLOW, r2.text + finally: + self.mgmt.delete_role(self.account_id, role) + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_account_root_principal_deny_blocks(self): + user, arn = self._caller() + root = f"arn:aws:iam::{self.account_id}:root" + try: + trust = _trust(_stmt("Allow", {"AWS": arn}), _stmt("Deny", {"AWS": root})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_bare_account_id_principal_deny_blocks(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}), _stmt("Deny", {"AWS": self.account_id})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_tagsession_only_allows_plain_assume(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, action="sts:TagSession"), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_session_tags_without_tagsession_denied(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn})) + assert self._assume(trust, arn, session_tags={"env": "dev"}) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_session_tags_with_tagsession_allowed(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"])) + assert self._assume(trust, arn, session_tags={"env": "dev"}) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_lowercase_deny_enforced(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}), _stmt("deny", {"AWS": arn})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_single_object_statement_allows(self): + user, arn = self._caller() + try: + trust = {"Version": "2012-10-17", "Statement": _stmt("Allow", {"AWS": arn})} + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + @pytest.mark.parametrize("trust", [ + _trust(_stmt("Allow", "arn:aws:iam::111122223333:user/x")), # bare string ARN / non-object + _trust({"Principal": {"AWS": "arn:aws:iam::111122223333:user/x"}, "Action": "sts:AssumeRole"}), # missing Effect + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, key="NotPrincipal")), # NotPrincipal + _trust({"Effect": "Deny", "Action": "sts:AssumeRole"}), # missing Principal + _trust(_stmt("Allow", "*")), # bare "*" principal + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/bb-*"})), # wildcard in ARN + _trust({"Effect": "Deny", "Principal": {"AWS": "arn:aws:iam::111122223333:user/x"}}), # missing Action + _trust(_stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"}, action="sts:*")), # sts:* + _trust({"Effect": "Deny", "Principal": {"AWS": "arn:aws:iam::111122223333:user/x"}, + "Action": "sts:AssumeRole", "Resource": "*"}), # Resource present + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"StringEqualz": {"aws:PrincipalTag/team": "contractors"}})), # unknown operator + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"StringEquals": "contractors"})), # condition not object + ]) + def test_create_rejects_malformed_trust_policy(self, trust): + role = f"bbrej-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + if resp.status_code == 201: + self.mgmt.delete_role(self.account_id, role) + assert resp.status_code == REJECT, f"expected reject, got {resp.status_code}: {resp.text}" + + @pytest.mark.parametrize("trust", [ + {"Statement": [_stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"})]}, # missing Version + {"Version": "2012-10-17", "Statement": _stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"})}, # single object + _trust(_stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"}), + _stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"StringEquals": {"aws:PrincipalTag/team": 5}})), # numeric condition value + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"StringEquals": {"aws:NoSuchKey": "x"}})), # unknown condition key + ]) + def test_create_accepts_iam_accepted_trust_policy(self, trust): + role = f"bbacc-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + try: + assert resp.status_code == 201, f"expected accept, got {resp.status_code}: {resp.text}" + finally: + if resp.status_code == 201: + self.mgmt.delete_role(self.account_id, role) From d751f96458b52bdd6c03c6b20197edc2b4aad92f Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 17:28:13 +0000 Subject: [PATCH 04/58] docs(auth): describe trust-policy Deny evaluation and write validation Update the auth component design, the v3 design, the ADR and the admin guide to describe the trust-policy model: IAM-order Deny-over-Allow decided per exercised STS action, case-insensitive Effect, account-root and bare-account-id principals, fail-closed on a Deny whose condition does not parse, and write-time validation of the document. Remove the fail-open claim from the ADR. --- docs/adr/0022-trust-policy-deny-over-allow.md | 21 +++++++++-- docs/design/05-component-auth-v3.md | 6 +++- docs/design/05-component-auth.md | 29 ++++++++++++--- docs/manuals/05-admin-guide.md | 35 +++++++++++++++++++ 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/docs/adr/0022-trust-policy-deny-over-allow.md b/docs/adr/0022-trust-policy-deny-over-allow.md index 70fc68002..daf21f10b 100644 --- a/docs/adr/0022-trust-policy-deny-over-allow.md +++ b/docs/adr/0022-trust-policy-deny-over-allow.md @@ -89,9 +89,24 @@ implicitly denied. is no change for trust policies that contain only `Allow` statements. - **Follow-up:** the decision logic was factored into a store-free `trust_decision` helper (plus `statement_matches` / `effect_of`) so the - Deny-over-Allow ordering is unit-tested directly. A statement whose - `Condition` block fails to parse is treated as non-matching in both the `Deny` - and `Allow` phases, matching the pre-existing `Allow`-path handling. + Deny-over-Allow ordering is unit-tested directly. The decision is made per + exercised STS action: `sts:AssumeRole`, plus `sts:TagSession` when the call + passes session tags (ExtendDB does not implement source identity, so + `sts:SetSourceIdentity` is never exercised). `Effect` is compared + case-insensitively, `Action`/`NotAction` are matched, and account principals + (`{"AWS": ":root"}` and a bare account id) match any caller in the + account. A statement whose `Condition` block fails to parse is handled + asymmetrically: an unparseable `Allow` does not grant, and an unparseable + `Deny` fails closed, so it blocks rather than being silently dropped. A + malformed `Deny` therefore cannot grant assumption. +- **Write-time validation:** trust documents are validated when a role is + created, so a document IAM would reject (a missing or invalid `Effect`, + `NotPrincipal`, a missing `Principal`, a bare-string or wildcard-ARN + principal, a missing `Action`, an action outside the STS trust set, a + `Resource` field, or an unknown or malformed `Condition`) is not stored and + then dropped at evaluation. Documents IAM accepts (a missing `Version`, a + single-object `Statement`, numeric condition values, unknown condition keys) + are accepted. - **Commitment:** trust-policy and identity-policy evaluation should stay aligned on evaluation order; a future change to one should consider the other. diff --git a/docs/design/05-component-auth-v3.md b/docs/design/05-component-auth-v3.md index cbac69f91..341d2e0b8 100755 --- a/docs/design/05-component-auth-v3.md +++ b/docs/design/05-component-auth-v3.md @@ -391,7 +391,11 @@ extenddb manage --user admin --password assume-role \ Flow: 1. Load role, extract trust policy -2. Evaluate trust policy: caller ARN must match a Principal in an Allow statement, conditions must pass +2. Evaluate the trust policy in IAM order: a matching explicit Deny blocks + assumption, otherwise a matching Allow grants it, decided per exercised STS + action (sts:AssumeRole, plus sts:TagSession when session tags are passed). + Principal, Action/NotAction, and Condition must match; a Deny whose Condition + does not parse fails closed. 3. Generate temporary credentials (ASIA* access key + secret + session token) 4. Merge tags: role tags + session tags (session wins on conflict) 5. Store session in `iam_sessions` diff --git a/docs/design/05-component-auth.md b/docs/design/05-component-auth.md index fd2224363..fa5bfb10c 100755 --- a/docs/design/05-component-auth.md +++ b/docs/design/05-component-auth.md @@ -867,9 +867,14 @@ extenddb provides a local AssumeRole mechanism for testing role-based access pat 5. Build AssumeRoleContext from caller tags + SessionTags + ExternalId. -6. Evaluate trust policy: caller ARN must match a Principal in an Allow - statement, and all Condition blocks must pass against AssumeRoleContext. - If denied, return 403 AccessDenied. +6. Evaluate the trust policy in IAM order. The call exercises `sts:AssumeRole`, + and `sts:TagSession` as well when session tags are passed. For each + exercised action a matching explicit `Deny` blocks the call, and otherwise a + matching `Allow` grants it; with neither, assumption is implicitly denied. A + statement matches when its `Principal` matches the caller, its `Action` (or + `NotAction`) covers the action, and all `Condition` blocks pass against + AssumeRoleContext. A `Deny` whose `Condition` fails to parse fails closed (it + blocks). If the call is not granted, return 403 AccessDenied. 7. Generate temporary credentials: - New access_key_id (prefixed "ASIA" to match AWS convention) @@ -904,7 +909,23 @@ A role's trust policy controls who can assume it: } ``` -The trust policy is evaluated when AssumeRole is called. Only the `Principal` and `Condition` fields are checked — `Action` must be `sts:AssumeRole` and `Resource` is implicit (the role itself). +The trust policy is evaluated when AssumeRole is called, following IAM order: a +matching explicit `Deny` blocks assumption before any `Allow` is considered, and +the decision is made per exercised STS action (`sts:AssumeRole`, plus +`sts:TagSession` when session tags are passed). A statement matches on +`Principal`, on `Action`/`NotAction`, and on its `Condition` block. `Effect` is +compared case-insensitively. The `Resource` field is not used (the role itself +is implicit) and is rejected at write. + +`Principal` matching covers a single ARN, a list of ARNs, `{"AWS": "*"}`, a bare +account id, and an account root ARN (`arn:aws:iam:::root`); the last +two match any caller in that account. `NotPrincipal` is rejected at write. A +`Deny` whose `Condition` references an operator or key the evaluator does not +resolve fails closed, so it blocks rather than being silently dropped. Trust +documents are validated when the role is created: the documents IAM rejects for +a trust policy are rejected, and those it accepts (a missing `Version`, a +single-object `Statement`, numeric condition values, unknown condition keys) are +accepted. > **Note:** Trust policies use `"Action": "sts:AssumeRole"` for compatibility with real IAM policy documents, even though extenddb exposes AssumeRole via `/management/assume-role` rather than an STS endpoint. The policy engine matches this action string during trust policy evaluation regardless of the actual HTTP endpoint used. diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index 90fe53f89..9e8e91925 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -323,6 +323,41 @@ Access key prefixes: `AKIAEXTENDDB` (long-lived), `ASIAEXTENDDB` (temporary/Assu delete-role --account-id 123456789012 --role-name data-reader ``` +#### What a trust-policy Deny matches + +A role's trust policy decides who may assume the role. It is evaluated in IAM +order: a matching explicit `Deny` blocks assumption before any `Allow` is +considered, and with no matching `Allow` assumption is implicitly denied. The +decision is made for each STS action the call exercises: `sts:AssumeRole` +always, and `sts:TagSession` as well when the call passes session tags. Passing +session tags to a role whose trust policy does not allow `sts:TagSession` is +denied. ExtendDB does not implement source identity, so `sts:SetSourceIdentity` +is never part of the decision. + +A statement matches when its `Principal` matches the caller, its `Action` (or +`NotAction`) covers the action, and all `Condition` blocks pass. `Effect` is +compared case-insensitively (`"deny"` works). A `Principal` matches on an exact +ARN, a list of ARNs, `{"AWS": "*"}`, a bare account id, or an account root ARN +(`arn:aws:iam:::root`); the last two match every caller in that +account, for both `Allow` and `Deny`. + +What a trust-policy `Deny` does not do: + +- A `Deny` whose `Condition` uses a condition key ExtendDB does not resolve + (for example `aws:SourceIp`) or an operator it does not implement (for + example `IpAddress`) fails closed: it blocks assumption rather than being + dropped. Write a `Deny` only on keys you can resolve (`aws:PrincipalTag/*`, + `sts:ExternalId`) unless you intend an unconditional block. +- `NotPrincipal` is not allowed in a trust policy and is rejected when the role + is created. +- A `Resource` field is not allowed in a trust policy and is rejected at create. +- An `Action` outside the STS trust set (`sts:AssumeRole`, `sts:TagSession`, + `sts:SetSourceIdentity`), including `sts:*`, is rejected at create. +- A bare `"*"` principal string, or a wildcard inside a principal ARN + (`user/dev-*`), is rejected at create. A principal naming a user or role that + does not exist is accepted; the statement simply matches no caller (an `Allow` + on it grants nothing, a `Deny` on it blocks no one). + ### Policies Inline policies can be attached to users, groups, and roles: From 085a00251f165a494ece4e3baf101c1c4972a9d2 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 17:28:14 +0000 Subject: [PATCH 05/58] test(storage-sqlite): cover caller-tag lookup for user and role resources Add a unit test that create/tag a user and a role and asserts fetch_caller_tags resolves tags for user/, role/ and assumed-role//. --- crates/storage-sqlite/src/store.rs | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/storage-sqlite/src/store.rs b/crates/storage-sqlite/src/store.rs index 32eee9a51..055bac8f2 100644 --- a/crates/storage-sqlite/src/store.rs +++ b/crates/storage-sqlite/src/store.rs @@ -688,6 +688,63 @@ mod d1_write_lock_tests { .await; } + /// The caller-tag lookup resolves tags for a `user/` resource, which + /// is the form the server passes from the caller ARN. It also resolves a + /// `role/` and an `assumed-role//` resource to the + /// role's tags. + #[tokio::test] + async fn fetch_caller_tags_resolves_user_and_role() { + use extenddb_storage::management_store::ManagementStore; + let engine = engine().await; + let store = catalog_store(&engine); + + ManagementStore::create_user(&store, "000000000000", "tag-user", None) + .await + .expect("create user"); + ManagementStore::tag_user( + &store, + "000000000000", + "tag-user", + &[("team".to_owned(), "contractors".to_owned())], + ) + .await + .expect("tag user"); + let tags = ManagementStore::fetch_caller_tags(&store, "000000000000", "user/tag-user") + .await + .expect("fetch user tags"); + assert_eq!(tags, vec![("team".to_owned(), "contractors".to_owned())]); + + ManagementStore::create_role( + &store, + "000000000000", + "tag-role", + &json!({"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole"}]}), + ) + .await + .expect("create role"); + ManagementStore::tag_role( + &store, + "000000000000", + "tag-role", + &[("env".to_owned(), "prod".to_owned())], + ) + .await + .expect("tag role"); + let role_tags = ManagementStore::fetch_caller_tags(&store, "000000000000", "role/tag-role") + .await + .expect("fetch role tags"); + assert_eq!(role_tags, vec![("env".to_owned(), "prod".to_owned())]); + let assumed = ManagementStore::fetch_caller_tags( + &store, + "000000000000", + "assumed-role/tag-role/session1", + ) + .await + .expect("fetch assumed-role tags"); + assert_eq!(assumed, vec![("env".to_owned(), "prod".to_owned())]); + } + #[tokio::test] async fn management_create_access_key_waits_for_the_write_lock() { use extenddb_storage::management_store::ManagementStore; From add5fbd00d732976a37119bade87eccbae5ffe7d Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 17:40:32 +0000 Subject: [PATCH 06/58] test(abac): allow sts:TagSession in the session-tag role trust policy Passing session tags to AssumeRole now requires the trust policy to allow sts:TagSession, matching IAM. The session-tag ABAC tests passed tags against a trust policy that allowed only sts:AssumeRole, so the assume call is now denied. Add sts:TagSession to the trust policy so the tests exercise their intent. --- tests/test_abac.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_abac.py b/tests/test_abac.py index d854ea45b..3977c7219 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -351,7 +351,7 @@ def _setup_role_with_session_tag_policy(self, user, role): "Statement": [{ "Effect": "Allow", "Principal": {"AWS": caller_arn}, - "Action": "sts:AssumeRole", + "Action": ["sts:AssumeRole", "sts:TagSession"], }], } resp = self.mgmt.create_role(self.account_id, role, trust_policy) From e642d27359b154e5699989cafd9c08d1aac51439 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 19:59:33 +0000 Subject: [PATCH 07/58] fix(auth): resolve caller and session keys in trust policy, fail closed on unresolvable keys A trust-policy condition gated on a key the AssumeRole context could not resolve was treated as a genuinely absent key. A Deny with a positive operator was then dropped and a caller the policy denies was issued credentials, while an Allow or a negated Deny on such a key evaluated against the wrong default. Resolve the keys the AssumeRole context has: aws:PrincipalArn and aws:PrincipalAccount from the caller ARN, and aws:RequestTag/* and aws:TagKeys from the session tags. For a key the service always populates but the context cannot resolve, the condition is now unresolvable and the statement fails closed, so a Deny blocks and an Allow does not grant whatever the operator. Keys that are only conditionally present keep IAM absent-key semantics. The three-valued condition outcome is shared with identity-policy evaluation through a new ConditionContext method. --- crates/auth/src/policy/condition.rs | 68 +++++ crates/auth/src/policy/condition/tests.rs | 25 +- .../src/policy/condition/tests/three_case.rs | 151 +++++++++++ crates/auth/src/policy/context.rs | 154 +++++++++++- crates/server/src/management/assume_role.rs | 234 +++++++++++++++++- tests/test_auth_integration.py | 137 ++++++++++ 6 files changed, 748 insertions(+), 21 deletions(-) create mode 100644 crates/auth/src/policy/condition/tests/three_case.rs diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 53a905a92..41a2487bd 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -127,6 +127,74 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext } } +/// Three-valued outcome of evaluating a statement's condition block. +/// +/// A condition key falls into one of three cases, and each is treated +/// differently: +/// 1. The context resolves the key to a value: evaluate normally. +/// 2. The context resolves the key as absent in the same way the service would +/// have it absent: IAM absent-key semantics apply (including the rule that a +/// negated operator on an absent single-valued key matches). +/// 3. The service would populate the key but this context cannot resolve it +/// (`is_unresolvable_key`): the statement fails closed whatever the operator. +/// A `Deny` that depends on it blocks; an `Allow` that depends on it does not +/// grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConditionMatch { + /// All conditions are satisfied; the statement applies. + Match, + /// A condition is not satisfied; the statement does not apply. + NoMatch, + /// A condition depends on a key the service populates but this context + /// cannot resolve. The statement fails closed. + Unresolvable, +} + +/// 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. +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) + { + return ConditionMatch::Unresolvable; + } + if evaluate_condition(condition, context) { + ConditionMatch::Match + } else { + ConditionMatch::NoMatch + } +} + +/// Evaluate a statement's whole condition block (all conditions are ANDed). +/// +/// `NoMatch` dominates: if any condition is definitely not satisfied the +/// statement does not apply, even if another condition is unresolvable. Failing +/// that, any unresolvable condition makes the block `Unresolvable` (fail +/// closed). Otherwise every condition matched. +pub fn evaluate_statement_conditions( + conditions: &[Condition], + context: &impl ConditionContext, +) -> ConditionMatch { + let mut any_unresolvable = false; + for condition in conditions { + match evaluate_condition_match(condition, context) { + ConditionMatch::Match => {} + ConditionMatch::NoMatch => return ConditionMatch::NoMatch, + ConditionMatch::Unresolvable => any_unresolvable = true, + } + } + if any_unresolvable { + ConditionMatch::Unresolvable + } else { + ConditionMatch::Match + } +} + /// Expand IAM policy variables in a string value. /// /// Replaces `${variable}` patterns with their resolved values from the context. diff --git a/crates/auth/src/policy/condition/tests.rs b/crates/auth/src/policy/condition/tests.rs index abb870568..8f0d3b3d6 100644 --- a/crates/auth/src/policy/condition/tests.rs +++ b/crates/auth/src/policy/condition/tests.rs @@ -8,25 +8,37 @@ use crate::policy::context::ConditionContext; use std::collections::HashMap; /// Simple test context that maps keys to values. -struct TestContext(HashMap>); +struct TestContext { + values: HashMap>, + unresolvable: std::collections::HashSet, +} impl TestContext { fn new() -> Self { - Self(HashMap::new()) + Self { + values: HashMap::new(), + unresolvable: std::collections::HashSet::new(), + } } fn with(mut self, key: &str, values: Vec<&str>) -> Self { - self.0.insert( + self.values.insert( key.to_owned(), values.into_iter().map(ToOwned::to_owned).collect(), ); self } + + /// Mark `key` as service-populated but unresolvable (case 3). + fn unresolvable(mut self, key: &str) -> Self { + self.unresolvable.insert(key.to_owned()); + self + } } impl ConditionContext for TestContext { fn resolve_key(&self, key: &str) -> Option> { - self.0 + self.values .get(key) .map(|v| v.iter().map(|s| s.as_str()).collect()) } @@ -36,6 +48,10 @@ impl ConditionContext for TestContext { // production arity rule. matches!(key, "dynamodb:LeadingKeys" | "dynamodb:Attributes") } + + fn is_unresolvable_key(&self, key: &str) -> bool { + self.unresolvable.contains(key) + } } fn cond(op: ConditionOperator, key: &str, values: Vec<&str>) -> Condition { @@ -48,3 +64,4 @@ fn cond(op: ConditionOperator, key: &str, values: Vec<&str>) -> Condition { mod multivalued; mod operators; +mod three_case; diff --git a/crates/auth/src/policy/condition/tests/three_case.rs b/crates/auth/src/policy/condition/tests/three_case.rs new file mode 100644 index 000000000..f0aabd82f --- /dev/null +++ b/crates/auth/src/policy/condition/tests/three_case.rs @@ -0,0 +1,151 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for the three-case condition rule: a key the context resolves (case +//! 1), a key genuinely absent (case 2), and a key the service populates but the +//! context cannot resolve (case 3, fail closed). + +use super::*; +use crate::policy::condition::{ + ConditionMatch, evaluate_condition_match, evaluate_statement_conditions, +}; + +#[test] +fn present_key_matches_or_not() { + let ctx = TestContext::new().with("aws:PrincipalArn", vec!["arn:a"]); + let eq = cond( + ConditionOperator::StringEquals, + "aws:PrincipalArn", + vec!["arn:a"], + ); + assert_eq!(evaluate_condition_match(&eq, &ctx), ConditionMatch::Match); + let ne = cond( + ConditionOperator::StringEquals, + "aws:PrincipalArn", + vec!["arn:b"], + ); + assert_eq!(evaluate_condition_match(&ne, &ctx), ConditionMatch::NoMatch); +} + +#[test] +fn absent_key_keeps_iam_semantics() { + // Case 2: a genuinely absent single-valued key. A positive operator does + // not match; a negated operator matches (the negated-absent rule). + let ctx = TestContext::new(); + let pos = cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["x"], + ); + assert_eq!( + evaluate_condition_match(&pos, &ctx), + ConditionMatch::NoMatch + ); + let neg = cond( + ConditionOperator::StringNotEquals, + "aws:PrincipalTag/team", + vec!["x"], + ); + assert_eq!(evaluate_condition_match(&neg, &ctx), ConditionMatch::Match); +} + +#[test] +fn unresolvable_key_is_unresolvable_for_every_operator() { + // Case 3: the context reports the key as service-populated but unresolved. + // Every operator yields Unresolvable, including a negated one (so the + // negated-absent rule cannot fire) and Null. + let ctx = TestContext::new().unresolvable("aws:PrincipalOrgID"); + for op in [ + ConditionOperator::StringEquals, + ConditionOperator::StringNotEquals, + ConditionOperator::Null, + ] { + let c = cond(op, "aws:PrincipalOrgID", vec!["o-1"]); + assert_eq!( + evaluate_condition_match(&c, &ctx), + ConditionMatch::Unresolvable + ); + } +} + +#[test] +fn resolved_unresolvable_key_uses_its_value() { + // If the context both lists a key as unresolvable and resolves it, the + // resolved value wins (the guard only fires when resolve_key is None). + let ctx = TestContext::new() + .with("aws:PrincipalArn", vec!["arn:a"]) + .unresolvable("aws:PrincipalArn"); + let c = cond( + ConditionOperator::StringEquals, + "aws:PrincipalArn", + vec!["arn:a"], + ); + assert_eq!(evaluate_condition_match(&c, &ctx), ConditionMatch::Match); +} + +#[test] +fn statement_nomatch_dominates_unresolvable() { + // AND of conditions: a definite NoMatch means the statement does not apply, + // even if another condition is unresolvable. + let ctx = TestContext::new() + .with("aws:PrincipalTag/team", vec!["employees"]) + .unresolvable("aws:PrincipalOrgID"); + let conds = vec![ + cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["contractors"], + ), + cond( + ConditionOperator::StringEquals, + "aws:PrincipalOrgID", + vec!["o-1"], + ), + ]; + assert_eq!( + evaluate_statement_conditions(&conds, &ctx), + ConditionMatch::NoMatch + ); +} + +#[test] +fn statement_unresolvable_when_only_unresolvable_present() { + let ctx = TestContext::new() + .with("aws:PrincipalTag/team", vec!["contractors"]) + .unresolvable("aws:PrincipalOrgID"); + let conds = vec![ + cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["contractors"], + ), + cond( + ConditionOperator::StringEquals, + "aws:PrincipalOrgID", + vec!["o-1"], + ), + ]; + assert_eq!( + evaluate_statement_conditions(&conds, &ctx), + ConditionMatch::Unresolvable + ); +} + +#[test] +fn statement_all_match() { + let ctx = TestContext::new().with("aws:PrincipalTag/team", vec!["contractors"]); + let conds = vec![cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["contractors"], + )]; + assert_eq!( + evaluate_statement_conditions(&conds, &ctx), + ConditionMatch::Match + ); + // An empty condition block always matches. + assert_eq!( + evaluate_statement_conditions(&[], &ctx), + ConditionMatch::Match + ); +} diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 36384b8cb..633fdf040 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -30,6 +30,54 @@ pub trait ConditionContext { /// present. Verified against real AWS IAM (BR-7085). Returning `false` for an /// unknown key preserves the normal single-valued evaluation path. fn is_multivalued_key(&self, key: &str) -> bool; + + /// Whether `key` is one the service populates on a request of this kind but + /// which this context cannot resolve. + /// + /// This distinguishes a key that is genuinely absent (the service would + /// also have it absent, so IAM absent-key semantics apply) from a key the + /// service populates that ExtendDB has no value for. A statement gated on + /// an unresolvable key fails closed: a `Deny` blocks, an `Allow` does not + /// grant. The default is `false` (no unresolvable keys), so a context that + /// resolves every key it is asked about needs no override. + fn is_unresolvable_key(&self, _key: &str) -> bool { + false + } +} + +/// Global condition keys the AWS services always populate on a request. A +/// context that cannot resolve one of these treats it as unresolvable (fail +/// closed) rather than absent, so a negated operator on it cannot spuriously +/// match and an `Allow` gated on it cannot spuriously grant. +/// +/// The set is the single-valued `aws:` global keys that STS always populates on +/// an `AssumeRole` call and that IAM always populates on a `DynamoDB` +/// data-plane request from an IAM principal. Keys that are only conditionally +/// present (for example `aws:MultiFactorAuthPresent`, which is absent when the +/// caller did not use MFA, or the VPC and federation keys) are deliberately not +/// listed: they are genuinely absent for ExtendDB's caller model, and IAM +/// absent-key semantics match the service there (measured, case e27 vs e28). +/// Tag keys (`aws:PrincipalTag/*`, `aws:RequestTag/*`, `aws:TagKeys`, +/// `dynamodb:ResourceTag/*`), `sts:ExternalId`, and the `dynamodb:` request +/// keys are resolved (or genuinely absent) per context and are not listed here. +#[must_use] +pub fn is_service_populated_global_key(key: &str) -> bool { + matches!( + key, + "aws:PrincipalArn" + | "aws:PrincipalAccount" + | "aws:PrincipalOrgID" + | "aws:PrincipalOrgPaths" + | "aws:PrincipalType" + | "aws:userid" + | "aws:username" + | "aws:SourceIp" + | "aws:SecureTransport" + | "aws:CurrentTime" + | "aws:EpochTime" + | "aws:RequestedRegion" + | "aws:UserAgent" + ) } /// Request parameters extracted from a `DynamoDB` operation for condition evaluation. @@ -146,31 +194,67 @@ impl ConditionContext for RequestContext { /// Context for evaluating trust policy conditions during `AssumeRole`. /// -/// Trust policies can reference `aws:PrincipalTag/*` and `sts:ExternalId`. -/// DynamoDB-specific keys are not applicable. +/// Trust policies can reference `aws:PrincipalTag/*`, `sts:ExternalId`, the +/// caller identity keys `aws:PrincipalArn` / `aws:PrincipalAccount`, and the +/// session-tag keys `aws:RequestTag/*` / `aws:TagKeys`. Other global keys STS +/// populates on an `AssumeRole` call (for example `aws:PrincipalOrgID`, +/// `aws:SourceIp`, `aws:MultiFactorAuthPresent`) are not resolvable here and +/// are reported as unresolvable (fail closed). `DynamoDB`-specific keys are not +/// applicable. #[derive(Debug)] pub struct AssumeRoleContext { /// Tags on the calling principal. pub principal_tags: HashMap, /// The external ID provided in the `AssumeRole` call (if any). pub external_id: Option, + /// The caller ARN (`aws:PrincipalArn`). + pub principal_arn: String, + /// The caller's account id (`aws:PrincipalAccount`), the account field of + /// the caller ARN. `None` when the ARN has no account field. + pub principal_account: Option, + /// Session tags passed on the call (`aws:RequestTag/*`, and their keys for + /// `aws:TagKeys`). + pub request_tags: HashMap, } impl ConditionContext for AssumeRoleContext { fn resolve_key(&self, key: &str) -> Option> { if let Some(tag_key) = key.strip_prefix("aws:PrincipalTag/") { self.principal_tags.get(tag_key).map(|v| vec![v.as_str()]) - } else if key == "sts:ExternalId" { - self.external_id.as_deref().map(|v| vec![v]) + } else if let Some(tag_key) = key.strip_prefix("aws:RequestTag/") { + self.request_tags.get(tag_key).map(|v| vec![v.as_str()]) } else { - None + match key { + "sts:ExternalId" => self.external_id.as_deref().map(|v| vec![v]), + "aws:PrincipalArn" => Some(vec![self.principal_arn.as_str()]), + "aws:PrincipalAccount" => self.principal_account.as_deref().map(|v| vec![v]), + "aws:TagKeys" => { + if self.request_tags.is_empty() { + None + } else { + Some(self.request_tags.keys().map(String::as_str).collect()) + } + } + _ => None, + } } } - fn is_multivalued_key(&self, _key: &str) -> bool { - // Trust-policy keys (`aws:PrincipalTag/*`, `sts:ExternalId`) are all + fn is_multivalued_key(&self, key: &str) -> bool { + // `aws:TagKeys` is the set of session-tag keys (`ArrayOfString`); every + // other trust-policy key (`aws:PrincipalTag/*`, `aws:RequestTag/*`, + // `sts:ExternalId`, `aws:PrincipalArn`, `aws:PrincipalAccount`) is // single-valued. - false + key == "aws:TagKeys" + } + + fn is_unresolvable_key(&self, key: &str) -> bool { + // Keys this context resolves (tags, external id, principal ARN and + // account) are never reported here (the caller only consults this when + // `resolve_key` returned `None`, i.e. the key is genuinely absent for a + // tag or external id). Any other global key STS populates on an + // `AssumeRole` call is unresolvable, so a Deny gated on it fails closed. + is_service_populated_global_key(key) } } @@ -255,8 +339,62 @@ mod tests { let ctx = AssumeRoleContext { principal_tags: HashMap::new(), external_id: Some("ext-123".to_owned()), + principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), + principal_account: Some("123456789012".to_owned()), + request_tags: HashMap::new(), }; assert_eq!(ctx.resolve_key("sts:ExternalId"), Some(vec!["ext-123"])); assert_eq!(ctx.resolve_key("dynamodb:LeadingKeys"), None); } + + #[test] + fn assume_role_context_principal_arn_and_account() { + let ctx = AssumeRoleContext { + principal_tags: HashMap::new(), + external_id: None, + principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), + principal_account: Some("123456789012".to_owned()), + request_tags: HashMap::new(), + }; + assert_eq!( + ctx.resolve_key("aws:PrincipalArn"), + Some(vec!["arn:aws:iam::123456789012:user/alice"]) + ); + assert_eq!( + ctx.resolve_key("aws:PrincipalAccount"), + Some(vec!["123456789012"]) + ); + // A global key STS populates but this context cannot resolve is + // unresolvable (fail closed), not absent. + assert_eq!(ctx.resolve_key("aws:PrincipalOrgID"), None); + assert!(ctx.is_unresolvable_key("aws:PrincipalOrgID")); + assert!(!ctx.is_unresolvable_key("sts:ExternalId")); + assert!(!ctx.is_unresolvable_key("aws:PrincipalTag/team")); + } + + #[test] + fn assume_role_context_request_tags_and_tag_keys() { + let ctx = AssumeRoleContext { + principal_tags: HashMap::new(), + external_id: None, + principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), + principal_account: Some("123456789012".to_owned()), + request_tags: HashMap::from([("env".to_owned(), "prod".to_owned())]), + }; + assert_eq!(ctx.resolve_key("aws:RequestTag/env"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:RequestTag/missing"), None); + assert_eq!(ctx.resolve_key("aws:TagKeys"), Some(vec!["env"])); + assert!(ctx.is_multivalued_key("aws:TagKeys")); + // With no session tags, aws:TagKeys is genuinely absent, not + // unresolvable. + let empty = AssumeRoleContext { + principal_tags: HashMap::new(), + external_id: None, + principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), + principal_account: Some("123456789012".to_owned()), + request_tags: HashMap::new(), + }; + assert_eq!(empty.resolve_key("aws:TagKeys"), None); + assert!(!empty.is_unresolvable_key("aws:TagKeys")); + } } diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index 682c6c146..5ececd5fc 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -20,7 +20,7 @@ use super::auth::authenticate_admin; use super::crypto::{ encrypt_secret, generate_secret_key, generate_session_key_id, generate_session_token, }; -use extenddb_auth::policy::condition::evaluate_condition; +use extenddb_auth::policy::condition::{ConditionMatch, evaluate_statement_conditions}; use extenddb_auth::policy::context::AssumeRoleContext; use extenddb_auth::policy::document::parse_conditions; @@ -147,8 +147,10 @@ pub async fn assume_role( // Session tags passed on the call require sts:TagSession in the trust // policy, so the STS action set the decision checks depends on their - // presence. + // presence. They also populate aws:RequestTag/* and aws:TagKeys for + // condition evaluation. let has_session_tags = request.session_tags.as_ref().is_some_and(value_has_entries); + let request_tags = parse_session_tags(request.session_tags.as_ref()); // Evaluate trust policy against the provided caller ARN. if !evaluate_trust_policy( @@ -156,6 +158,7 @@ pub async fn assume_role( caller_arn, request.external_id.as_deref(), has_session_tags, + request_tags, &*state.catalog_store, ) .await @@ -301,6 +304,7 @@ async fn evaluate_trust_policy( caller_arn: &str, external_id: Option<&str>, has_session_tags: bool, + request_tags: std::collections::HashMap, store: &dyn extenddb_storage::CatalogStore, ) -> bool { let Some(statements) = statements_vec(trust_policy) else { @@ -311,6 +315,9 @@ async fn evaluate_trust_policy( let context = AssumeRoleContext { principal_tags, external_id: external_id.map(String::from), + principal_arn: caller_arn.to_owned(), + principal_account: caller_account(caller_arn).map(ToOwned::to_owned), + request_tags, }; trust_decision(&statements, caller_arn, &context, has_session_tags) @@ -425,13 +432,14 @@ enum ConditionOutcome { fn evaluate_conditions(stmt: &Value, context: &AssumeRoleContext) -> ConditionOutcome { let normalized = normalize_condition_scalars(&stmt["Condition"]); match parse_conditions(&normalized) { - Ok(conds) => { - if conds.iter().all(|c| evaluate_condition(c, context)) { - ConditionOutcome::Pass - } else { - ConditionOutcome::Fail - } - } + Ok(conds) => match evaluate_statement_conditions(&conds, context) { + ConditionMatch::Match => ConditionOutcome::Pass, + ConditionMatch::NoMatch => ConditionOutcome::Fail, + // A condition on a key STS populates but ExtendDB cannot resolve + // here fails closed, the same as an operator the evaluator cannot + // parse: a Deny blocks, an Allow does not grant. + ConditionMatch::Unresolvable => ConditionOutcome::Unparseable, + }, Err(_) => ConditionOutcome::Unparseable, } } @@ -533,6 +541,45 @@ fn value_has_entries(v: &Value) -> bool { } } +/// Parse the request `session_tags` into a key/value map for condition +/// evaluation (`aws:RequestTag/*`, `aws:TagKeys`). Accepts the object form +/// `{"env": "prod"}` and the list form `[{"Key": "env", "Value": "prod"}]`. +/// Scalar values are coerced to their string form; non-scalar values are +/// skipped. +fn parse_session_tags(session_tags: Option<&Value>) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + match session_tags { + Some(Value::Object(obj)) => { + for (k, v) in obj { + if let Some(s) = scalar_to_string(v) { + map.insert(k.clone(), s); + } + } + } + Some(Value::Array(arr)) => { + for entry in arr { + let key = entry.get("Key").and_then(Value::as_str); + let value = entry.get("Value").and_then(scalar_to_string); + if let (Some(k), Some(v)) = (key, value) { + map.insert(k.to_owned(), v); + } + } + } + _ => {} + } + map +} + +/// A JSON scalar (string, number, bool) as its string form; `None` otherwise. +fn scalar_to_string(v: &Value) -> Option { + match v { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + /// Coerce scalar condition values (numbers, booleans) to strings, matching /// IAM's handling of condition values, so a numeric or boolean value parses and /// evaluates as its string form rather than being rejected. @@ -812,6 +859,9 @@ mod tests { AssumeRoleContext { principal_tags: HashMap::new(), external_id: None, + principal_arn: CALLER.to_owned(), + principal_account: Some(ACCT.to_owned()), + request_tags: HashMap::new(), } } @@ -819,6 +869,9 @@ mod tests { AssumeRoleContext { principal_tags: HashMap::new(), external_id: Some(id.to_owned()), + principal_arn: CALLER.to_owned(), + principal_account: Some(ACCT.to_owned()), + request_tags: HashMap::new(), } } @@ -829,6 +882,22 @@ mod tests { .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) .collect(), external_id: None, + principal_arn: CALLER.to_owned(), + principal_account: Some(ACCT.to_owned()), + request_tags: HashMap::new(), + } + } + + fn ctx_with_request_tags(tags: &[(&str, &str)]) -> AssumeRoleContext { + AssumeRoleContext { + principal_tags: HashMap::new(), + external_id: None, + principal_arn: CALLER.to_owned(), + principal_account: Some(ACCT.to_owned()), + request_tags: tags + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(), } } @@ -1059,6 +1128,153 @@ mod tests { assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); } + // ---- resolved keys: PrincipalArn / PrincipalAccount ------------------- + + #[test] + fn deny_principalarn_eq_self_blocks() { + // x04: Deny {"AWS":"*"} if StringEquals aws:PrincipalArn = caller. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalArn": CALLER}}}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_principalarn_notequals_self_does_not_block() { + // x07: Deny {"AWS":"*"} if StringNotEquals aws:PrincipalArn = caller. + // The caller IS alice, so the negated condition is false and the Deny + // does not apply; the Allow grants. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringNotEquals": {"aws:PrincipalArn": CALLER}}}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_gate_principalarn_grants() { + // x01: Allow {"AWS":"*"} if StringEquals aws:PrincipalArn = caller. + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": "*"}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalArn": CALLER}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_gate_principalaccount_grants() { + // x08: Allow {"AWS":"*"} if StringEquals aws:PrincipalAccount = acct. + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": "*"}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalAccount": ACCT}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_principalaccount_notequals_in_account_does_not_block() { + // x09: Deny {"AWS":"*"} if StringNotEquals aws:PrincipalAccount = acct. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringNotEquals": {"aws:PrincipalAccount": ACCT}}}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + // ---- resolved keys: RequestTag / TagKeys (session tags) --------------- + + #[test] + fn deny_requesttag_match_blocks_tagged_call() { + // x17: session tag env=prod, Deny if StringEquals aws:RequestTag/env. + let stmts = vec![ + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"]}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Condition": {"StringEquals": {"aws:RequestTag/env": "prod"}}}), + ]; + assert!(!trust_decision( + &stmts, + CALLER, + &ctx_with_request_tags(&[("env", "prod")]), + true + )); + } + + #[test] + fn allow_gate_requesttag_grants_tagged_call() { + // x18: session tag env=dev, Allow if StringEquals aws:RequestTag/env. + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Condition": {"StringEquals": {"aws:RequestTag/env": "dev"}}})]; + assert!(trust_decision( + &stmts, + CALLER, + &ctx_with_request_tags(&[("env", "dev")]), + true + )); + } + + #[test] + fn deny_tagkeys_foranyvalue_blocks_tagged_call() { + // x19: session tag env=prod, Deny if ForAnyValue:StringEquals + // aws:TagKeys = env. + let stmts = vec![ + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"]}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Condition": {"ForAnyValue:StringEquals": {"aws:TagKeys": "env"}}}), + ]; + assert!(!trust_decision( + &stmts, + CALLER, + &ctx_with_request_tags(&[("env", "prod")]), + true + )); + } + + // ---- case 3: a key STS populates but ExtendDB cannot resolve ---------- + + #[test] + fn deny_on_unresolvable_key_fails_closed() { + // x13: Deny {"AWS":"*"} if StringEquals aws:PrincipalOrgID = org. + // ExtendDB cannot resolve the org id, so the Deny fails closed (blocks). + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-abc123"}}}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_negated_on_unresolvable_key_fails_closed() { + // x12: Deny {"AWS":"*"} if StringNotEquals aws:PrincipalOrgID = org. + // An unresolvable key fails closed regardless of operator, so the Deny + // blocks (this over-denies relative to IAM, the documented safe + // direction) rather than the negated-absent rule spuriously granting. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringNotEquals": {"aws:PrincipalOrgID": "o-abc123"}}}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_gate_on_unresolvable_key_does_not_grant() { + // x11: Allow {"AWS":"*"} if StringEquals aws:PrincipalOrgID = org. + // The Allow is gated on an unresolvable key, so it does not grant and no + // other statement allows: implicit deny. + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": "*"}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-abc123"}}})]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + // ---- Action and session tags ----------------------------------------- #[test] diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index b72c819d0..7d40f68ba 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -935,6 +935,143 @@ def test_single_object_statement_allows(self): finally: self.mgmt.delete_user(self.account_id, user) + # -- Resolved caller-identity keys: aws:PrincipalArn / aws:PrincipalAccount + + def test_deny_principalarn_eq_self_blocks(self): + # A Deny on a broad principal gated on the caller's own ARN blocks. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalArn": arn}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_principalarn_notequals_self_does_not_block(self): + # StringNotEquals on the caller's own ARN is false, so the Deny is inert. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringNotEquals": {"aws:PrincipalArn": arn}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gate_principalarn_grants(self): + # Restrict a broad principal to one ARN: the matching caller is granted. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalArn": arn}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gate_principalaccount_grants(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalAccount": self.account_id}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_principalaccount_notequals_in_account_does_not_block(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringNotEquals": {"aws:PrincipalAccount": self.account_id}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + # -- Resolved session-tag keys: aws:RequestTag/* and aws:TagKeys --------- + + def test_deny_requesttag_match_blocks(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"]), + _stmt("Deny", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"], + condition={"StringEquals": {"aws:RequestTag/env": "prod"}}), + ) + assert self._assume(trust, arn, session_tags={"env": "prod"}) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gate_requesttag_grants(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"], + condition={"StringEquals": {"aws:RequestTag/env": "dev"}})) + assert self._assume(trust, arn, session_tags={"env": "dev"}) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_tagkeys_foranyvalue_blocks(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"]), + _stmt("Deny", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"], + condition={"ForAnyValue:StringEquals": {"aws:TagKeys": "env"}}), + ) + assert self._assume(trust, arn, session_tags={"env": "prod"}) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + # -- Case 3: a key STS populates but ExtendDB cannot resolve (fail closed) + + def test_deny_on_unresolvable_org_key_fails_closed(self): + # A Deny gated on aws:PrincipalOrgID, which ExtendDB cannot resolve, + # fails closed and blocks. This matches IAM when the key would match. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalOrgID": "o-example1"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gate_on_unresolvable_org_key_does_not_grant(self): + # An Allow gated solely on aws:PrincipalOrgID does not grant, because the + # key is unresolvable. ExtendDB denies where IAM would allow; this is the + # documented fail-closed divergence for keys ExtendDB does not evaluate. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalOrgID": "o-example1"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_negated_on_unresolvable_org_key_fails_closed(self): + # StringNotEquals on an unresolvable key must not spuriously grant via + # the negated-absent rule: the Deny fails closed and blocks (ExtendDB + # over-denies here relative to IAM, the documented safe direction). + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringNotEquals": {"aws:PrincipalOrgID": "o-example1"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + @pytest.mark.parametrize("trust", [ _trust(_stmt("Allow", "arn:aws:iam::111122223333:user/x")), # bare string ARN / non-object _trust({"Principal": {"AWS": "arn:aws:iam::111122223333:user/x"}, "Action": "sts:AssumeRole"}), # missing Effect From 3aca26959d6fa3662877175df271d5d9e98a7a49 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 20:14:36 +0000 Subject: [PATCH 08/58] fix(auth): resolve tag condition keys case-insensitively The service resolves aws:PrincipalTag/, aws:RequestTag/ and dynamodb:ResourceTag/ without regard to the case of . ExtendDB matched the key case-sensitively, so a policy naming a tag key in a case that differed from the stored tag read the key as absent. A Deny gated on such a key was not applied, and with the negated-operator-on-absent rule an Allow over-granted and a Deny over-denied on a case-mismatched key. Resolve tag keys case-insensitively in both the identity RequestContext and the AssumeRoleContext. Tag values stay case-sensitive. --- crates/auth/src/policy/context.rs | 63 +++++++++++++++++++++++++++++-- tests/test_auth_integration.py | 50 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 633fdf040..0f997e00e 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -60,6 +60,19 @@ pub trait ConditionContext { /// Tag keys (`aws:PrincipalTag/*`, `aws:RequestTag/*`, `aws:TagKeys`, /// `dynamodb:ResourceTag/*`), `sts:ExternalId`, and the `dynamodb:` request /// keys are resolved (or genuinely absent) per context and are not listed here. +/// Look up a tag map by key, case-insensitively on the key. +/// +/// AWS resolves tag condition keys (`aws:PrincipalTag/`, `aws:RequestTag/`, +/// `dynamodb:ResourceTag/`) without regard to the case of ``; a tag stored +/// as `Team` is matched by a policy naming `team`. Tag values stay +/// case-sensitive. IAM forbids two tag keys that differ only in case, so a +/// case-insensitive match is unambiguous. +fn ci_tag_lookup<'a>(map: &'a HashMap, tag_key: &str) -> Option> { + map.iter() + .find(|(k, _)| k.eq_ignore_ascii_case(tag_key)) + .map(|(_, v)| vec![v.as_str()]) +} + #[must_use] pub fn is_service_populated_global_key(key: &str) -> bool { matches!( @@ -155,9 +168,9 @@ impl RequestContext { impl ConditionContext for RequestContext { fn resolve_key(&self, key: &str) -> Option> { if let Some(tag_key) = key.strip_prefix("aws:PrincipalTag/") { - self.principal_tags.get(tag_key).map(|v| vec![v.as_str()]) + ci_tag_lookup(&self.principal_tags, tag_key) } else if let Some(tag_key) = key.strip_prefix("dynamodb:ResourceTag/") { - self.resource_tags.get(tag_key).map(|v| vec![v.as_str()]) + ci_tag_lookup(&self.resource_tags, tag_key) } else { match key { "dynamodb:LeadingKeys" => self @@ -220,9 +233,9 @@ pub struct AssumeRoleContext { impl ConditionContext for AssumeRoleContext { fn resolve_key(&self, key: &str) -> Option> { if let Some(tag_key) = key.strip_prefix("aws:PrincipalTag/") { - self.principal_tags.get(tag_key).map(|v| vec![v.as_str()]) + ci_tag_lookup(&self.principal_tags, tag_key) } else if let Some(tag_key) = key.strip_prefix("aws:RequestTag/") { - self.request_tags.get(tag_key).map(|v| vec![v.as_str()]) + ci_tag_lookup(&self.request_tags, tag_key) } else { match key { "sts:ExternalId" => self.external_id.as_deref().map(|v| vec![v]), @@ -291,6 +304,48 @@ mod tests { ); } + #[test] + fn request_context_tag_key_is_case_insensitive() { + // The service resolves the tag key case-insensitively; the value stays + // case-sensitive. Stored `Team`, policy names `team`. + let ctx = RequestContext::build( + HashMap::from([("Team".to_owned(), "contractors".to_owned())]), + HashMap::from([("Env".to_owned(), "Prod".to_owned())]), + false, + RequestParams::default(), + ); + assert_eq!( + ctx.resolve_key("aws:PrincipalTag/team"), + Some(vec!["contractors"]) + ); + assert_eq!( + ctx.resolve_key("aws:PrincipalTag/TEAM"), + Some(vec!["contractors"]) + ); + assert_eq!( + ctx.resolve_key("dynamodb:ResourceTag/env"), + Some(vec!["Prod"]) + ); + // The value is not case-folded, only the key. + assert_eq!(ctx.resolve_key("aws:PrincipalTag/missing"), None); + } + + #[test] + fn assume_role_context_tag_key_is_case_insensitive() { + let ctx = AssumeRoleContext { + principal_tags: HashMap::from([("Team".to_owned(), "contractors".to_owned())]), + external_id: None, + principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), + principal_account: Some("123456789012".to_owned()), + request_tags: HashMap::from([("Env".to_owned(), "prod".to_owned())]), + }; + assert_eq!( + ctx.resolve_key("aws:PrincipalTag/team"), + Some(vec!["contractors"]) + ); + assert_eq!(ctx.resolve_key("aws:RequestTag/env"), Some(vec!["prod"])); + } + #[test] fn request_context_leading_keys() { let ctx = RequestContext::build( diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 7d40f68ba..176480a1a 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1072,6 +1072,56 @@ def test_deny_negated_on_unresolvable_org_key_fails_closed(self): finally: self.mgmt.delete_user(self.account_id, user) + # -- Tag key is resolved case-insensitively (stored `Team`, policy `team`) -- + + def test_tag_key_case_insensitive_positive_deny_blocks(self): + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalTag/team": "contractors"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_key_case_insensitive_positive_allow_grants(self): + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalTag/team": "contractors"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_key_case_insensitive_negated_allow_does_not_overgrant(self): + # The tag resolves case-insensitively to "contractors", so StringNotEquals + # is false and the Allow does not grant. If the key were matched + # case-sensitively the tag would read as absent, the negated-absent rule + # would fire, and the excluded contractor would be granted. + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringNotEquals": {"aws:PrincipalTag/team": "contractors"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_key_case_insensitive_negated_deny_does_not_overdeny(self): + # StringNotEquals is false on the resolved tag, so the Deny is inert and + # the Allow grants. Case-sensitive matching would over-deny here. + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringNotEquals": {"aws:PrincipalTag/team": "contractors"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + @pytest.mark.parametrize("trust", [ _trust(_stmt("Allow", "arn:aws:iam::111122223333:user/x")), # bare string ARN / non-object _trust({"Principal": {"AWS": "arn:aws:iam::111122223333:user/x"}, "Action": "sts:AssumeRole"}), # missing Effect From 421a03bda7f9241cf6e41ef476685a10557c5d24 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 20:53:45 +0000 Subject: [PATCH 09/58] fix(auth): fail closed on identity-policy keys ExtendDB cannot resolve The shared condition change makes a negated operator on an absent single-valued key match. On the identity path a global key the service populates but ExtendDB does not resolve, for example aws:PrincipalOrgID, was treated as absent, so a negated operator spuriously matched: an Allow over-granted and a Deny evaluated against the wrong default. Resolve aws:PrincipalArn and aws:PrincipalAccount in the DynamoDB RequestContext from the caller identity. Treat the other global keys the service always populates as unresolvable, so a statement gated on one fails closed: a Deny blocks and an Allow does not grant, whatever the operator. The evaluator now applies a Deny when its conditions match or are unresolvable, and grants an Allow only when its conditions definitely match. --- crates/auth/src/policy/context.rs | 23 ++++ crates/auth/src/policy/evaluator.rs | 38 ++++-- crates/auth/src/policy/evaluator/tests.rs | 25 +++- .../src/policy/evaluator/tests/case_three.rs | 122 ++++++++++++++++++ crates/server/src/authorization.rs | 8 ++ tests/test_abac.py | 100 ++++++++++++++ 6 files changed, 301 insertions(+), 15 deletions(-) create mode 100644 crates/auth/src/policy/evaluator/tests/case_three.rs diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 0f997e00e..7c6939624 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -110,6 +110,10 @@ pub struct RequestParams { pub return_consumed_capacity: Option, /// The enclosing operation for batch/transact sub-operations. pub enclosing_operation: Option, + /// The caller ARN (`aws:PrincipalArn`), when known. + pub principal_arn: Option, + /// The caller's account id (`aws:PrincipalAccount`), when known. + pub principal_account: Option, } /// Context for evaluating conditions on `DynamoDB` operations. @@ -136,6 +140,10 @@ pub struct RequestContext { pub full_table_scan: Option, /// The enclosing operation for batch/transact sub-operations. pub enclosing_operation: Option, + /// The caller ARN (`aws:PrincipalArn`), when known. + pub principal_arn: Option, + /// The caller's account id (`aws:PrincipalAccount`), when known. + pub principal_account: Option, } impl RequestContext { @@ -161,6 +169,8 @@ impl RequestContext { return_consumed_capacity: params.return_consumed_capacity, full_table_scan: if is_scan { Some(true) } else { None }, enclosing_operation: params.enclosing_operation, + principal_arn: params.principal_arn, + principal_account: params.principal_account, } } } @@ -192,6 +202,8 @@ impl ConditionContext for RequestContext { "dynamodb:EnclosingOperation" => { self.enclosing_operation.as_deref().map(|v| vec![v]) } + "aws:PrincipalArn" => self.principal_arn.as_deref().map(|v| vec![v]), + "aws:PrincipalAccount" => self.principal_account.as_deref().map(|v| vec![v]), _ => None, } } @@ -203,6 +215,17 @@ impl ConditionContext for RequestContext { // single-valued. Verified against real AWS IAM (BR-7085). matches!(key, "dynamodb:LeadingKeys" | "dynamodb:Attributes") } + + fn is_unresolvable_key(&self, key: &str) -> bool { + // A global key the service always populates on a data-plane request but + // that this context does not resolve (for example aws:PrincipalOrgID, + // aws:SourceIp) is unresolvable, so a Deny on it fails closed and an + // Allow on it does not grant. aws:PrincipalArn and aws:PrincipalAccount + // are resolved above, so they never reach here. Conditionally-present + // keys (MFA, VPC, federation) are not in the set and keep absent-key + // semantics. + is_service_populated_global_key(key) + } } /// Context for evaluating trust policy conditions during `AssumeRole`. diff --git a/crates/auth/src/policy/evaluator.rs b/crates/auth/src/policy/evaluator.rs index b49c440ff..df321dedf 100644 --- a/crates/auth/src/policy/evaluator.rs +++ b/crates/auth/src/policy/evaluator.rs @@ -7,7 +7,7 @@ //! session policy → identity allow → implicit deny. This is the same algorithm //! used by real AWS IAM, supporting IBAC, RBAC, and ABAC patterns. -use super::condition::evaluate_condition; +use super::condition::{ConditionMatch, evaluate_statement_conditions}; use super::context::ConditionContext; use super::document::{ActionMatch, Effect, PolicyDocument, ResourceMatch, Statement}; use super::matcher::{arn_match, wildcard_match_ignore_case}; @@ -101,7 +101,7 @@ where if stmt.effect == Effect::Deny && action_matches(stmt, action) && resource_matches(stmt, resource_arn) - && conditions_match(stmt, context) + && deny_conditions_apply(stmt, context) { return AuthzDecision::Deny; } @@ -114,7 +114,7 @@ where stmt.effect == Effect::Allow && action_matches(stmt, action) && resource_matches(stmt, resource_arn) - && conditions_match(stmt, context) + && allow_conditions_grant(stmt, context) }); if !boundary_allows { return AuthzDecision::Deny; @@ -127,7 +127,7 @@ where stmt.effect == Effect::Allow && action_matches(stmt, action) && resource_matches(stmt, resource_arn) - && conditions_match(stmt, context) + && allow_conditions_grant(stmt, context) }); if !session_allows { return AuthzDecision::Deny; @@ -140,7 +140,7 @@ where if stmt.effect == Effect::Allow && action_matches(stmt, action) && resource_matches(stmt, resource_arn) - && conditions_match(stmt, context) + && allow_conditions_grant(stmt, context) { return AuthzDecision::Allow; } @@ -176,12 +176,28 @@ fn resource_matches(statement: &Statement, request_resource: &str) -> bool { } } -/// Check if all conditions in the statement are satisfied. -fn conditions_match(statement: &Statement, context: &impl ConditionContext) -> bool { - statement - .conditions - .iter() - .all(|c| evaluate_condition(c, context)) +/// Outcome of a statement's `Condition` block, three-valued. +fn statement_conditions(statement: &Statement, context: &impl ConditionContext) -> ConditionMatch { + evaluate_statement_conditions(&statement.conditions, context) +} + +/// Whether a `Deny` applies: its conditions match, or depend on a key the +/// service populates but the context cannot resolve (fail closed, so the Deny +/// blocks rather than being silently dropped). +fn deny_conditions_apply(statement: &Statement, context: &impl ConditionContext) -> bool { + matches!( + statement_conditions(statement, context), + ConditionMatch::Match | ConditionMatch::Unresolvable + ) +} + +/// Whether an `Allow` grants: its conditions definitely match. An unresolvable +/// key does not grant (fail closed). +fn allow_conditions_grant(statement: &Statement, context: &impl ConditionContext) -> bool { + matches!( + statement_conditions(statement, context), + ConditionMatch::Match + ) } #[cfg(test)] diff --git a/crates/auth/src/policy/evaluator/tests.rs b/crates/auth/src/policy/evaluator/tests.rs index 62eaa2dee..dcc8e403e 100644 --- a/crates/auth/src/policy/evaluator/tests.rs +++ b/crates/auth/src/policy/evaluator/tests.rs @@ -9,25 +9,37 @@ use crate::policy::document::PolicyDocument; use std::collections::HashMap; /// Simple test context. -struct Ctx(HashMap>); +struct Ctx { + values: HashMap>, + unresolvable: std::collections::HashSet, +} impl Ctx { fn empty() -> Self { - Self(HashMap::new()) + Self { + values: HashMap::new(), + unresolvable: std::collections::HashSet::new(), + } } fn with(mut self, key: &str, values: Vec<&str>) -> Self { - self.0.insert( + self.values.insert( key.to_owned(), values.into_iter().map(ToOwned::to_owned).collect(), ); self } + + /// Mark `key` as service-populated but unresolvable (case 3). + fn unresolvable(mut self, key: &str) -> Self { + self.unresolvable.insert(key.to_owned()); + self + } } impl ConditionContext for Ctx { fn resolve_key(&self, key: &str) -> Option> { - self.0 + self.values .get(key) .map(|v| v.iter().map(|s| s.as_str()).collect()) } @@ -35,11 +47,16 @@ impl ConditionContext for Ctx { fn is_multivalued_key(&self, key: &str) -> bool { matches!(key, "dynamodb:LeadingKeys" | "dynamodb:Attributes") } + + fn is_unresolvable_key(&self, key: &str) -> bool { + self.unresolvable.contains(key) + } } fn parse(json: &str) -> PolicyDocument { PolicyDocument::from_json(json).unwrap() } +mod case_three; mod combined; mod core; diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs new file mode 100644 index 000000000..f6ab83e30 --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -0,0 +1,122 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Identity-policy evaluation of a key the service populates but the context +//! cannot resolve (case 3): a Deny fails closed and blocks, an Allow gated on +//! it does not grant, and the negated-operator-on-absent rule cannot fire. + +use super::*; + +const RES: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/T"; + +/// Allow dynamodb:* plus a Deny GetItem gated on StringEquals aws:PrincipalOrgID. +fn allow_all_deny_get_on_orgid() -> PolicyDocument { + parse( + r#"{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}, + {"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:PrincipalOrgID":"o-example1"}}} + ]}"#, + ) +} + +#[test] +fn deny_on_unresolvable_orgid_blocks_fail_closed() { + // The org id is populated by the service but unresolvable here, so the Deny + // fails closed and blocks GetItem. + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + evaluate_policies( + &[allow_all_deny_get_on_orgid()], + None, + None, + "dynamodb:GetItem", + RES, + &ctx + ), + AuthzDecision::Deny + ); + // A different action the Deny does not name is still allowed. + assert_eq!( + evaluate_policies( + &[allow_all_deny_get_on_orgid()], + None, + None, + "dynamodb:PutItem", + RES, + &ctx + ), + AuthzDecision::Allow + ); +} + +#[test] +fn allow_gated_on_unresolvable_orgid_does_not_grant() { + let policy = parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:PrincipalOrgID":"o-example1"}} + }]}"#, + ); + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + evaluate_policies(&[policy], None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Deny + ); +} + +#[test] +fn negated_allow_on_unresolvable_key_does_not_overgrant() { + // StringNotEquals on an unresolvable key must not grant via the + // negated-absent rule: the Allow does not grant, so the request is denied. + let policy = parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringNotEquals":{"aws:PrincipalOrgID":"o-example1"}} + }]}"#, + ); + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + evaluate_policies(&[policy], None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Deny + ); +} + +#[test] +fn negated_deny_on_unresolvable_key_fails_closed() { + // A Deny with a negated operator on an unresolvable key fails closed and + // blocks (over-denies relative to the service, the documented safe + // direction) rather than being dropped. + let policy = parse( + r#"{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}, + {"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringNotEquals":{"aws:PrincipalOrgID":"o-example1"}}} + ]}"#, + ); + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + evaluate_policies(&[policy], None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Deny + ); +} + +#[test] +fn resolved_principal_arn_allows_normally() { + // aws:PrincipalArn is resolvable on the identity path; a matching Allow + // grants (not fail closed). + let policy = parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:user/alice"}} + }]}"#, + ); + let ctx = Ctx::empty().with( + "aws:PrincipalArn", + vec!["arn:aws:iam::123456789012:user/alice"], + ); + assert_eq!( + evaluate_policies(&[policy], None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Allow + ); +} diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index 715f917d5..af3a0c517 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -108,6 +108,9 @@ async fn check_user_authorization( // so we deref through the Arc and clone once. This is the only place // where the tag map allocation is unavoidable; future iterations could // change RequestContext to hold Arc> for true zero-copy. + let mut params = params; + params.principal_arn = Some(format!("arn:aws:iam::{account_id}:user/{user_name}")); + params.principal_account = Some(account_id.to_owned()); let context = RequestContext::build( (*principal_tags).clone(), (*resource_tags).clone(), @@ -160,6 +163,11 @@ async fn check_role_authorization( wrap_tags(cache.fetch_resource_tags(resource_arn)), )?; + let mut params = params; + params.principal_arn = Some(format!( + "arn:aws:sts::{account_id}:assumed-role/{role_name}/{session_name}" + )); + params.principal_account = Some(account_id.to_owned()); let context = RequestContext::build(principal_tags, (*resource_tags).clone(), is_scan, params); // Convert the Arc> to a slice for the evaluator. We materialize diff --git a/tests/test_abac.py b/tests/test_abac.py index 3977c7219..e8e1081d8 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -436,3 +436,103 @@ def test_session_tag_denies_non_matching_role(self): finally: self.mgmt.delete_user(self.account_id, user) self.mgmt.delete_role(self.account_id, role) + + +class TestUnresolvableGlobalKey: + """Identity policies gated on a global key the service populates but + ExtendDB cannot resolve (for example aws:PrincipalOrgID). Such a statement + fails closed: a Deny blocks and an Allow does not grant, whatever the + operator, so a negated operator cannot spuriously grant. A key ExtendDB does + resolve (aws:PrincipalArn) evaluates normally. + """ + + @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 + + def _admin_table(self) -> tuple[Any, str]: + admin = f"ur-adm-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, admin) + resp = self.mgmt.put_user_policy( + self.account_id, admin, "full", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}]}, + ) + assert resp.status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + table = f"urk-{uuid.uuid4().hex[:8]}" + client.create_table( + TableName=table, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(client, table) + client.put_item(TableName=table, Item={"pk": {"S": "k1"}, "data": {"S": "v1"}}) + return client, table + + def test_deny_on_unresolvable_orgid_blocks_fail_closed(self): + admin_client, table = self._admin_table() + user = f"urk-deny-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) + resp = self.mgmt.put_user_policy( + self.account_id, user, "p", + {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}, + {"Effect": "Deny", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-example1"}}}]}, + ) + assert resp.status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + try: + with pytest.raises(ClientError) as exc: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + assert exc.value.response["Error"]["Code"] == "AccessDeniedException" + # The Deny names only GetItem, so PutItem still succeeds. + client.put_item(TableName=table, Item={"pk": {"S": "k2"}, "data": {"S": "v2"}}) + finally: + admin_client.delete_table(TableName=table) + self.mgmt.delete_user(self.account_id, user) + + def test_negated_allow_on_unresolvable_orgid_does_not_grant(self): + admin_client, table = self._admin_table() + user = f"urk-neg-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) + resp = self.mgmt.put_user_policy( + self.account_id, user, "p", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringNotEquals": {"aws:PrincipalOrgID": "o-example1"}}}]}, + ) + assert resp.status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + try: + with pytest.raises(ClientError) as exc: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + assert exc.value.response["Error"]["Code"] == "AccessDeniedException" + finally: + admin_client.delete_table(TableName=table) + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gated_on_resolved_principal_arn_grants(self): + admin_client, table = self._admin_table() + user = f"urk-arn-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) + user_arn = f"arn:aws:iam::{self.account_id}:user/{user}" + resp = self.mgmt.put_user_policy( + self.account_id, user, "p", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringEquals": {"aws:PrincipalArn": user_arn}}}]}, + ) + assert resp.status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + try: + out = client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + assert out["Item"]["data"]["S"] == "v1" + finally: + admin_client.delete_table(TableName=table) + self.mgmt.delete_user(self.account_id, user) From 3d4a3c547b1e41ae046173ab8e281073893e9b42 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 20:56:28 +0000 Subject: [PATCH 10/58] docs(admin): list trust-policy keys ExtendDB resolves and the global keys it does not evaluate Resolving the caller and session keys closes the common over-denials, where an Allow or Deny gated on aws:PrincipalArn, aws:PrincipalAccount, aws:RequestTag or aws:TagKeys was refused. The organization keys (aws:PrincipalOrgID, aws:PrincipalOrgPaths) and the other global keys STS populates are still not evaluated, so a trust policy depending on one fails closed rather than being silently granted. Document the resolved set and the unevaluated set so operators know which keys are safe to gate on. --- docs/manuals/05-admin-guide.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index 9e8e91925..456f5fd1f 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -343,11 +343,24 @@ account, for both `Allow` and `Deny`. What a trust-policy `Deny` does not do: -- A `Deny` whose `Condition` uses a condition key ExtendDB does not resolve - (for example `aws:SourceIp`) or an operator it does not implement (for - example `IpAddress`) fails closed: it blocks assumption rather than being - dropped. Write a `Deny` only on keys you can resolve (`aws:PrincipalTag/*`, - `sts:ExternalId`) unless you intend an unconditional block. +- A statement whose `Condition` uses a condition key ExtendDB does not resolve, + or an operator it does not implement, fails closed: a `Deny` blocks assumption + and an `Allow` grants nothing, rather than the key being treated as absent. + ExtendDB resolves these trust-policy keys: `aws:PrincipalTag/` (tag key + matched case-insensitively), `sts:ExternalId`, `aws:PrincipalArn`, + `aws:PrincipalAccount`, `aws:RequestTag/` and `aws:TagKeys` (from the + passed session tags). It does not evaluate these global keys that STS + populates, so a statement depending on one fails closed: `aws:PrincipalOrgID`, + `aws:PrincipalOrgPaths`, `aws:PrincipalType`, `aws:userid`, `aws:username`, + `aws:SourceIp`, `aws:SecureTransport`, `aws:CurrentTime`, `aws:EpochTime`, + `aws:RequestedRegion` and `aws:UserAgent`. The practical effect: a common + organization-scoped trust policy (`Allow` gated on `aws:PrincipalOrgID`, or a + `Deny` outside the org) is refused on ExtendDB rather than silently granted. + The operators `IpAddress`, `NotIpAddress` and `BinaryEquals` are accepted at + role creation (IAM accepts them) but are not evaluated, so a statement using + one also fails closed. Write a conditional trust statement only on the + resolved keys and implemented operators unless you intend an unconditional + block. - `NotPrincipal` is not allowed in a trust policy and is rejected when the role is created. - A `Resource` field is not allowed in a trust policy and is rejected at create. From be646d1f293c00eb382d7cb683c28b87530d4b99 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 20:57:22 +0000 Subject: [PATCH 11/58] docs(adr): state the three-case condition-key rule and the resolved key set ADR-0022 described Deny-over-Allow ordering and the unparseable-condition fail-closed behaviour but not how an unresolved key is treated. State the rule the evaluator now follows for every condition key: a resolved key evaluates normally, a genuinely absent key keeps IAM absent-key semantics, and a key the service always populates but the context cannot resolve fails closed. List the keys each context resolves. --- docs/adr/0022-trust-policy-deny-over-allow.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/adr/0022-trust-policy-deny-over-allow.md b/docs/adr/0022-trust-policy-deny-over-allow.md index daf21f10b..1e903b8a6 100644 --- a/docs/adr/0022-trust-policy-deny-over-allow.md +++ b/docs/adr/0022-trust-policy-deny-over-allow.md @@ -87,6 +87,24 @@ implicitly denied. will now block it. This is the intended fix, but it is a behavior change for any deployment that (perhaps unknowingly) relied on `Deny` being ignored. There is no change for trust policies that contain only `Allow` statements. +- **Condition-key resolution (three cases).** A condition key falls into one of + three cases, in both trust-policy and identity-policy evaluation. (1) The + context resolves the key to a value: evaluate normally. (2) The context + resolves it as absent the same way the service would: IAM absent-key + semantics apply, including that a negated operator on an absent single-valued + key matches. (3) The service always populates the key but the context cannot + resolve it: the statement fails closed whatever the operator, so a `Deny` + blocks and an `Allow` does not grant, rather than the key being treated as + absent (which would let a negated operator spuriously match). The + `AssumeRoleContext` resolves `aws:PrincipalArn`, `aws:PrincipalAccount`, + `aws:PrincipalTag/*` (tag key matched case-insensitively), `aws:RequestTag/*`, + `aws:TagKeys` and `sts:ExternalId`; the always-populated global keys it does + not resolve (`aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, + `aws:PrincipalType`, `aws:userid`, `aws:username`, `aws:SourceIp`, + `aws:SecureTransport`, `aws:CurrentTime`, `aws:EpochTime`, + `aws:RequestedRegion`, `aws:UserAgent`) are case 3. Conditionally-present keys + such as `aws:MultiFactorAuthPresent` are absent for ExtendDB's caller model + and keep absent-key semantics. - **Follow-up:** the decision logic was factored into a store-free `trust_decision` helper (plus `statement_matches` / `effect_of`) so the Deny-over-Allow ordering is unit-tested directly. The decision is made per From 42f2da57187e0f7e85354c2883da7828b28c8f4b Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 21:09:05 +0000 Subject: [PATCH 12/58] fix(auth): implement StringNotEqualsIgnoreCase in the condition evaluator Write validation accepted StringNotEqualsIgnoreCase, which IAM accepts, but the evaluator could not parse it, so a statement using it was unparseable: a Deny always blocked and an Allow never granted regardless of the values. Implement the operator as the case-insensitive negation of StringEqualsIgnoreCase and classify it as a negated operator, so it follows the negated-operator-on-absent rule like the other negated operators. BinaryEquals, IpAddress and NotIpAddress stay accepted at write and unevaluated: they compare data neither context carries, so a statement using one still fails closed, which is the documented safe direction. --- crates/auth/src/policy/condition.rs | 4 +++ crates/auth/src/policy/document.rs | 2 ++ crates/server/src/management/assume_role.rs | 25 +++++++++++++++++- tests/test_auth_integration.py | 29 +++++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 41a2487bd..5f5ffba7e 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -284,6 +284,7 @@ fn is_negative_operator(op: &ConditionOperator) -> bool { matches!( op, ConditionOperator::StringNotEquals + | ConditionOperator::StringNotEqualsIgnoreCase | ConditionOperator::NumericNotEquals | ConditionOperator::DateNotEquals | ConditionOperator::StringNotLike @@ -307,6 +308,9 @@ fn compare_single(op: &ConditionOperator, context_value: &str, policy_value: &st ConditionOperator::StringEqualsIgnoreCase => { context_value.eq_ignore_ascii_case(policy_value) } + ConditionOperator::StringNotEqualsIgnoreCase => { + !context_value.eq_ignore_ascii_case(policy_value) + } ConditionOperator::StringLike => wildcard_match(policy_value, context_value), ConditionOperator::StringNotLike => !wildcard_match(policy_value, context_value), ConditionOperator::NumericEquals => parse_f64_cmp(context_value, policy_value, f64::eq), diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index 195c60a9f..0243389db 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -93,6 +93,7 @@ pub enum ConditionOperator { StringEquals, StringNotEquals, StringEqualsIgnoreCase, + StringNotEqualsIgnoreCase, StringLike, StringNotLike, // Numeric @@ -357,6 +358,7 @@ fn parse_base_operator(name: &str) -> Result Ok(ConditionOperator::StringEquals), "StringNotEquals" => Ok(ConditionOperator::StringNotEquals), "StringEqualsIgnoreCase" => Ok(ConditionOperator::StringEqualsIgnoreCase), + "StringNotEqualsIgnoreCase" => Ok(ConditionOperator::StringNotEqualsIgnoreCase), "StringLike" => Ok(ConditionOperator::StringLike), "StringNotLike" => Ok(ConditionOperator::StringNotLike), "NumericEquals" => Ok(ConditionOperator::NumericEquals), diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index 5ececd5fc..dadcd6a63 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -1100,8 +1100,31 @@ mod tests { assert!(trust_decision(&stmts, CALLER, &ctx_ext("secret"), false)); } - // ---- fail closed on an unparseable Deny ------------------------------- + #[test] + fn string_not_equals_ignore_case_external_id() { + // Deny alice unless the external id equals "secret" case-insensitively. + // With a matching id (any case) the negated condition is false, so the + // Deny does not apply and the Allow grants. + let stmts = vec![ + allow_alice(), + json!({ + "Effect": "Deny", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringNotEqualsIgnoreCase": {"sts:ExternalId": "secret"}} + }), + ]; + // Present and equal (mixed case): condition false, Deny inert, allowed. + assert!(trust_decision(&stmts, CALLER, &ctx_ext("SECRET"), false)); + assert!(trust_decision(&stmts, CALLER, &ctx_ext("secret"), false)); + // Present and different: condition true, Deny applies, blocked. + assert!(!trust_decision(&stmts, CALLER, &ctx_ext("other"), false)); + // Absent: a negated operator on an absent single-valued key matches, so + // the Deny applies and blocks (confused-deputy guard). + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + // ---- fail closed on an unparseable Deny ------------------------------- #[test] fn deny_with_unparseable_condition_fails_closed() { // An operator ExtendDB cannot evaluate must not silently drop the Deny. diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 176480a1a..212c015cd 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1122,6 +1122,35 @@ def test_tag_key_case_insensitive_negated_deny_does_not_overdeny(self): finally: self.mgmt.delete_user(self.account_id, user) + def test_string_not_equals_ignore_case_external_id(self): + # StringNotEqualsIgnoreCase is implemented, so a matching external id + # (any case) makes the condition false and the Deny inert; an absent + # external id matches the negated operator and the Deny blocks. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringNotEqualsIgnoreCase": {"sts:ExternalId": "secret"}}), + ) + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, resp.text + try: + r = requests.post( + f"{self.mgmt.base_url}/accounts/{self.account_id}/roles/{role}/assume", + json={"caller_arn": arn, "session_name": "bb-session", + "duration_seconds": 3600, "external_id": "SECRET"}, + auth=self.mgmt.admin_auth, timeout=30, verify=self.mgmt.verify, + ) + assert r.status_code == ALLOW, r.text + assert self.mgmt.assume_role( + self.account_id, role, arn, "bb-session").status_code == DENY + finally: + self.mgmt.delete_role(self.account_id, role) + finally: + self.mgmt.delete_user(self.account_id, user) + @pytest.mark.parametrize("trust", [ _trust(_stmt("Allow", "arn:aws:iam::111122223333:user/x")), # bare string ARN / non-object _trust({"Principal": {"AWS": "arn:aws:iam::111122223333:user/x"}, "Action": "sts:AssumeRole"}), # missing Effect From 9ad9a763354513d8037c37267e31028b05602cc2 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Tue, 20 Oct 2026 21:11:10 +0000 Subject: [PATCH 13/58] docs: correct the ADR helper reference and document upgrade impact and identity-policy conditions ADR-0022 named a statement_matches helper that was never introduced; replace it with the real helper names. Add an upgrade-impact note that passing session tags now requires sts:TagSession in the trust policy and that create_role now rejects the documents IAM rejects. In the admin guide, state that principal ARNs are matched case-sensitively, that the assume endpoint authorizes on the trust policy alone and does not evaluate the caller's identity permissions, and that the three-case condition-key rule, including the negated-operator-on-absent rule and fail-closed on an unresolved key, applies to identity policies as well. --- docs/adr/0022-trust-policy-deny-over-allow.md | 13 +++++++++++- docs/manuals/05-admin-guide.md | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/adr/0022-trust-policy-deny-over-allow.md b/docs/adr/0022-trust-policy-deny-over-allow.md index 1e903b8a6..61e723161 100644 --- a/docs/adr/0022-trust-policy-deny-over-allow.md +++ b/docs/adr/0022-trust-policy-deny-over-allow.md @@ -87,6 +87,16 @@ implicitly denied. will now block it. This is the intended fix, but it is a behavior change for any deployment that (perhaps unknowingly) relied on `Deny` being ignored. There is no change for trust policies that contain only `Allow` statements. +- **Upgrade impact for existing deployments:** two further changes affect + deployments upgrading from a build that ignored `Deny`. First, passing session + tags on an `AssumeRole` call now exercises `sts:TagSession`, so a role whose + trust policy allows only `sts:AssumeRole` denies a tagged call that previously + succeeded; allow `sts:TagSession` in the trust policy to keep passing tags. + Second, `create_role` now validates the trust document the way IAM does, so + automation that sent a document IAM rejects (a `Resource` field, `sts:*`, a + bare `"*"` principal, a missing `Principal`, and so on) starts getting a 400 + at role creation. Existing stored roles are never re-validated, so no stored + role is locked out at evaluation. - **Condition-key resolution (three cases).** A condition key falls into one of three cases, in both trust-policy and identity-policy evaluation. (1) The context resolves the key to a value: evaluate normally. (2) The context @@ -106,7 +116,8 @@ implicitly denied. such as `aws:MultiFactorAuthPresent` are absent for ExtendDB's caller model and keep absent-key semantics. - **Follow-up:** the decision logic was factored into a store-free - `trust_decision` helper (plus `statement_matches` / `effect_of`) so the + `trust_decision` helper (with `action_permitted`, `deny_statement_blocks`, + `allow_statement_grants` and `effect_of`) so the Deny-over-Allow ordering is unit-tested directly. The decision is made per exercised STS action: `sts:AssumeRole`, plus `sts:TagSession` when the call passes session tags (ExtendDB does not implement source identity, so diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index 456f5fd1f..851ce1e46 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -341,6 +341,13 @@ ARN, a list of ARNs, `{"AWS": "*"}`, a bare account id, or an account root ARN (`arn:aws:iam:::root`); the last two match every caller in that account, for both `Allow` and `Deny`. +Principal ARNs are matched case-sensitively: a `Deny` naming `user/Alice` does +not block a caller asserted as `user/alice`. Assume authorizes on the trust +policy alone. The endpoint is admin-only and the admin asserts the caller ARN; +unlike IAM, ExtendDB does not also evaluate the caller's identity policy for +`sts:AssumeRole`, so a role that trusts an account root grants assumption to an +in-account caller on the strength of the trust policy by itself. + What a trust-policy `Deny` does not do: - A statement whose `Condition` uses a condition key ExtendDB does not resolve, @@ -419,6 +426,20 @@ Inline policies can be attached to users, groups, and roles: }' ``` +#### Condition keys in identity policies + +Identity-policy conditions on DynamoDB requests follow the same three-case rule +as trust policies. A condition key the request resolves is evaluated normally. A +key that is genuinely absent follows IAM absent-key semantics, including that a +negated operator (`StringNotEquals`, `StringNotLike`, `ArnNotEquals`) on an +absent single-valued key matches, so a `Deny` gated on one applies when the +caller omits the key. A global key the service always populates but ExtendDB +does not resolve (`aws:PrincipalOrgID`, `aws:SourceIp`, `aws:PrincipalType`, +`aws:userid`, and the others listed for trust policies above) fails closed: a +`Deny` on it blocks and an `Allow` on it does not grant. ExtendDB resolves +`aws:PrincipalArn`, `aws:PrincipalAccount`, `aws:PrincipalTag/` (tag key +case-insensitive) and `dynamodb:ResourceTag/` on a data-plane request. + ### Permissions Boundaries ```bash From 900ec6b1776d86513f24d157a43cc23421e7e3b5 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 00:12:17 +0000 Subject: [PATCH 14/58] fix(auth): resolve request-wide global keys and fail closed on the rest Condition evaluation marked a fixed set of global keys unresolvable and treated every other unresolved key as absent. That denied common guard statements on keys ExtendDB can resolve (aws:SecureTransport, aws:RequestedRegion, aws:CurrentTime, aws:EpochTime, aws:PrincipalType) and granted statements on keys the service populates that ExtendDB did not resolve (aws:ViaAWSService, aws:PrincipalIsAWSService, aws:ResourceTag). Resolve every global key ExtendDB has the data for, in both the DynamoDB request context and the trust policy context: aws:SecureTransport from the listener TLS state, aws:RequestedRegion from the server region, the request time for aws:CurrentTime and aws:EpochTime, aws:PrincipalType (User for an IAM user, AssumedRole for a role session), aws:username for a user, aws:ViaAWSService and aws:PrincipalIsAWSService as false, and aws:ResourceTag on the identity path from the resource tag map. Keep an allowlist of keys that are legitimately absent for ExtendDB's callers so they keep absent key semantics. Any other unresolved aws: key fails closed whatever the operator, so a Deny on it blocks and an Allow on it does not grant. Behaviour measured on live DynamoDB, IAM and STS in us-east-1. --- crates/auth/src/policy/context.rs | 433 ++++++++++++++++---- crates/server/src/authorization.rs | 5 + crates/server/src/lib.rs | 3 + crates/server/src/management/assume_role.rs | 137 +++++-- crates/server/src/management/mod.rs | 6 + crates/server/src/request_helpers.rs | 8 +- 6 files changed, 478 insertions(+), 114 deletions(-) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 7c6939624..dec05aab0 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -45,54 +45,94 @@ pub trait ConditionContext { } } -/// Global condition keys the AWS services always populate on a request. A -/// context that cannot resolve one of these treats it as unresolvable (fail -/// closed) rather than absent, so a negated operator on it cannot spuriously -/// match and an `Allow` gated on it cannot spuriously grant. -/// -/// The set is the single-valued `aws:` global keys that STS always populates on -/// an `AssumeRole` call and that IAM always populates on a `DynamoDB` -/// data-plane request from an IAM principal. Keys that are only conditionally -/// present (for example `aws:MultiFactorAuthPresent`, which is absent when the -/// caller did not use MFA, or the VPC and federation keys) are deliberately not -/// listed: they are genuinely absent for ExtendDB's caller model, and IAM -/// absent-key semantics match the service there (measured, case e27 vs e28). -/// Tag keys (`aws:PrincipalTag/*`, `aws:RequestTag/*`, `aws:TagKeys`, -/// `dynamodb:ResourceTag/*`), `sts:ExternalId`, and the `dynamodb:` request -/// keys are resolved (or genuinely absent) per context and are not listed here. /// Look up a tag map by key, case-insensitively on the key. /// /// AWS resolves tag condition keys (`aws:PrincipalTag/`, `aws:RequestTag/`, -/// `dynamodb:ResourceTag/`) without regard to the case of ``; a tag stored -/// as `Team` is matched by a policy naming `team`. Tag values stay -/// case-sensitive. IAM forbids two tag keys that differ only in case, so a -/// case-insensitive match is unambiguous. +/// `aws:ResourceTag/`, `dynamodb:ResourceTag/`) without regard to the case +/// of ``; a tag stored as `Team` is matched by a policy naming `team`. Tag +/// values stay case-sensitive. IAM forbids two tag keys that differ only in +/// case, so a case-insensitive match is unambiguous. fn ci_tag_lookup<'a>(map: &'a HashMap, tag_key: &str) -> Option> { map.iter() .find(|(k, _)| k.eq_ignore_ascii_case(tag_key)) .map(|(_, v)| vec![v.as_str()]) } +/// Global `aws:` condition keys that are legitimately absent for ExtendDB's +/// caller model: the live service also leaves them unset on the equivalent +/// request, so IAM absent-key semantics apply to them (a negated operator on an +/// absent one matches; an `Allow` gated on its presence does not grant). +/// Measured live on a plain IAM user request and on an assumed-role session. +/// +/// This is an allowlist, the inverse of the earlier denylist. Any other +/// unresolved `aws:` global key (for example `aws:PrincipalOrgID`, +/// `aws:SourceIp`, or a key AWS adds later) is treated as unresolvable and fails +/// closed, so the evaluator does not have to track the full, growing set of +/// keys the service populates. `aws:username` and `aws:TagKeys` are listed +/// because each is resolved when present and legitimately absent otherwise (no +/// user name on a role session; no session tags on a plain call). #[must_use] -pub fn is_service_populated_global_key(key: &str) -> bool { +pub fn is_absent_ok_global_key(key: &str) -> bool { matches!( key, - "aws:PrincipalArn" - | "aws:PrincipalAccount" - | "aws:PrincipalOrgID" - | "aws:PrincipalOrgPaths" - | "aws:PrincipalType" - | "aws:userid" + "aws:MultiFactorAuthPresent" + | "aws:MultiFactorAuthAge" + | "aws:TokenIssueTime" + | "aws:SourceVpc" + | "aws:SourceVpce" + | "aws:VpcSourceIp" + | "aws:SourceArn" + | "aws:SourceAccount" + | "aws:SourceOrgID" + | "aws:SourceOrgPaths" + | "aws:CalledVia" + | "aws:CalledViaFirst" + | "aws:CalledViaLast" + | "aws:FederatedProvider" + | "aws:Referer" + | "aws:SourceIdentity" + | "aws:Ec2InstanceSourceVpc" + | "aws:Ec2InstanceSourcePrivateIPv4" | "aws:username" - | "aws:SourceIp" - | "aws:SecureTransport" - | "aws:CurrentTime" - | "aws:EpochTime" - | "aws:RequestedRegion" - | "aws:UserAgent" + | "aws:TagKeys" ) } +/// Whether an unresolved condition key must fail closed for the context that +/// could not resolve it. Consulted only after `resolve_key` returned `None`. +/// +/// Only `aws:` global keys fail closed. `dynamodb:` and `sts:` keys keep +/// absent-key semantics (a genuinely absent one simply does not match). A tag +/// sub-key (`aws:PrincipalTag/`, `aws:RequestTag/`, `aws:ResourceTag/`) is +/// genuinely absent when unresolved, so it keeps absent-key semantics too. A +/// key on the absent-ok allowlist is legitimately absent. Every other `aws:` +/// key is one the service may populate but this context has no value for, so it +/// is unresolvable and the statement using it fails closed. +#[must_use] +pub fn aws_key_is_unresolvable(key: &str) -> bool { + if !key.starts_with("aws:") { + return false; + } + if key.starts_with("aws:PrincipalTag/") + || key.starts_with("aws:RequestTag/") + || key.starts_with("aws:ResourceTag/") + { + return false; + } + !is_absent_ok_global_key(key) +} + +/// The request time as the pair (`aws:CurrentTime` as RFC 3339 UTC, +/// `aws:EpochTime` as whole seconds since the epoch). +#[must_use] +pub fn request_time_strings() -> (String, String) { + let now = time::OffsetDateTime::now_utc(); + let iso = now + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default(); + (iso, now.unix_timestamp().to_string()) +} + /// Request parameters extracted from a `DynamoDB` operation for condition evaluation. #[derive(Debug, Default)] pub struct RequestParams { @@ -114,6 +154,18 @@ pub struct RequestParams { pub principal_arn: Option, /// The caller's account id (`aws:PrincipalAccount`), when known. pub principal_account: Option, + /// The server region (`aws:RequestedRegion`), when known. + pub region: Option, + /// Whether the request arrived over TLS (`aws:SecureTransport`). The + /// listener is entirely TLS or entirely plaintext, so this is a + /// server-level value, not per-connection. + pub secure_transport: bool, + /// The caller principal type (`aws:PrincipalType`): `User` for an IAM user, + /// `AssumedRole` for a role session. + pub principal_type: Option, + /// The IAM user name (`aws:username`) for a user caller; `None` for a role + /// session, where the service does not populate it. + pub username: Option, } /// Context for evaluating conditions on `DynamoDB` operations. @@ -144,6 +196,19 @@ pub struct RequestContext { pub principal_arn: Option, /// The caller's account id (`aws:PrincipalAccount`), when known. pub principal_account: Option, + /// The server region (`aws:RequestedRegion`), when known. + pub region: Option, + /// Whether the request arrived over TLS (`aws:SecureTransport`). + pub secure_transport: bool, + /// The caller principal type (`aws:PrincipalType`). + pub principal_type: Option, + /// The IAM user name (`aws:username`) for a user caller; `None` for a role + /// session. + pub username: Option, + /// The request time formatted for `aws:CurrentTime` (RFC 3339 UTC). + pub current_time: String, + /// The request time formatted for `aws:EpochTime` (whole seconds). + pub epoch_time: String, } impl RequestContext { @@ -159,6 +224,7 @@ impl RequestContext { is_scan: bool, params: RequestParams, ) -> Self { + let (current_time, epoch_time) = request_time_strings(); Self { principal_tags, resource_tags, @@ -171,6 +237,12 @@ impl RequestContext { enclosing_operation: params.enclosing_operation, principal_arn: params.principal_arn, principal_account: params.principal_account, + region: params.region, + secure_transport: params.secure_transport, + principal_type: params.principal_type, + username: params.username, + current_time, + epoch_time, } } } @@ -179,6 +251,11 @@ impl ConditionContext for RequestContext { fn resolve_key(&self, key: &str) -> Option> { if let Some(tag_key) = key.strip_prefix("aws:PrincipalTag/") { ci_tag_lookup(&self.principal_tags, tag_key) + } else if let Some(tag_key) = key.strip_prefix("aws:ResourceTag/") { + // The service evaluates resource tags as aws:ResourceTag/*. ExtendDB + // resolves them from the same resource-tag map it uses for + // dynamodb:ResourceTag/*, case-insensitively on the key. + ci_tag_lookup(&self.resource_tags, tag_key) } else if let Some(tag_key) = key.strip_prefix("dynamodb:ResourceTag/") { ci_tag_lookup(&self.resource_tags, tag_key) } else { @@ -204,6 +281,21 @@ impl ConditionContext for RequestContext { } "aws:PrincipalArn" => self.principal_arn.as_deref().map(|v| vec![v]), "aws:PrincipalAccount" => self.principal_account.as_deref().map(|v| vec![v]), + "aws:RequestedRegion" => self.region.as_deref().map(|v| vec![v]), + "aws:SecureTransport" => Some(vec![if self.secure_transport { + "true" + } else { + "false" + }]), + "aws:CurrentTime" => Some(vec![self.current_time.as_str()]), + "aws:EpochTime" => Some(vec![self.epoch_time.as_str()]), + "aws:PrincipalType" => self.principal_type.as_deref().map(|v| vec![v]), + "aws:username" => self.username.as_deref().map(|v| vec![v]), + // ExtendDB is not an AWS service calling on a principal's behalf, + // so these are always false, matching the service for a direct + // caller. + "aws:ViaAWSService" => Some(vec!["false"]), + "aws:PrincipalIsAWSService" => Some(vec!["false"]), _ => None, } } @@ -217,26 +309,27 @@ impl ConditionContext for RequestContext { } fn is_unresolvable_key(&self, key: &str) -> bool { - // A global key the service always populates on a data-plane request but - // that this context does not resolve (for example aws:PrincipalOrgID, - // aws:SourceIp) is unresolvable, so a Deny on it fails closed and an - // Allow on it does not grant. aws:PrincipalArn and aws:PrincipalAccount - // are resolved above, so they never reach here. Conditionally-present - // keys (MFA, VPC, federation) are not in the set and keep absent-key - // semantics. - is_service_populated_global_key(key) + // 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. + aws_key_is_unresolvable(key) } } /// Context for evaluating trust policy conditions during `AssumeRole`. /// /// Trust policies can reference `aws:PrincipalTag/*`, `sts:ExternalId`, the -/// caller identity keys `aws:PrincipalArn` / `aws:PrincipalAccount`, and the -/// session-tag keys `aws:RequestTag/*` / `aws:TagKeys`. Other global keys STS -/// populates on an `AssumeRole` call (for example `aws:PrincipalOrgID`, -/// `aws:SourceIp`, `aws:MultiFactorAuthPresent`) are not resolvable here and -/// are reported as unresolvable (fail closed). `DynamoDB`-specific keys are not -/// applicable. +/// caller identity keys `aws:PrincipalArn` / `aws:PrincipalAccount` / +/// `aws:PrincipalType` / `aws:username`, the session-tag keys +/// `aws:RequestTag/*` / `aws:TagKeys`, and the request-wide keys +/// `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, +/// `aws:EpochTime`, `aws:ViaAWSService` and `aws:PrincipalIsAWSService`, all of +/// which this context resolves. A global key STS populates that this context +/// cannot resolve (for example `aws:PrincipalOrgID` or `aws:SourceIp`, neither +/// on the absent-ok allowlist) is reported as unresolvable (fail closed). Keys +/// that are legitimately absent for this caller model keep absent-key +/// semantics. `DynamoDB`-specific keys are not applicable. #[derive(Debug)] pub struct AssumeRoleContext { /// Tags on the calling principal. @@ -251,6 +344,21 @@ pub struct AssumeRoleContext { /// Session tags passed on the call (`aws:RequestTag/*`, and their keys for /// `aws:TagKeys`). pub request_tags: HashMap, + /// The STS region (`aws:RequestedRegion`), when known. + pub region: Option, + /// Whether the `AssumeRole` call arrived over TLS (`aws:SecureTransport`). + pub secure_transport: bool, + /// The calling principal's type (`aws:PrincipalType`): `User` for an IAM + /// user, `AssumedRole` for a role session. `None` when it cannot be + /// determined from the caller ARN (then it fails closed). + pub principal_type: Option, + /// The calling IAM user's name (`aws:username`); `None` when the caller is + /// not an IAM user (for example a role session), where it is absent. + pub username: Option, + /// The request time formatted for `aws:CurrentTime` (RFC 3339 UTC). + pub current_time: String, + /// The request time formatted for `aws:EpochTime` (whole seconds). + pub epoch_time: String, } impl ConditionContext for AssumeRoleContext { @@ -264,6 +372,18 @@ impl ConditionContext for AssumeRoleContext { "sts:ExternalId" => self.external_id.as_deref().map(|v| vec![v]), "aws:PrincipalArn" => Some(vec![self.principal_arn.as_str()]), "aws:PrincipalAccount" => self.principal_account.as_deref().map(|v| vec![v]), + "aws:RequestedRegion" => self.region.as_deref().map(|v| vec![v]), + "aws:SecureTransport" => Some(vec![if self.secure_transport { + "true" + } else { + "false" + }]), + "aws:CurrentTime" => Some(vec![self.current_time.as_str()]), + "aws:EpochTime" => Some(vec![self.epoch_time.as_str()]), + "aws:PrincipalType" => self.principal_type.as_deref().map(|v| vec![v]), + "aws:username" => self.username.as_deref().map(|v| vec![v]), + "aws:ViaAWSService" => Some(vec!["false"]), + "aws:PrincipalIsAWSService" => Some(vec!["false"]), "aws:TagKeys" => { if self.request_tags.is_empty() { None @@ -286,11 +406,12 @@ impl ConditionContext for AssumeRoleContext { fn is_unresolvable_key(&self, key: &str) -> bool { // Keys this context resolves (tags, external id, principal ARN and - // account) are never reported here (the caller only consults this when - // `resolve_key` returned `None`, i.e. the key is genuinely absent for a - // tag or external id). Any other global key STS populates on an - // `AssumeRole` call is unresolvable, so a Deny gated on it fails closed. - is_service_populated_global_key(key) + // account, region, secure transport, time, principal type, user name) + // never reach here. Any other unresolved aws: global key that is not on + // the absent-ok allowlist is one STS populates but this context has no + // value for, so a Deny gated on it fails closed and an Allow does not + // grant. + aws_key_is_unresolvable(key) } } @@ -298,6 +419,28 @@ impl ConditionContext for AssumeRoleContext { mod tests { use super::*; + /// Build an `AssumeRoleContext` for an IAM user caller with the given tags, + /// external id and session tags, and the request-wide keys resolved. + fn ar_ctx( + principal_tags: HashMap, + external_id: Option, + request_tags: HashMap, + ) -> AssumeRoleContext { + AssumeRoleContext { + principal_tags, + external_id, + principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), + principal_account: Some("123456789012".to_owned()), + request_tags, + region: Some("us-east-1".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + username: Some("alice".to_owned()), + current_time: "2026-01-01T00:00:00Z".to_owned(), + epoch_time: "1767225600".to_owned(), + } + } + #[test] fn request_context_principal_tag() { let ctx = RequestContext::build( @@ -355,13 +498,11 @@ mod tests { #[test] fn assume_role_context_tag_key_is_case_insensitive() { - let ctx = AssumeRoleContext { - principal_tags: HashMap::from([("Team".to_owned(), "contractors".to_owned())]), - external_id: None, - principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), - principal_account: Some("123456789012".to_owned()), - request_tags: HashMap::from([("Env".to_owned(), "prod".to_owned())]), - }; + let ctx = ar_ctx( + HashMap::from([("Team".to_owned(), "contractors".to_owned())]), + None, + HashMap::from([("Env".to_owned(), "prod".to_owned())]), + ); assert_eq!( ctx.resolve_key("aws:PrincipalTag/team"), Some(vec!["contractors"]) @@ -414,26 +555,14 @@ mod tests { #[test] fn assume_role_context_external_id() { - let ctx = AssumeRoleContext { - principal_tags: HashMap::new(), - external_id: Some("ext-123".to_owned()), - principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), - principal_account: Some("123456789012".to_owned()), - request_tags: HashMap::new(), - }; + let ctx = ar_ctx(HashMap::new(), Some("ext-123".to_owned()), HashMap::new()); assert_eq!(ctx.resolve_key("sts:ExternalId"), Some(vec!["ext-123"])); assert_eq!(ctx.resolve_key("dynamodb:LeadingKeys"), None); } #[test] fn assume_role_context_principal_arn_and_account() { - let ctx = AssumeRoleContext { - principal_tags: HashMap::new(), - external_id: None, - principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), - principal_account: Some("123456789012".to_owned()), - request_tags: HashMap::new(), - }; + let ctx = ar_ctx(HashMap::new(), None, HashMap::new()); assert_eq!( ctx.resolve_key("aws:PrincipalArn"), Some(vec!["arn:aws:iam::123456789012:user/alice"]) @@ -452,27 +581,159 @@ mod tests { #[test] fn assume_role_context_request_tags_and_tag_keys() { - let ctx = AssumeRoleContext { - principal_tags: HashMap::new(), - external_id: None, - principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), - principal_account: Some("123456789012".to_owned()), - request_tags: HashMap::from([("env".to_owned(), "prod".to_owned())]), - }; + let ctx = ar_ctx( + HashMap::new(), + None, + HashMap::from([("env".to_owned(), "prod".to_owned())]), + ); assert_eq!(ctx.resolve_key("aws:RequestTag/env"), Some(vec!["prod"])); assert_eq!(ctx.resolve_key("aws:RequestTag/missing"), None); assert_eq!(ctx.resolve_key("aws:TagKeys"), Some(vec!["env"])); assert!(ctx.is_multivalued_key("aws:TagKeys")); // With no session tags, aws:TagKeys is genuinely absent, not // unresolvable. - let empty = AssumeRoleContext { - principal_tags: HashMap::new(), - external_id: None, - principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), - principal_account: Some("123456789012".to_owned()), - request_tags: HashMap::new(), - }; + let empty = ar_ctx(HashMap::new(), None, HashMap::new()); assert_eq!(empty.resolve_key("aws:TagKeys"), None); assert!(!empty.is_unresolvable_key("aws:TagKeys")); } + + fn user_ctx() -> RequestContext { + RequestContext::build( + HashMap::new(), + HashMap::from([("env".to_owned(), "prod".to_owned())]), + false, + RequestParams { + principal_arn: Some("arn:aws:iam::123456789012:user/alice".to_owned()), + principal_account: Some("123456789012".to_owned()), + region: Some("us-east-1".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + username: Some("alice".to_owned()), + ..Default::default() + }, + ) + } + + #[test] + fn request_context_resolves_request_wide_keys() { + let ctx = user_ctx(); + assert_eq!( + ctx.resolve_key("aws:RequestedRegion"), + Some(vec!["us-east-1"]) + ); + assert_eq!(ctx.resolve_key("aws:SecureTransport"), Some(vec!["true"])); + assert_eq!(ctx.resolve_key("aws:PrincipalType"), Some(vec!["User"])); + assert_eq!(ctx.resolve_key("aws:username"), Some(vec!["alice"])); + assert_eq!(ctx.resolve_key("aws:ViaAWSService"), Some(vec!["false"])); + assert_eq!( + ctx.resolve_key("aws:PrincipalIsAWSService"), + Some(vec!["false"]) + ); + // Time keys are populated (non-empty). + assert!( + ctx.resolve_key("aws:CurrentTime") + .is_some_and(|v| !v[0].is_empty()) + ); + assert!( + ctx.resolve_key("aws:EpochTime") + .is_some_and(|v| !v[0].is_empty()) + ); + // aws:ResourceTag/* resolves from the resource-tag map, case-insensitive key. + assert_eq!(ctx.resolve_key("aws:ResourceTag/env"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:ResourceTag/ENV"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:ResourceTag/missing"), None); + } + + #[test] + fn request_context_secure_transport_false_when_plaintext() { + let ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + secure_transport: false, + ..Default::default() + }, + ); + assert_eq!(ctx.resolve_key("aws:SecureTransport"), Some(vec!["false"])); + } + + #[test] + fn request_context_role_session_username_is_absent_not_unresolvable() { + // A role session has no user name; the service leaves aws:username + // absent, so it keeps absent-key semantics rather than failing closed. + let ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + principal_type: Some("AssumedRole".to_owned()), + username: None, + ..Default::default() + }, + ); + assert_eq!( + ctx.resolve_key("aws:PrincipalType"), + Some(vec!["AssumedRole"]) + ); + assert_eq!(ctx.resolve_key("aws:username"), None); + assert!(!ctx.is_unresolvable_key("aws:username")); + } + + #[test] + fn unresolvable_classification_matches_the_revised_rule() { + // Service-populated aws: keys ExtendDB does not resolve fail closed. + for k in [ + "aws:PrincipalOrgID", + "aws:PrincipalOrgPaths", + "aws:userid", + "aws:SourceIp", + "aws:UserAgent", + "aws:NoSuchGlobalKey", + ] { + assert!(aws_key_is_unresolvable(k), "{k} should be unresolvable"); + } + // Legitimately-absent keys keep absent-key semantics. + for k in [ + "aws:MultiFactorAuthPresent", + "aws:MultiFactorAuthAge", + "aws:TokenIssueTime", + "aws:SourceVpc", + "aws:SourceVpce", + "aws:VpcSourceIp", + "aws:SourceArn", + "aws:SourceAccount", + "aws:SourceOrgID", + "aws:SourceOrgPaths", + "aws:CalledVia", + "aws:CalledViaFirst", + "aws:CalledViaLast", + "aws:FederatedProvider", + "aws:Referer", + "aws:SourceIdentity", + "aws:Ec2InstanceSourceVpc", + "aws:Ec2InstanceSourcePrivateIPv4", + "aws:username", + "aws:TagKeys", + ] { + assert!(is_absent_ok_global_key(k), "{k} should be absent-ok"); + assert!( + !aws_key_is_unresolvable(k), + "{k} should not be unresolvable" + ); + } + // Tag sub-keys and non-aws: keys are never unresolvable. + for k in [ + "aws:PrincipalTag/team", + "aws:RequestTag/env", + "aws:ResourceTag/env", + "dynamodb:LeadingKeys", + "sts:ExternalId", + ] { + assert!( + !aws_key_is_unresolvable(k), + "{k} should not be unresolvable" + ); + } + } } diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index af3a0c517..573c3144c 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -111,6 +111,8 @@ async fn check_user_authorization( let mut params = params; params.principal_arn = Some(format!("arn:aws:iam::{account_id}:user/{user_name}")); params.principal_account = Some(account_id.to_owned()); + params.principal_type = Some("User".to_owned()); + params.username = Some(user_name.to_owned()); let context = RequestContext::build( (*principal_tags).clone(), (*resource_tags).clone(), @@ -168,6 +170,9 @@ async fn check_role_authorization( "arn:aws:sts::{account_id}:assumed-role/{role_name}/{session_name}" )); params.principal_account = Some(account_id.to_owned()); + // A role session's principal type is AssumedRole; the service does not + // populate aws:username for it, so username stays None (absent). + params.principal_type = Some("AssumedRole".to_owned()); let context = RequestContext::build(principal_tags, (*resource_tags).clone(), is_scan, params); // Convert the Arc> to a slice for the evaluator. We materialize diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index b36c04ab2..7a2471de9 100755 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -126,6 +126,7 @@ pub async fn start_server( let metrics_for_console = state.metrics.clone(); let authz_cache_for_mgmt = state.authz_cache.clone(); let table_key_info_cache_for_mgmt = state.table_key_info_cache.clone(); + let mgmt_region = state.region.clone(); let version_info = state.version_info.clone(); let docs_store = state.docs_store.clone(); let listen_url = { @@ -173,6 +174,8 @@ pub async fn start_server( auth_cache: auth_cache_for_mgmt, authz_cache: authz_cache_for_mgmt, table_key_info_cache: table_key_info_cache_for_mgmt, + region: mgmt_region, + secure_transport: tls_enabled, }); let mgmt_router = management::router().with_state(mgmt_state); app = app.nest("/management", mgmt_router); diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index dadcd6a63..43be7ec85 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -159,6 +159,8 @@ pub async fn assume_role( request.external_id.as_deref(), has_session_tags, request_tags, + &state.region, + state.secure_transport, &*state.catalog_store, ) .await @@ -305,6 +307,8 @@ async fn evaluate_trust_policy( external_id: Option<&str>, has_session_tags: bool, request_tags: std::collections::HashMap, + region: &str, + secure_transport: bool, store: &dyn extenddb_storage::CatalogStore, ) -> bool { let Some(statements) = statements_vec(trust_policy) else { @@ -312,17 +316,45 @@ async fn evaluate_trust_policy( }; let principal_tags = fetch_caller_tags(caller_arn, store).await; + let (current_time, epoch_time) = extenddb_auth::policy::context::request_time_strings(); let context = AssumeRoleContext { principal_tags, external_id: external_id.map(String::from), principal_arn: caller_arn.to_owned(), principal_account: caller_account(caller_arn).map(ToOwned::to_owned), request_tags, + region: Some(region.to_owned()), + secure_transport, + principal_type: caller_principal_type(caller_arn).map(ToOwned::to_owned), + username: caller_username(caller_arn).map(ToOwned::to_owned), + current_time, + epoch_time, }; trust_decision(&statements, caller_arn, &context, has_session_tags) } +/// The `aws:PrincipalType` for a caller ARN: `User` for an IAM user ARN, +/// `AssumedRole` for an assumed-role ARN. `None` for any other form, which then +/// fails closed if a trust policy conditions on `aws:PrincipalType`. Measured +/// live: an IAM user caller is `User`, a role session is `AssumedRole`. +fn caller_principal_type(caller_arn: &str) -> Option<&'static str> { + let resource = caller_arn.splitn(6, ':').nth(5)?; + if resource.starts_with("user/") { + Some("User") + } else if resource.starts_with("assumed-role/") { + Some("AssumedRole") + } else { + None + } +} + +/// The `aws:username` for a caller ARN: the user name for an IAM user ARN; +/// `None` otherwise (the service does not populate it for a role session). +fn caller_username(caller_arn: &str) -> Option<&str> { + caller_arn.splitn(6, ':').nth(5)?.strip_prefix("user/") +} + /// The statements of a policy document as an owned list. `Statement` may be a /// single object or an array; both are supported, matching IAM. Returns `None` /// when `Statement` is absent or neither an object nor an array. @@ -855,50 +887,52 @@ mod tests { const CALLER: &str = "arn:aws:iam::123456789012:user/alice"; const BOB: &str = "arn:aws:iam::123456789012:user/bob"; - fn ctx() -> AssumeRoleContext { + fn mk_ctx( + principal_tags: HashMap, + external_id: Option, + request_tags: HashMap, + ) -> AssumeRoleContext { AssumeRoleContext { - principal_tags: HashMap::new(), - external_id: None, + principal_tags, + external_id, principal_arn: CALLER.to_owned(), principal_account: Some(ACCT.to_owned()), - request_tags: HashMap::new(), + request_tags, + region: Some("us-east-1".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + username: Some("alice".to_owned()), + current_time: "2026-01-01T00:00:00Z".to_owned(), + epoch_time: "1767225600".to_owned(), } } + fn ctx() -> AssumeRoleContext { + mk_ctx(HashMap::new(), None, HashMap::new()) + } + fn ctx_ext(id: &str) -> AssumeRoleContext { - AssumeRoleContext { - principal_tags: HashMap::new(), - external_id: Some(id.to_owned()), - principal_arn: CALLER.to_owned(), - principal_account: Some(ACCT.to_owned()), - request_tags: HashMap::new(), - } + mk_ctx(HashMap::new(), Some(id.to_owned()), HashMap::new()) } fn ctx_with_tags(tags: &[(&str, &str)]) -> AssumeRoleContext { - AssumeRoleContext { - principal_tags: tags - .iter() + mk_ctx( + tags.iter() .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) .collect(), - external_id: None, - principal_arn: CALLER.to_owned(), - principal_account: Some(ACCT.to_owned()), - request_tags: HashMap::new(), - } + None, + HashMap::new(), + ) } fn ctx_with_request_tags(tags: &[(&str, &str)]) -> AssumeRoleContext { - AssumeRoleContext { - principal_tags: HashMap::new(), - external_id: None, - principal_arn: CALLER.to_owned(), - principal_account: Some(ACCT.to_owned()), - request_tags: tags - .iter() + mk_ctx( + HashMap::new(), + None, + tags.iter() .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) .collect(), - } + ) } /// Allow alice to assume (sts:AssumeRole). @@ -1534,4 +1568,53 @@ mod tests { "Condition": {"StringEquals": "contractors"}}]}), ); } + + // ---- resolved request-wide keys on the trust path -------------------- + + #[test] + fn trust_tls_guard_does_not_block_over_tls() { + // T8: Allow alice, Deny * if Bool aws:SecureTransport=false. The call is + // over TLS, so SecureTransport is true, the deny does not fire, and the + // allow grants. Matches live STS (ALLOW). + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"Bool": {"aws:SecureTransport": "false"}}}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn trust_secure_transport_true_grants() { + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"Bool": {"aws:SecureTransport": "true"}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn trust_principal_type_user_grants_and_assumedrole_does_not() { + // The caller is an IAM user, so PrincipalType=User grants and + // PrincipalType=AssumedRole does not. Matches live STS. + let user_gate = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalType": "User"}}})]; + assert!(trust_decision(&user_gate, CALLER, &ctx(), false)); + let role_gate = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalType": "AssumedRole"}}})]; + assert!(!trust_decision(&role_gate, CALLER, &ctx(), false)); + } + + #[test] + fn caller_principal_type_and_username_derivation() { + assert_eq!(caller_principal_type(CALLER), Some("User")); + assert_eq!(caller_username(CALLER), Some("alice")); + let role = "arn:aws:sts::123456789012:assumed-role/app-role/session-1"; + assert_eq!(caller_principal_type(role), Some("AssumedRole")); + assert_eq!(caller_username(role), None); + let root = "arn:aws:iam::123456789012:root"; + assert_eq!(caller_principal_type(root), None); + assert_eq!(caller_username(root), None); + } } diff --git a/crates/server/src/management/mod.rs b/crates/server/src/management/mod.rs index 0682c98fc..b132b16b6 100755 --- a/crates/server/src/management/mod.rs +++ b/crates/server/src/management/mod.rs @@ -49,6 +49,12 @@ pub struct ManagementState { /// Concrete `TableKeyInfo` cache handle, used by the auth-cache-metrics /// endpoint. Same instance is held in `auth_cache.table_key_info`. pub table_key_info_cache: Arc, + /// Server region, resolved into `aws:RequestedRegion` during trust policy + /// evaluation. + pub region: Arc, + /// Whether the listener serves TLS, resolved into `aws:SecureTransport` + /// during trust policy evaluation. + pub secure_transport: bool, } /// Build the management API router. diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 923b5e483..76eab7f63 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -115,7 +115,11 @@ pub(crate) async fn authorize_request( &action_op, &resource_arn, false, - extenddb_auth::policy::context::RequestParams::default(), + extenddb_auth::policy::context::RequestParams { + region: Some(state.region.to_string()), + secure_transport: state.tls_enabled, + ..Default::default() + }, ) .await?; } @@ -143,6 +147,8 @@ pub(crate) async fn authorize_request( .get("ReturnConsumedCapacity") .and_then(|v| v.as_str()) .map(ToOwned::to_owned), + region: Some(state.region.to_string()), + secure_transport: state.tls_enabled, ..Default::default() }; authorization::check_authorization( From d73acf0832432617fc805deb8e16d30833f02fc8 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 00:30:18 +0000 Subject: [PATCH 15/58] test(auth): wire tests for the revised global-key rule Identity path (test_abac.py): guard statements on keys ExtendDB now resolves grant, matching live DynamoDB (aws:SecureTransport, aws:RequestedRegion, aws:PrincipalType, aws:CurrentTime); keys the service populates evaluate as present (aws:ViaAWSService, aws:PrincipalIsAWSService, aws:ResourceTag), a non-MFA caller keeps absent-key semantics under BoolIfExists aws:MultiFactorAuthPresent, and an unknown aws: key fails closed. Trust path (test_auth_integration.py): the TLS guard on AssumeRole no longer blocks over TLS, and aws:PrincipalType resolves for the calling principal. Expected outcomes measured live on DynamoDB, IAM and STS in us-east-1. Pass on SQLite and PostgreSQL (identity and trust) and on MongoDB and Cassandra (trust). --- tests/test_abac.py | 148 +++++++++++++++++++++++++++++++++ tests/test_auth_integration.py | 47 +++++++++++ 2 files changed, 195 insertions(+) diff --git a/tests/test_abac.py b/tests/test_abac.py index e8e1081d8..6aea92c93 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -536,3 +536,151 @@ def test_allow_gated_on_resolved_principal_arn_grants(self): finally: admin_client.delete_table(TableName=table) self.mgmt.delete_user(self.account_id, user) + + +# --------------------------------------------------------------------------- +# Revised global-key rule: resolvable keys, service-populated keys, allowlist +# --------------------------------------------------------------------------- + +def _run_get(mgmt: ManagementClient, endpoint: str, region: str, account_id: str, + table: str, policy: dict) -> str: + """Create a fresh user with `policy`, call GetItem on `table`, return the + outcome, and clean the user up. ExtendDB serves fresh policy on write, so no + retry is needed.""" + user = f"gk-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(mgmt, account_id, user) + resp = mgmt.put_user_policy(account_id, user, "p", policy) + assert resp.status_code == 204, resp.text + client = _make_client(endpoint, ak, sk, region) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + return "DENY" + finally: + mgmt.delete_user(account_id, user) + + +class _TaggedTableBase: + """Shared env=prod tagged table for the global-key tests.""" + + @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 + + @pytest.fixture + def table(self): + admin = f"gk-adm-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, admin) + resp = self.mgmt.put_user_policy( + self.account_id, admin, "full", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}]}, + ) + assert resp.status_code == 204 + admin_client = _make_client(self.endpoint, ak, sk, self.region) + name = f"gk-{uuid.uuid4().hex[:8]}" + admin_client.create_table( + TableName=name, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + Tags=[{"Key": "env", "Value": "prod"}], + ) + wait_for_active(admin_client, name) + admin_client.put_item(TableName=name, Item={"pk": {"S": "k1"}, "data": {"S": "v1"}}) + yield name + try: + admin_client.delete_table(TableName=name) + wait_for_deleted(admin_client, name) + except Exception: + pass + self.mgmt.delete_user(self.account_id, admin) + + def _get(self, table: str, policy: dict) -> str: + return _run_get(self.mgmt, self.endpoint, self.region, self.account_id, table, policy) + + @staticmethod + def _allow_get_if(condition: dict) -> dict: + return {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": condition}]} + + @staticmethod + def _allow_all_deny_get_if(condition: dict) -> dict: + return {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}, + {"Effect": "Deny", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": condition}]} + + +class TestResolvableGlobalKeys(_TaggedTableBase): + """Guard statements on keys ExtendDB resolves no longer fail closed. + All four grant, matching live DynamoDB.""" + + def test_securetransport_deny_guard_allows(self, table): + # Deny if not over TLS; the request is over TLS so the deny is inert. + assert self._get(table, self._allow_all_deny_get_if( + {"Bool": {"aws:SecureTransport": "false"}})) == "ALLOW" + + def test_requestedregion_deny_guard_allows(self, table): + # Deny if region is not the server region; it is, so the deny is inert. + assert self._get(table, self._allow_all_deny_get_if( + {"StringNotEquals": {"aws:RequestedRegion": self.region}})) == "ALLOW" + + def test_principaltype_user_allows(self, table): + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:PrincipalType": "User"}})) == "ALLOW" + + def test_currenttime_allows(self, table): + assert self._get(table, self._allow_get_if( + {"DateGreaterThan": {"aws:CurrentTime": "2020-01-01T00:00:00Z"}})) == "ALLOW" + + +class TestServicePopulatedGlobalKeys(_TaggedTableBase): + """The allowlist: keys the service populates evaluate as present, and + an unknown aws: key fails closed.""" + + def test_viaawsservice_negated_allow_denies(self, table): + # ViaAWSService is present and false; StringNotEquals=false is false, so + # the Allow does not grant. Live DynamoDB: DENY. + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:ViaAWSService": "false"}})) == "DENY" + + def test_principalisawsservice_negated_allow_denies(self, table): + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:PrincipalIsAWSService": "false"}})) == "DENY" + + def test_resourcetag_negated_allow_denies(self, table): + # Table tagged env=prod; StringNotEquals env=prod is false. Live: DENY. + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:ResourceTag/env": "prod"}})) == "DENY" + + def test_resourcetag_positive_allow_grants(self, table): + # aws:ResourceTag/env resolves to prod. Live: ALLOW. + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:ResourceTag/env": "prod"}})) == "ALLOW" + + def test_mfa_boolifexists_allows_non_mfa_caller(self, table): + # A non-MFA caller has aws:MultiFactorAuthPresent absent; BoolIfExists + # passes when the key is absent. Live: ALLOW. + assert self._get(table, self._allow_get_if( + {"BoolIfExists": {"aws:MultiFactorAuthPresent": "false"}})) == "ALLOW" + + def test_unknown_key_negated_allow_fails_closed(self, table): + # An unknown aws: key is unresolvable, so the Allow does not grant even + # under a negated operator. ExtendDB denies here while live DynamoDB + # allows (treating the key as absent); this is the deliberate + # fail-closed over-denial recorded in the report's open questions. + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:NoSuchGlobalKey": "x"}})) == "DENY" + + def test_unknown_key_deny_fails_closed(self, table): + # A Deny gated on an unknown aws: key fails closed and blocks. Live + # DynamoDB also denies here (the negated-absent rule applies the Deny). + assert self._get(table, self._allow_all_deny_get_if( + {"StringNotEquals": {"aws:NoSuchGlobalKey": "x"}})) == "DENY" diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 212c015cd..4ff72c34a 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1122,6 +1122,53 @@ def test_tag_key_case_insensitive_negated_deny_does_not_overdeny(self): finally: self.mgmt.delete_user(self.account_id, user) + # -- Resolved request-wide keys on the trust path (T8) ------------------ + + def test_tls_guard_does_not_block_over_tls(self): + # Allow, plus Deny if Bool aws:SecureTransport=false. The call is over + # TLS so SecureTransport is true, the deny does not fire, assumption is + # granted. Live STS: ALLOW. Round one failed closed on this guard. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"Bool": {"aws:SecureTransport": "false"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_secure_transport_true_grants(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"Bool": {"aws:SecureTransport": "true"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_principaltype_user_grants(self): + # The caller is an IAM user, so aws:PrincipalType is User. Live STS: ALLOW. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalType": "User"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_principaltype_assumedrole_does_not_grant(self): + # The caller is a user, so PrincipalType=AssumedRole does not match and + # the gated Allow does not grant. Live STS: DENY. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalType": "AssumedRole"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + def test_string_not_equals_ignore_case_external_id(self): # StringNotEqualsIgnoreCase is implemented, so a matching external id # (any case) makes the condition false and the Deny inert; an absent From 79c93c3e1b0c844cd99d06440791ac89526da6f1 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 00:41:27 +0000 Subject: [PATCH 16/58] chore(auth): keep clippy happy on the trust policy evaluator argument count --- crates/server/src/management/assume_role.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index 43be7ec85..c88b9db19 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -301,6 +301,7 @@ async fn generate_and_store_session( /// malformed stored `Deny` cannot silently grant assumption. Documents are /// validated at write time (`validate_trust_policy`), so a well-formed store /// does not reach the fail-closed path in normal operation. +#[allow(clippy::too_many_arguments)] async fn evaluate_trust_policy( trust_policy: &Value, caller_arn: &str, From a6103709387465053544e54d4233c4e014d69950 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 00:43:23 +0000 Subject: [PATCH 17/58] docs: describe the revised global-key resolution rule for trust and identity policies --- docs/adr/0022-trust-policy-deny-over-allow.md | 31 ++++++++------ docs/manuals/05-admin-guide.md | 42 ++++++++++--------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/docs/adr/0022-trust-policy-deny-over-allow.md b/docs/adr/0022-trust-policy-deny-over-allow.md index 61e723161..20e080087 100644 --- a/docs/adr/0022-trust-policy-deny-over-allow.md +++ b/docs/adr/0022-trust-policy-deny-over-allow.md @@ -102,19 +102,26 @@ implicitly denied. context resolves the key to a value: evaluate normally. (2) The context resolves it as absent the same way the service would: IAM absent-key semantics apply, including that a negated operator on an absent single-valued - key matches. (3) The service always populates the key but the context cannot - resolve it: the statement fails closed whatever the operator, so a `Deny` + key matches. (3) An `aws:` global key the service may populate but the context + cannot resolve: the statement fails closed whatever the operator, so a `Deny` blocks and an `Allow` does not grant, rather than the key being treated as - absent (which would let a negated operator spuriously match). The - `AssumeRoleContext` resolves `aws:PrincipalArn`, `aws:PrincipalAccount`, - `aws:PrincipalTag/*` (tag key matched case-insensitively), `aws:RequestTag/*`, - `aws:TagKeys` and `sts:ExternalId`; the always-populated global keys it does - not resolve (`aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, - `aws:PrincipalType`, `aws:userid`, `aws:username`, `aws:SourceIp`, - `aws:SecureTransport`, `aws:CurrentTime`, `aws:EpochTime`, - `aws:RequestedRegion`, `aws:UserAgent`) are case 3. Conditionally-present keys - such as `aws:MultiFactorAuthPresent` are absent for ExtendDB's caller model - and keep absent-key semantics. + absent (which would let a negated operator spuriously match). Both contexts + resolve every global key ExtendDB has the data for: `aws:SecureTransport` + (from the listener TLS state), `aws:RequestedRegion` (the server region), + `aws:CurrentTime` and `aws:EpochTime` (the request time), `aws:PrincipalType`, + `aws:username` (for a user caller), `aws:ViaAWSService`, + `aws:PrincipalIsAWSService`, `aws:PrincipalArn`, `aws:PrincipalAccount`, the + tag keys (`aws:PrincipalTag/*`, `aws:RequestTag/*`, `aws:ResourceTag/*` on the + identity path, `dynamodb:ResourceTag/*`, each matched case-insensitively on + the key), `sts:ExternalId` and `aws:TagKeys`. Keys that are legitimately + absent for ExtendDB's callers keep absent-key semantics; these are an + allowlist that includes `aws:MultiFactorAuthPresent`, `aws:TokenIssueTime`, + the VPC, federation and source-service keys, and `aws:username` on a role + session. Any other unresolved `aws:` global key (for example + `aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, `aws:userid`, `aws:SourceIp`, + `aws:UserAgent`) is case 3 and fails closed; the allowlist, rather than a + denylist of keys the service populates, is what the evaluator maintains, so a + key AWS adds later fails closed rather than silently granting. - **Follow-up:** the decision logic was factored into a store-free `trust_decision` helper (with `action_permitted`, `deny_statement_blocks`, `allow_statement_grants` and `effect_of`) so the diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index 851ce1e46..a7a88efa6 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -350,19 +350,20 @@ in-account caller on the strength of the trust policy by itself. What a trust-policy `Deny` does not do: -- A statement whose `Condition` uses a condition key ExtendDB does not resolve, - or an operator it does not implement, fails closed: a `Deny` blocks assumption - and an `Allow` grants nothing, rather than the key being treated as absent. - ExtendDB resolves these trust-policy keys: `aws:PrincipalTag/` (tag key - matched case-insensitively), `sts:ExternalId`, `aws:PrincipalArn`, - `aws:PrincipalAccount`, `aws:RequestTag/` and `aws:TagKeys` (from the - passed session tags). It does not evaluate these global keys that STS - populates, so a statement depending on one fails closed: `aws:PrincipalOrgID`, - `aws:PrincipalOrgPaths`, `aws:PrincipalType`, `aws:userid`, `aws:username`, - `aws:SourceIp`, `aws:SecureTransport`, `aws:CurrentTime`, `aws:EpochTime`, - `aws:RequestedRegion` and `aws:UserAgent`. The practical effect: a common - organization-scoped trust policy (`Allow` gated on `aws:PrincipalOrgID`, or a - `Deny` outside the org) is refused on ExtendDB rather than silently granted. +- A statement whose `Condition` uses an `aws:` global key ExtendDB cannot + resolve, or an operator it does not implement, fails closed: a `Deny` blocks + assumption and an `Allow` grants nothing, rather than the key being treated as + absent. ExtendDB resolves these trust-policy keys: `aws:PrincipalTag/` + (tag key matched case-insensitively), `sts:ExternalId`, `aws:PrincipalArn`, + `aws:PrincipalAccount`, `aws:RequestTag/`, `aws:TagKeys` (from the passed + session tags), `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, + `aws:EpochTime`, `aws:PrincipalType`, `aws:username` (for a user caller), + `aws:ViaAWSService` and `aws:PrincipalIsAWSService`. Keys that are absent for + ExtendDB's callers (for example `aws:MultiFactorAuthPresent`, the VPC, + federation and source-service keys, and `aws:username` for a role session) + keep absent-key semantics. An `aws:` global key ExtendDB does not resolve (for + example `aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, `aws:userid`, + `aws:SourceIp`, `aws:UserAgent`) causes a statement using it to fail closed. The operators `IpAddress`, `NotIpAddress` and `BinaryEquals` are accepted at role creation (IAM accepts them) but are not evaluated, so a statement using one also fails closed. Write a conditional trust statement only on the @@ -433,12 +434,15 @@ as trust policies. A condition key the request resolves is evaluated normally. A key that is genuinely absent follows IAM absent-key semantics, including that a negated operator (`StringNotEquals`, `StringNotLike`, `ArnNotEquals`) on an absent single-valued key matches, so a `Deny` gated on one applies when the -caller omits the key. A global key the service always populates but ExtendDB -does not resolve (`aws:PrincipalOrgID`, `aws:SourceIp`, `aws:PrincipalType`, -`aws:userid`, and the others listed for trust policies above) fails closed: a -`Deny` on it blocks and an `Allow` on it does not grant. ExtendDB resolves -`aws:PrincipalArn`, `aws:PrincipalAccount`, `aws:PrincipalTag/` (tag key -case-insensitive) and `dynamodb:ResourceTag/` on a data-plane request. +caller omits the key. An `aws:` global key ExtendDB cannot resolve (for example +`aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, `aws:userid`, `aws:SourceIp`, +`aws:UserAgent`) fails closed: a `Deny` on it blocks and an `Allow` on it does +not grant. ExtendDB resolves `aws:PrincipalArn`, `aws:PrincipalAccount`, +`aws:PrincipalType`, `aws:username` (for a user caller), `aws:SecureTransport`, +`aws:RequestedRegion`, `aws:CurrentTime`, `aws:EpochTime`, `aws:ViaAWSService`, +`aws:PrincipalIsAWSService`, `aws:PrincipalTag/`, `aws:ResourceTag/` +and `dynamodb:ResourceTag/` (tag keys matched case-insensitively) on a +data-plane request. ### Permissions Boundaries From 02969a908e6f25f0180091ac6ef606f2a6a4a626 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 01:18:48 +0000 Subject: [PATCH 18/58] fix(auth): resolve the remaining global condition keys and treat unpopulated ones as absent The earlier rule kept an allowlist of globally absent condition keys and failed closed on every other aws: global key. That over-denied statements on keys the service populates for ExtendDB's callers but ExtendDB had not yet resolved, and on keys the service treats as absent. Change the default for an unresolved aws: global key from fail closed to absent, matching the service, and resolve the keys the service populates: - Organization keys (aws:PrincipalOrgID, aws:PrincipalOrgPaths, and the resource and source organization keys) are absent. An ExtendDB account is a standalone AWS account, where IAM leaves these keys unset, so absent key semantics apply. - aws:userid resolves to the principal unique id: an AIDA id for a user and role-id:session-name for a role session, derived deterministically from the catalog identity (new policy::principal_id module). The format and the role-session shape were measured live. - aws:SourceIp and aws:UserAgent resolve from the request. IpAddress and NotIpAddress are now evaluated as IPv4 and IPv6 CIDR membership, with a family mismatch matching neither operator, as measured live. - aws:ResourceAccount resolves to the resource owner account. The fail-closed set is now empty, kept as an explicit list so a future global key can be added if the service begins populating it before ExtendDB resolves it. Unit tests cover the operators, the userid derivation, and the revised classification. --- crates/auth/src/policy/condition.rs | 12 ++ crates/auth/src/policy/context.rs | 215 ++++++++++++++----------- crates/auth/src/policy/document.rs | 5 + crates/auth/src/policy/matcher.rs | 94 +++++++++++ crates/auth/src/policy/mod.rs | 1 + crates/auth/src/policy/principal_id.rs | 181 +++++++++++++++++++++ 6 files changed, 416 insertions(+), 92 deletions(-) create mode 100644 crates/auth/src/policy/principal_id.rs diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index 5f5ffba7e..a48f60840 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -290,6 +290,7 @@ fn is_negative_operator(op: &ConditionOperator) -> bool { | ConditionOperator::StringNotLike | ConditionOperator::ArnNotEquals | ConditionOperator::ArnNotLike + | ConditionOperator::NotIpAddress ) } @@ -348,6 +349,17 @@ fn compare_single(op: &ConditionOperator, context_value: &str, policy_value: &st ConditionOperator::ArnNotEquals => context_value != policy_value, ConditionOperator::ArnLike => super::matcher::arn_match(policy_value, context_value), ConditionOperator::ArnNotLike => !super::matcher::arn_match(policy_value, context_value), + // IpAddress matches only when the caller address and the policy range + // are the same family and the address is in the range. NotIpAddress + // matches only when they are the same family and the address is not in + // the range. A family mismatch or a malformed value makes both false, + // matching the live service. + ConditionOperator::IpAddress => { + super::matcher::ip_in_cidr(policy_value, context_value) == Some(true) + } + ConditionOperator::NotIpAddress => { + super::matcher::ip_in_cidr(policy_value, context_value) == Some(false) + } // Null, ForAllValues, ForAnyValue, IfExists handled by caller _ => false, } diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index dec05aab0..d2528e245 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -58,68 +58,38 @@ fn ci_tag_lookup<'a>(map: &'a HashMap, tag_key: &str) -> Option< .map(|(_, v)| vec![v.as_str()]) } -/// Global `aws:` condition keys that are legitimately absent for ExtendDB's -/// caller model: the live service also leaves them unset on the equivalent -/// request, so IAM absent-key semantics apply to them (a negated operator on an -/// absent one matches; an `Allow` gated on its presence does not grant). -/// Measured live on a plain IAM user request and on an assumed-role session. +/// Documented AWS global condition keys the live service populates for a +/// request from one of ExtendDB's callers but that this build cannot resolve to +/// the value the service would have. A statement using one fails closed: a +/// `Deny` blocks and an `Allow` does not grant, instead of the key being +/// treated as absent (where a negated operator on an absent single-valued key +/// would spuriously match). /// -/// This is an allowlist, the inverse of the earlier denylist. Any other -/// unresolved `aws:` global key (for example `aws:PrincipalOrgID`, -/// `aws:SourceIp`, or a key AWS adds later) is treated as unresolvable and fails -/// closed, so the evaluator does not have to track the full, growing set of -/// keys the service populates. `aws:username` and `aws:TagKeys` are listed -/// because each is resolved when present and legitimately absent otherwise (no -/// user name on a role session; no session tags on a plain call). -#[must_use] -pub fn is_absent_ok_global_key(key: &str) -> bool { - matches!( - key, - "aws:MultiFactorAuthPresent" - | "aws:MultiFactorAuthAge" - | "aws:TokenIssueTime" - | "aws:SourceVpc" - | "aws:SourceVpce" - | "aws:VpcSourceIp" - | "aws:SourceArn" - | "aws:SourceAccount" - | "aws:SourceOrgID" - | "aws:SourceOrgPaths" - | "aws:CalledVia" - | "aws:CalledViaFirst" - | "aws:CalledViaLast" - | "aws:FederatedProvider" - | "aws:Referer" - | "aws:SourceIdentity" - | "aws:Ec2InstanceSourceVpc" - | "aws:Ec2InstanceSourcePrivateIPv4" - | "aws:username" - | "aws:TagKeys" - ) -} +/// This set is empty. ExtendDB resolves every global key the live service +/// populates for a signed IAM-user or assumed-role request, or an `AssumeRole` +/// call: the principal identity keys, `aws:userid`, the request time, region, +/// TLS state, source address, user agent, principal and resource account, and +/// the tag keys. Every other `aws:` global key is one the service leaves absent +/// for a standalone AWS account reached by a direct caller (the organization, +/// VPC-endpoint, federation, MFA, service-principal and cross-service keys), or +/// one the service does not recognize; the service treats both as absent. +/// ExtendDB does the same by not listing them here, so an unresolved key keeps +/// IAM absent-key semantics. The slice stays so a future global key the service +/// begins populating before ExtendDB resolves it can be made to fail closed by +/// listing it. +const FAIL_CLOSED_GLOBAL_KEYS: &[&str] = &[]; /// Whether an unresolved condition key must fail closed for the context that /// could not resolve it. Consulted only after `resolve_key` returned `None`. /// -/// Only `aws:` global keys fail closed. `dynamodb:` and `sts:` keys keep -/// absent-key semantics (a genuinely absent one simply does not match). A tag -/// sub-key (`aws:PrincipalTag/`, `aws:RequestTag/`, `aws:ResourceTag/`) is -/// genuinely absent when unresolved, so it keeps absent-key semantics too. A -/// key on the absent-ok allowlist is legitimately absent. Every other `aws:` -/// key is one the service may populate but this context has no value for, so it -/// is unresolvable and the statement using it fails closed. +/// A key fails closed only when it is on [`FAIL_CLOSED_GLOBAL_KEYS`] (currently +/// empty). Every other unresolved key, `aws:` global, `dynamodb:`, +/// `sts:` or a tag sub-key, keeps absent-key semantics: a genuinely absent key +/// does not match, and a negated operator on an absent single-valued key +/// matches, which is what the live service does for a key it does not populate. #[must_use] pub fn aws_key_is_unresolvable(key: &str) -> bool { - if !key.starts_with("aws:") { - return false; - } - if key.starts_with("aws:PrincipalTag/") - || key.starts_with("aws:RequestTag/") - || key.starts_with("aws:ResourceTag/") - { - return false; - } - !is_absent_ok_global_key(key) + FAIL_CLOSED_GLOBAL_KEYS.contains(&key) } /// The request time as the pair (`aws:CurrentTime` as RFC 3339 UTC, @@ -166,6 +136,19 @@ pub struct RequestParams { /// The IAM user name (`aws:username`) for a user caller; `None` for a role /// session, where the service does not populate it. pub username: Option, + /// The caller's source IP (`aws:SourceIp`): the peer address of the + /// connection the request arrived on. `None` when it could not be + /// determined, where it keeps absent-key semantics. + pub source_ip: Option, + /// The caller's `User-Agent` header (`aws:UserAgent`), when present. + pub user_agent: Option, + /// The caller's principal unique id (`aws:userid`): an `AIDA...` id for a + /// user, `:` for a role session. + pub userid: Option, + /// The account that owns the target resource (`aws:ResourceAccount`). For + /// ExtendDB this is the account in the resource ARN, which is the caller's + /// account (ExtendDB resources are same-account). + pub resource_account: Option, } /// Context for evaluating conditions on `DynamoDB` operations. @@ -205,6 +188,14 @@ pub struct RequestContext { /// The IAM user name (`aws:username`) for a user caller; `None` for a role /// session. pub username: Option, + /// The caller's source IP (`aws:SourceIp`). + pub source_ip: Option, + /// The caller's `User-Agent` header (`aws:UserAgent`). + pub user_agent: Option, + /// The caller's principal unique id (`aws:userid`). + pub userid: Option, + /// The target resource owner account (`aws:ResourceAccount`). + pub resource_account: Option, /// The request time formatted for `aws:CurrentTime` (RFC 3339 UTC). pub current_time: String, /// The request time formatted for `aws:EpochTime` (whole seconds). @@ -241,6 +232,10 @@ impl RequestContext { secure_transport: params.secure_transport, principal_type: params.principal_type, username: params.username, + source_ip: params.source_ip, + user_agent: params.user_agent, + userid: params.userid, + resource_account: params.resource_account, current_time, epoch_time, } @@ -291,6 +286,10 @@ impl ConditionContext for RequestContext { "aws:EpochTime" => Some(vec![self.epoch_time.as_str()]), "aws:PrincipalType" => self.principal_type.as_deref().map(|v| vec![v]), "aws:username" => self.username.as_deref().map(|v| vec![v]), + "aws:SourceIp" => self.source_ip.as_deref().map(|v| vec![v]), + "aws:UserAgent" => self.user_agent.as_deref().map(|v| vec![v]), + "aws:userid" => self.userid.as_deref().map(|v| vec![v]), + "aws:ResourceAccount" => self.resource_account.as_deref().map(|v| vec![v]), // ExtendDB is not an AWS service calling on a principal's behalf, // so these are always false, matching the service for a direct // caller. @@ -355,6 +354,18 @@ pub struct AssumeRoleContext { /// The calling IAM user's name (`aws:username`); `None` when the caller is /// not an IAM user (for example a role session), where it is absent. pub username: Option, + /// The caller's source IP (`aws:SourceIp`): the peer address of the + /// `AssumeRole` connection, which is what STS records. + pub source_ip: Option, + /// The caller's `User-Agent` header (`aws:UserAgent`) on the `AssumeRole` + /// call. + pub user_agent: Option, + /// The caller's principal unique id (`aws:userid`), derived from the caller + /// ARN. `None` for a caller ARN that is neither a user nor an assumed-role + /// session. + pub userid: Option, + /// The account that owns the role being assumed (`aws:ResourceAccount`). + pub resource_account: Option, /// The request time formatted for `aws:CurrentTime` (RFC 3339 UTC). pub current_time: String, /// The request time formatted for `aws:EpochTime` (whole seconds). @@ -382,6 +393,10 @@ impl ConditionContext for AssumeRoleContext { "aws:EpochTime" => Some(vec![self.epoch_time.as_str()]), "aws:PrincipalType" => self.principal_type.as_deref().map(|v| vec![v]), "aws:username" => self.username.as_deref().map(|v| vec![v]), + "aws:SourceIp" => self.source_ip.as_deref().map(|v| vec![v]), + "aws:UserAgent" => self.user_agent.as_deref().map(|v| vec![v]), + "aws:userid" => self.userid.as_deref().map(|v| vec![v]), + "aws:ResourceAccount" => self.resource_account.as_deref().map(|v| vec![v]), "aws:ViaAWSService" => Some(vec!["false"]), "aws:PrincipalIsAWSService" => Some(vec!["false"]), "aws:TagKeys" => { @@ -436,11 +451,46 @@ mod tests { secure_transport: true, principal_type: Some("User".to_owned()), username: Some("alice".to_owned()), + source_ip: Some("203.0.113.9".to_owned()), + user_agent: Some("aws-sdk-rust".to_owned()), + userid: Some("AIDAEXAMPLEUSERID1234".to_owned()), + resource_account: Some("123456789012".to_owned()), current_time: "2026-01-01T00:00:00Z".to_owned(), epoch_time: "1767225600".to_owned(), } } + #[test] + fn request_context_resolves_new_round3_keys() { + let ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + source_ip: Some("203.0.113.9".to_owned()), + user_agent: Some("Botocore/1.0".to_owned()), + userid: Some("AIDAEXAMPLEUSERID1234".to_owned()), + resource_account: Some("123456789012".to_owned()), + ..Default::default() + }, + ); + assert_eq!(ctx.resolve_key("aws:SourceIp"), Some(vec!["203.0.113.9"])); + assert_eq!(ctx.resolve_key("aws:UserAgent"), Some(vec!["Botocore/1.0"])); + assert_eq!( + ctx.resolve_key("aws:userid"), + Some(vec!["AIDAEXAMPLEUSERID1234"]) + ); + assert_eq!( + ctx.resolve_key("aws:ResourceAccount"), + Some(vec!["123456789012"]) + ); + // Organization keys are absent for a standalone account, not + // unresolvable: they return None and do not fail closed. + assert_eq!(ctx.resolve_key("aws:PrincipalOrgID"), None); + assert!(!ctx.is_unresolvable_key("aws:PrincipalOrgID")); + assert!(!ctx.is_unresolvable_key("aws:PrincipalOrgPaths")); + } + #[test] fn request_context_principal_tag() { let ctx = RequestContext::build( @@ -571,10 +621,10 @@ mod tests { ctx.resolve_key("aws:PrincipalAccount"), Some(vec!["123456789012"]) ); - // A global key STS populates but this context cannot resolve is - // unresolvable (fail closed), not absent. + // An organization key is absent for a standalone account, not + // unresolvable: it returns None and keeps absent-key semantics. assert_eq!(ctx.resolve_key("aws:PrincipalOrgID"), None); - assert!(ctx.is_unresolvable_key("aws:PrincipalOrgID")); + assert!(!ctx.is_unresolvable_key("aws:PrincipalOrgID")); assert!(!ctx.is_unresolvable_key("sts:ExternalId")); assert!(!ctx.is_unresolvable_key("aws:PrincipalTag/team")); } @@ -681,59 +731,40 @@ mod tests { } #[test] - fn unresolvable_classification_matches_the_revised_rule() { - // Service-populated aws: keys ExtendDB does not resolve fail closed. + fn no_documented_global_key_fails_closed_after_round3() { + // The fail-closed set is empty. Every aws: global key is either resolved + // by the context or kept absent (absent-key semantics), matching the + // service; none is reported unresolvable. This includes the keys round + // two failed closed on (the organization keys, aws:userid, aws:SourceIp, + // aws:UserAgent), the keys the service leaves absent for a direct caller, + // an unknown key, tag sub-keys and non-aws: keys. for k in [ "aws:PrincipalOrgID", "aws:PrincipalOrgPaths", + "aws:ResourceOrgID", + "aws:ResourceOrgPaths", + "aws:SourceOrgID", + "aws:SourceOrgPaths", "aws:userid", "aws:SourceIp", "aws:UserAgent", - "aws:NoSuchGlobalKey", - ] { - assert!(aws_key_is_unresolvable(k), "{k} should be unresolvable"); - } - // Legitimately-absent keys keep absent-key semantics. - for k in [ + "aws:ResourceAccount", "aws:MultiFactorAuthPresent", "aws:MultiFactorAuthAge", "aws:TokenIssueTime", "aws:SourceVpc", "aws:SourceVpce", "aws:VpcSourceIp", - "aws:SourceArn", - "aws:SourceAccount", - "aws:SourceOrgID", - "aws:SourceOrgPaths", - "aws:CalledVia", - "aws:CalledViaFirst", - "aws:CalledViaLast", "aws:FederatedProvider", - "aws:Referer", - "aws:SourceIdentity", - "aws:Ec2InstanceSourceVpc", - "aws:Ec2InstanceSourcePrivateIPv4", - "aws:username", - "aws:TagKeys", - ] { - assert!(is_absent_ok_global_key(k), "{k} should be absent-ok"); - assert!( - !aws_key_is_unresolvable(k), - "{k} should not be unresolvable" - ); - } - // Tag sub-keys and non-aws: keys are never unresolvable. - for k in [ + "aws:PrincipalServiceName", + "aws:NoSuchGlobalKey", "aws:PrincipalTag/team", "aws:RequestTag/env", "aws:ResourceTag/env", "dynamodb:LeadingKeys", "sts:ExternalId", ] { - assert!( - !aws_key_is_unresolvable(k), - "{k} should not be unresolvable" - ); + assert!(!aws_key_is_unresolvable(k), "{k} must not fail closed"); } } } diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index 0243389db..4b84ccad9 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -119,6 +119,9 @@ pub enum ConditionOperator { ArnNotEquals, ArnLike, ArnNotLike, + // IP address (CIDR membership, IPv4 and IPv6) + IpAddress, + NotIpAddress, // Set operators (prefix applied to any of the above) ForAllValues(Box), ForAnyValue(Box), @@ -379,6 +382,8 @@ fn parse_base_operator(name: &str) -> Result Ok(ConditionOperator::ArnNotEquals), "ArnLike" => Ok(ConditionOperator::ArnLike), "ArnNotLike" => Ok(ConditionOperator::ArnNotLike), + "IpAddress" => Ok(ConditionOperator::IpAddress), + "NotIpAddress" => Ok(ConditionOperator::NotIpAddress), _ => Err(PolicyParseError(format!("unknown operator: {name}"))), } } diff --git a/crates/auth/src/policy/matcher.rs b/crates/auth/src/policy/matcher.rs index 3e97d98e7..1cb381e96 100755 --- a/crates/auth/src/policy/matcher.rs +++ b/crates/auth/src/policy/matcher.rs @@ -126,6 +126,66 @@ pub fn arn_match(pattern: &str, value: &str) -> bool { .all(|(p, v)| wildcard_match(p, v)) } +/// Whether `addr` falls inside the CIDR `cidr`, for the `IpAddress` and +/// `NotIpAddress` condition operators. +/// +/// `cidr` is an IPv4 or IPv6 network in `address/prefix` form, or a bare +/// address (treated as a full-length prefix). Returns: +/// - `Some(true)` when `addr` and `cidr` are the same family and `addr` is in +/// the network, +/// - `Some(false)` when they are the same family and `addr` is not in it, +/// - `None` when either value does not parse or the families differ. +/// +/// The family-mismatch `None` matters: the live service, given an IPv4 policy +/// range and an IPv6 caller (or the reverse), matches neither `IpAddress` nor +/// `NotIpAddress` (both evaluate false), rather than letting `NotIpAddress` +/// pass by negation. Measured live on this account: an IPv4 `IpAddress` range +/// and an IPv4 `NotIpAddress` range both fail to apply for an IPv6 caller. +#[must_use] +pub fn ip_in_cidr(cidr: &str, addr: &str) -> Option { + use std::net::IpAddr; + + let ip: IpAddr = addr.parse().ok()?; + let (net_str, prefix_str) = match cidr.split_once('/') { + Some((n, p)) => (n, Some(p)), + None => (cidr, None), + }; + let net: IpAddr = net_str.parse().ok()?; + match (ip, net) { + (IpAddr::V4(ip), IpAddr::V4(net)) => { + let prefix: u32 = match prefix_str { + Some(p) => p.parse().ok()?, + None => 32, + }; + if prefix > 32 { + return None; + } + let mask: u32 = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + Some((u32::from(ip) & mask) == (u32::from(net) & mask)) + } + (IpAddr::V6(ip), IpAddr::V6(net)) => { + let prefix: u32 = match prefix_str { + Some(p) => p.parse().ok()?, + None => 128, + }; + if prefix > 128 { + return None; + } + let mask: u128 = if prefix == 0 { + 0 + } else { + u128::MAX << (128 - prefix) + }; + Some((u128::from(ip) & mask) == (u128::from(net) & mask)) + } + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -268,4 +328,38 @@ mod tests { "arn:aws:dynamodb:us-east-1:123:table/Users" )); } + + // --- ip_in_cidr --- + + #[test] + fn ipv4_membership() { + assert_eq!(ip_in_cidr("203.0.113.0/24", "203.0.113.9"), Some(true)); + assert_eq!(ip_in_cidr("203.0.113.0/24", "203.0.114.9"), Some(false)); + assert_eq!(ip_in_cidr("0.0.0.0/0", "52.94.133.133"), Some(true)); + // Bare address is a full-length (/32) prefix. + assert_eq!(ip_in_cidr("52.94.133.133", "52.94.133.133"), Some(true)); + assert_eq!(ip_in_cidr("52.94.133.133/32", "52.94.133.134"), Some(false)); + } + + #[test] + fn ipv6_membership() { + assert_eq!(ip_in_cidr("2001:db8::/32", "2001:db8::1"), Some(true)); + assert_eq!(ip_in_cidr("2001:db8::/32", "2001:dead::1"), Some(false)); + assert_eq!(ip_in_cidr("::/0", "2001:db8::1"), Some(true)); + } + + #[test] + fn family_mismatch_is_none() { + // An IPv4 range and an IPv6 caller (and the reverse) are incomparable: + // neither IpAddress nor NotIpAddress applies. + assert_eq!(ip_in_cidr("203.0.113.0/24", "2001:db8::1"), None); + assert_eq!(ip_in_cidr("2001:db8::/32", "203.0.113.9"), None); + } + + #[test] + fn malformed_is_none() { + assert_eq!(ip_in_cidr("not-a-cidr", "203.0.113.9"), None); + assert_eq!(ip_in_cidr("203.0.113.0/24", "not-an-ip"), None); + assert_eq!(ip_in_cidr("203.0.113.0/33", "203.0.113.9"), None); + } } diff --git a/crates/auth/src/policy/mod.rs b/crates/auth/src/policy/mod.rs index 9af3ab1c9..d7ee3740f 100755 --- a/crates/auth/src/policy/mod.rs +++ b/crates/auth/src/policy/mod.rs @@ -12,3 +12,4 @@ pub mod context; pub mod document; pub mod evaluator; pub mod matcher; +pub mod principal_id; diff --git a/crates/auth/src/policy/principal_id.rs b/crates/auth/src/policy/principal_id.rs new file mode 100644 index 000000000..ed1119414 --- /dev/null +++ b/crates/auth/src/policy/principal_id.rs @@ -0,0 +1,181 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Principal unique identifiers for the `aws:userid` condition key. +//! +//! The live service populates `aws:userid` with the principal's unique id: for +//! an IAM user the user's id (an `AIDA...` string), and for a role session the +//! pair `:` where the role id is an `AROA...` string. +//! Measured live on this account: an IAM user resolves to `AIDA` followed by a +//! base32 body, and an assumed-role session to `AROA...:` (for +//! example `AROA6A7B6LB4GWNTMCATH:r3probe`). +//! +//! ExtendDB does not store a unique id for users or roles (the catalog keys a +//! user by `(account_id, user_name)` and a role by `(account_id, role_name)`), +//! and it exposes no surface, such as an STS `GetCallerIdentity`, that returns +//! one. The id therefore has no external value a customer could already have +//! read, so the only contract `aws:userid` must honour is the service's: an +//! `AIDA`/`AROA` string that is stable for a principal, unique across +//! principals, and the same body for a user and for a role session built from +//! that role. This module derives it deterministically from the principal's +//! stable catalog identity, so every existing user and role has one with no +//! stored column and no catalog migration; the derivation is the source of +//! truth. + +use sha2::{Digest, Sha256}; + +/// RFC 4648 base32 alphabet (uppercase, no padding), the character set the +/// service's principal ids use. +const BASE32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/// The body length (characters after the four-character type prefix). The +/// service's ids are 21 characters (for example `AROA` plus a 17-character +/// body), which this matches. +const BODY_LEN: usize = 17; + +/// Base32-encode the first `n` characters of `data`. +fn base32_prefix(data: &[u8], n: usize) -> String { + let mut buffer: u32 = 0; + let mut bits_left: u32 = 0; + let mut out = String::with_capacity(n); + for &byte in data { + buffer = (buffer << 8) | u32::from(byte); + bits_left += 8; + while bits_left >= 5 { + bits_left -= 5; + let idx = ((buffer >> bits_left) & 0x1f) as usize; + out.push(BASE32[idx] as char); + if out.len() == n { + return out; + } + } + buffer &= (1 << bits_left) - 1; + } + out +} + +/// Derive an id body from a domain-separated set of stable parts. +fn id_body(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part.as_bytes()); + // A length-prefixed separator keeps ("ab","c") distinct from ("a","bc"). + hasher.update([0u8]); + } + base32_prefix(&hasher.finalize(), BODY_LEN) +} + +/// The `aws:userid` value for an IAM user: its unique id, an `AIDA...` string. +#[must_use] +pub fn user_unique_id(account_id: &str, user_name: &str) -> String { + format!("AIDA{}", id_body(&["user", account_id, user_name])) +} + +/// A role's unique id, an `AROA...` string. Used as the first half of a role +/// session's `aws:userid`. +#[must_use] +pub fn role_unique_id(account_id: &str, role_name: &str) -> String { + format!("AROA{}", id_body(&["role", account_id, role_name])) +} + +/// The `aws:userid` value for a role session: `:`. +#[must_use] +pub fn role_session_userid(account_id: &str, role_name: &str, session_name: &str) -> String { + format!("{}:{session_name}", role_unique_id(account_id, role_name)) +} + +/// The `aws:userid` value for a caller identified by ARN, for the trust path. +/// +/// An IAM user ARN (`arn:aws:iam:::user/`) resolves to the +/// user's `AIDA...` id; an assumed-role ARN +/// (`arn:aws:sts:::assumed-role//`) to +/// `:`. Any other ARN form returns `None`, so a trust policy +/// conditioning on `aws:userid` for such a caller fails to match rather than +/// resolving to a fabricated value. +#[must_use] +pub fn userid_for_caller_arn(caller_arn: &str) -> Option { + let parts: Vec<&str> = caller_arn.splitn(6, ':').collect(); + if parts.len() < 6 { + return None; + } + let account = parts[4]; + let resource = parts[5]; + if let Some(user_name) = resource.strip_prefix("user/") { + Some(user_unique_id(account, user_name)) + } else if let Some(rest) = resource.strip_prefix("assumed-role/") { + let mut it = rest.splitn(2, '/'); + let role = it.next()?; + let session = it.next()?; + Some(role_session_userid(account, role, session)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_id_has_the_service_prefix_and_length() { + let id = user_unique_id("123456789012", "alice"); + assert!(id.starts_with("AIDA"), "{id}"); + assert_eq!(id.len(), 4 + BODY_LEN); + assert!(id.bytes().all(|b| BASE32.contains(&b) || b"AIDA".contains(&b))); + } + + #[test] + fn role_id_has_the_service_prefix_and_length() { + let id = role_unique_id("123456789012", "app-role"); + assert!(id.starts_with("AROA"), "{id}"); + assert_eq!(id.len(), 4 + BODY_LEN); + } + + #[test] + fn ids_are_stable_and_unique() { + assert_eq!( + user_unique_id("123456789012", "alice"), + user_unique_id("123456789012", "alice") + ); + assert_ne!( + user_unique_id("123456789012", "alice"), + user_unique_id("123456789012", "bob") + ); + assert_ne!( + user_unique_id("123456789012", "alice"), + user_unique_id("999999999999", "alice") + ); + // A user and a role with the same name get different ids (different + // type prefix and domain separation). + assert_ne!( + user_unique_id("123456789012", "shared"), + role_unique_id("123456789012", "shared") + ); + } + + #[test] + fn role_session_userid_is_role_id_colon_session() { + let rid = role_unique_id("123456789012", "app-role"); + assert_eq!( + role_session_userid("123456789012", "app-role", "sess-1"), + format!("{rid}:sess-1") + ); + } + + #[test] + fn userid_for_caller_arn_covers_user_and_role_session() { + assert_eq!( + userid_for_caller_arn("arn:aws:iam::123456789012:user/alice"), + Some(user_unique_id("123456789012", "alice")) + ); + assert_eq!( + userid_for_caller_arn("arn:aws:sts::123456789012:assumed-role/app-role/sess-1"), + Some(role_session_userid("123456789012", "app-role", "sess-1")) + ); + assert_eq!( + userid_for_caller_arn("arn:aws:iam::123456789012:root"), + None + ); + assert_eq!(userid_for_caller_arn("not-an-arn"), None); + } +} From a9390c96fc6e3d83c46c3ae5de4829c3b2d4e290 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 01:18:55 +0000 Subject: [PATCH 19/58] fix(server): thread source IP, user agent, userid and resource account to authorization Supply the condition keys the auth crate now resolves. On the DynamoDB path the handler reads the connection peer address and the User-Agent header and passes them to authorize_request, which sets them on the request parameters for both the single-table and batch/transact branches; the authorization layer sets aws:userid (AIDA for a user, role-id:session for a role session) and aws:ResourceAccount (the resource owner account). On the AssumeRole path the trust evaluator receives the call's peer address and User-Agent and derives aws:userid from the caller ARN and aws:ResourceAccount from the role account. The peer address is used directly; an X-Forwarded-For header is never trusted. --- crates/server/src/authorization.rs | 12 ++ crates/server/src/handler.rs | 23 +++- crates/server/src/management/assume_role.rs | 115 ++++++++++++++++---- crates/server/src/request_helpers.rs | 6 + 4 files changed, 134 insertions(+), 22 deletions(-) diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index 573c3144c..d722c5efe 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -113,6 +113,12 @@ async fn check_user_authorization( params.principal_account = Some(account_id.to_owned()); params.principal_type = Some("User".to_owned()); params.username = Some(user_name.to_owned()); + params.userid = Some(extenddb_auth::policy::principal_id::user_unique_id( + account_id, user_name, + )); + // The target table's owner account. ExtendDB builds the resource ARN with + // the caller's account, so the resource account is that account. + params.resource_account = Some(account_id.to_owned()); let context = RequestContext::build( (*principal_tags).clone(), (*resource_tags).clone(), @@ -173,6 +179,12 @@ async fn check_role_authorization( // A role session's principal type is AssumedRole; the service does not // populate aws:username for it, so username stays None (absent). params.principal_type = Some("AssumedRole".to_owned()); + params.userid = Some(extenddb_auth::policy::principal_id::role_session_userid( + account_id, + role_name, + session_name, + )); + params.resource_account = Some(account_id.to_owned()); let context = RequestContext::build(principal_tags, (*resource_tags).clone(), is_scan, params); // Convert the Arc> to a slice for the evaluator. We materialize diff --git a/crates/server/src/handler.rs b/crates/server/src/handler.rs index 5a5a5f51c..e1eb49215 100755 --- a/crates/server/src/handler.rs +++ b/crates/server/src/handler.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use axum::body::Bytes; -use axum::extract::State; +use axum::extract::{ConnectInfo, State}; use axum::http::HeaderMap; use axum::response::Response; use extenddb_core::error::DynamoDbError; @@ -32,6 +32,7 @@ use crate::throttle_helpers::{ pub(crate) async fn handle_request( State(state): State>, uri: axum::http::Uri, + ConnectInfo(peer_addr): ConnectInfo, mut headers: HeaderMap, body: Result, ) -> Response { @@ -117,7 +118,25 @@ pub(crate) async fn handle_request( &request_id, ); } - match authorize_request(&state, &identity, &input, &operation, &account_id).await { + // aws:SourceIp is the connection peer address; aws:UserAgent is the + // User-Agent header. Behind a proxy only the peer address is trusted, + // never an X-Forwarded-For header. + let source_ip = peer_addr.ip().to_string(); + let user_agent = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(ToOwned::to_owned); + match authorize_request( + &state, + &identity, + &input, + &operation, + &account_id, + Some(source_ip.as_str()), + user_agent.as_deref(), + ) + .await + { Ok(ki) => pre_fetched_key_info = ki, Err(e) => return error_response(&e, &request_id), } diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index c88b9db19..68e0bfaf9 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -152,7 +152,13 @@ pub async fn assume_role( let has_session_tags = request.session_tags.as_ref().is_some_and(value_has_entries); let request_tags = parse_session_tags(request.session_tags.as_ref()); - // Evaluate trust policy against the provided caller ARN. + // Evaluate trust policy against the provided caller ARN. aws:SourceIp and + // aws:UserAgent are the AssumeRole connection's peer address and User-Agent, + // which is what STS records for the call. + let user_agent = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(ToOwned::to_owned); if !evaluate_trust_policy( &trust_policy, caller_arn, @@ -161,6 +167,9 @@ pub async fn assume_role( request_tags, &state.region, state.secure_transport, + Some(source_ip.as_str()), + user_agent.as_deref(), + &account_id, &*state.catalog_store, ) .await @@ -310,6 +319,9 @@ async fn evaluate_trust_policy( request_tags: std::collections::HashMap, region: &str, secure_transport: bool, + source_ip: Option<&str>, + user_agent: Option<&str>, + account_id: &str, store: &dyn extenddb_storage::CatalogStore, ) -> bool { let Some(statements) = statements_vec(trust_policy) else { @@ -328,6 +340,13 @@ async fn evaluate_trust_policy( secure_transport, principal_type: caller_principal_type(caller_arn).map(ToOwned::to_owned), username: caller_username(caller_arn).map(ToOwned::to_owned), + source_ip: source_ip.map(ToOwned::to_owned), + user_agent: user_agent.map(ToOwned::to_owned), + // aws:userid of the caller (an AIDA... id for a user, role-id:session + // for a role session), derived from the caller ARN. + userid: extenddb_auth::policy::principal_id::userid_for_caller_arn(caller_arn), + // aws:ResourceAccount is the account that owns the role being assumed. + resource_account: Some(account_id.to_owned()), current_time, epoch_time, }; @@ -903,6 +922,10 @@ mod tests { secure_transport: true, principal_type: Some("User".to_owned()), username: Some("alice".to_owned()), + source_ip: Some("203.0.113.9".to_owned()), + user_agent: Some("aws-cli/2.0 Botocore/1.0".to_owned()), + userid: Some("AIDAEXAMPLEALICEUSERID".to_owned()), + resource_account: Some(ACCT.to_owned()), current_time: "2026-01-01T00:00:00Z".to_owned(), epoch_time: "1767225600".to_owned(), } @@ -1162,14 +1185,15 @@ mod tests { // ---- fail closed on an unparseable Deny ------------------------------- #[test] fn deny_with_unparseable_condition_fails_closed() { - // An operator ExtendDB cannot evaluate must not silently drop the Deny. + // BinaryEquals is a valid IAM operator the evaluator does not implement, + // so a Deny using it must not be silently dropped. let stmts = vec![ allow_alice(), json!({ "Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", - "Condition": {"NotIpAddress": {"aws:SourceIp": "192.0.2.0/24"}} + "Condition": {"BinaryEquals": {"aws:PrincipalTag/x": "AAAA"}} }), ]; assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); @@ -1181,11 +1205,57 @@ mod tests { "Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", - "Condition": {"NotIpAddress": {"aws:SourceIp": "192.0.2.0/24"}} + "Condition": {"BinaryEquals": {"aws:PrincipalTag/x": "AAAA"}} })]; assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); } + // ---- resolved SourceIp / UserAgent on the trust path ------------------ + #[test] + fn trust_ipaddress_source_ip_matches_caller() { + // The caller address is 203.0.113.9 (mk_ctx). IpAddress on the enclosing + // range grants; a range the caller is not in does not. Matches live STS. + let in_range = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"IpAddress": {"aws:SourceIp": "203.0.113.0/24"}}})]; + assert!(trust_decision(&in_range, CALLER, &ctx(), false)); + let out_of_range = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"IpAddress": {"aws:SourceIp": "198.51.100.0/24"}}})]; + assert!(!trust_decision(&out_of_range, CALLER, &ctx(), false)); + } + + #[test] + fn trust_notipaddress_deny_blocks_caller_outside_range() { + // Deny unless the caller is inside 203.0.113.0/24. The caller is inside, + // so NotIpAddress is false and the Deny does not apply. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"NotIpAddress": {"aws:SourceIp": "203.0.113.0/24"}}}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn trust_useragent_stringlike_grants() { + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringLike": {"aws:UserAgent": "*Botocore*"}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn trust_userid_resolves_from_caller_arn() { + // The caller ARN is an IAM user, so aws:userid is the derived AIDA id. + let ctx = ctx(); + let expected = ctx.userid.clone().expect("userid set in mk_ctx"); + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:userid": expected}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx, false)); + } + // ---- resolved keys: PrincipalArn / PrincipalAccount ------------------- #[test] @@ -1294,26 +1364,29 @@ mod tests { )); } - // ---- case 3: a key STS populates but ExtendDB cannot resolve ---------- + // ---- organization keys: absent for a standalone account (R1) ---------- #[test] - fn deny_on_unresolvable_key_fails_closed() { - // x13: Deny {"AWS":"*"} if StringEquals aws:PrincipalOrgID = org. - // ExtendDB cannot resolve the org id, so the Deny fails closed (blocks). + fn deny_on_absent_org_key_does_not_block() { + // Deny {"AWS":"*"} if StringEquals aws:PrincipalOrgID = org. An ExtendDB + // account is a standalone AWS account, so the organization key is absent; + // StringEquals on an absent single-valued key does not match, so the Deny + // does not apply and the Allow grants. Matches IAM for a standalone + // account. let stmts = vec![ allow_alice(), json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-abc123"}}}), ]; - assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); } #[test] - fn deny_negated_on_unresolvable_key_fails_closed() { - // x12: Deny {"AWS":"*"} if StringNotEquals aws:PrincipalOrgID = org. - // An unresolvable key fails closed regardless of operator, so the Deny - // blocks (this over-denies relative to IAM, the documented safe - // direction) rather than the negated-absent rule spuriously granting. + fn deny_negated_on_absent_org_key_blocks() { + // Deny {"AWS":"*"} if StringNotEquals aws:PrincipalOrgID = org. The key + // is absent for a standalone account, and a negated operator on an absent + // single-valued key matches, so the Deny applies and blocks. Matches IAM + // for a standalone account (the key is genuinely absent, not fail-closed). let stmts = vec![ allow_alice(), json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", @@ -1323,10 +1396,10 @@ mod tests { } #[test] - fn allow_gate_on_unresolvable_key_does_not_grant() { - // x11: Allow {"AWS":"*"} if StringEquals aws:PrincipalOrgID = org. - // The Allow is gated on an unresolvable key, so it does not grant and no - // other statement allows: implicit deny. + fn allow_gate_on_absent_org_key_does_not_grant() { + // Allow {"AWS":"*"} if StringEquals aws:PrincipalOrgID = org. The key is + // absent, so the positive operator does not match and the Allow does not + // grant: implicit deny. Matches IAM for a standalone account. let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-abc123"}}})]; @@ -1456,10 +1529,12 @@ mod tests { #[test] fn validate_accepts_known_iam_operator_extenddb_does_not_evaluate() { - // NotIpAddress is a valid IAM operator; IAM accepts the document. + // BinaryEquals is a valid IAM operator the evaluator does not implement; + // IAM accepts the document, and a Deny using it fails closed at + // evaluation. ok(json!({"Statement": [{ "Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", - "Condition": {"NotIpAddress": {"aws:SourceIp": "192.0.2.0/24"}} + "Condition": {"BinaryEquals": {"aws:PrincipalTag/x": "AAAA"}} }]})); } diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 76eab7f63..583ac89f3 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -61,6 +61,8 @@ pub(crate) async fn authorize_request( input: &Value, operation: &str, account_id: &str, + source_ip: Option<&str>, + user_agent: Option<&str>, ) -> Result, DynamoDbError> { let table_name = extract_table_name(input); @@ -118,6 +120,8 @@ pub(crate) async fn authorize_request( extenddb_auth::policy::context::RequestParams { region: Some(state.region.to_string()), secure_transport: state.tls_enabled, + source_ip: source_ip.map(ToOwned::to_owned), + user_agent: user_agent.map(ToOwned::to_owned), ..Default::default() }, ) @@ -149,6 +153,8 @@ pub(crate) async fn authorize_request( .map(ToOwned::to_owned), region: Some(state.region.to_string()), secure_transport: state.tls_enabled, + source_ip: source_ip.map(ToOwned::to_owned), + user_agent: user_agent.map(ToOwned::to_owned), ..Default::default() }; authorization::check_authorization( From b19f44d46a04f8b4e634f79712e003be1fa4c692 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 01:38:34 +0000 Subject: [PATCH 20/58] test(auth): wire tests for the resolved global keys and absent organization keys Cover the round-three keys on the identity path (test_abac.py) and the trust path (test_auth_integration.py): aws:userid (AIDA for a user), aws:SourceIp with IpAddress and NotIpAddress on the loopback, aws:UserAgent with StringLike, aws:ResourceAccount, and the organization keys absent for a standalone account. Update the earlier organization-key and unknown-key rows to the revised rule: an absent organization key and an unknown aws: key now keep absent-key semantics (a positive operator does not match, a negated operator matches) rather than failing closed, matching live IAM for a standalone account. The unknown-key negated-allow row changes from DENY to ALLOW. --- tests/test_abac.py | 115 ++++++++++++++++++++++++++------- tests/test_auth_integration.py | 100 ++++++++++++++++++++++++---- 2 files changed, 178 insertions(+), 37 deletions(-) diff --git a/tests/test_abac.py b/tests/test_abac.py index 6aea92c93..e7a517ab9 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -438,12 +438,12 @@ def test_session_tag_denies_non_matching_role(self): self.mgmt.delete_role(self.account_id, role) -class TestUnresolvableGlobalKey: - """Identity policies gated on a global key the service populates but - ExtendDB cannot resolve (for example aws:PrincipalOrgID). Such a statement - fails closed: a Deny blocks and an Allow does not grant, whatever the - operator, so a negated operator cannot spuriously grant. A key ExtendDB does - resolve (aws:PrincipalArn) evaluates normally. +class TestOrganizationKeysAbsent: + """Identity policies gated on an organization key (for example + aws:PrincipalOrgID). An ExtendDB account is a standalone AWS account, so + IAM leaves these keys absent and absent-key semantics apply: a positive + operator does not match, and a negated operator on an absent single-valued + key matches. A key ExtendDB resolves (aws:PrincipalArn) evaluates normally. """ @pytest.fixture(autouse=True) @@ -474,7 +474,7 @@ def _admin_table(self) -> tuple[Any, str]: client.put_item(TableName=table, Item={"pk": {"S": "k1"}, "data": {"S": "v1"}}) return client, table - def test_deny_on_unresolvable_orgid_blocks_fail_closed(self): + def test_deny_on_absent_orgid_does_not_block(self): admin_client, table = self._admin_table() user = f"urk-deny-{uuid.uuid4().hex[:8]}" ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) @@ -488,16 +488,17 @@ def test_deny_on_unresolvable_orgid_blocks_fail_closed(self): assert resp.status_code == 204 client = _make_client(self.endpoint, ak, sk, self.region) try: - with pytest.raises(ClientError) as exc: - client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) - assert exc.value.response["Error"]["Code"] == "AccessDeniedException" - # The Deny names only GetItem, so PutItem still succeeds. - client.put_item(TableName=table, Item={"pk": {"S": "k2"}, "data": {"S": "v2"}}) + # An ExtendDB account is a standalone AWS account, so + # aws:PrincipalOrgID is absent; StringEquals on an absent key does + # not match, so the Deny is inert and GetItem is allowed. Matches + # live IAM for a standalone account. + out = client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + assert out["Item"]["data"]["S"] == "v1" finally: admin_client.delete_table(TableName=table) self.mgmt.delete_user(self.account_id, user) - def test_negated_allow_on_unresolvable_orgid_does_not_grant(self): + def test_negated_allow_on_absent_orgid_grants(self): admin_client, table = self._admin_table() user = f"urk-neg-{uuid.uuid4().hex[:8]}" ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) @@ -510,9 +511,11 @@ def test_negated_allow_on_unresolvable_orgid_does_not_grant(self): assert resp.status_code == 204 client = _make_client(self.endpoint, ak, sk, self.region) try: - with pytest.raises(ClientError) as exc: - client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) - assert exc.value.response["Error"]["Code"] == "AccessDeniedException" + # Org key absent for a standalone account; a negated operator on an + # absent single-valued key matches, so the Allow grants. Matches live + # IAM for a standalone account. + out = client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + assert out["Item"]["data"]["S"] == "v1" finally: admin_client.delete_table(TableName=table) self.mgmt.delete_user(self.account_id, user) @@ -671,16 +674,78 @@ def test_mfa_boolifexists_allows_non_mfa_caller(self, table): assert self._get(table, self._allow_get_if( {"BoolIfExists": {"aws:MultiFactorAuthPresent": "false"}})) == "ALLOW" - def test_unknown_key_negated_allow_fails_closed(self, table): - # An unknown aws: key is unresolvable, so the Allow does not grant even - # under a negated operator. ExtendDB denies here while live DynamoDB - # allows (treating the key as absent); this is the deliberate - # fail-closed over-denial recorded in the report's open questions. + def test_unknown_key_negated_allow_grants(self, table): + # An unknown aws: key is absent, as on the service, so the negated + # operator matches an absent single-valued key and the Allow grants. + # Live DynamoDB: ALLOW. (Round two failed closed and denied here; this + # row changes from DENY to ALLOW under the revised rule.) assert self._get(table, self._allow_get_if( - {"StringNotEquals": {"aws:NoSuchGlobalKey": "x"}})) == "DENY" + {"StringNotEquals": {"aws:NoSuchGlobalKey": "x"}})) == "ALLOW" - def test_unknown_key_deny_fails_closed(self, table): - # A Deny gated on an unknown aws: key fails closed and blocks. Live - # DynamoDB also denies here (the negated-absent rule applies the Deny). + def test_unknown_key_deny_negated_blocks(self, table): + # A Deny gated on StringNotEquals of an unknown aws: key: the key is + # absent, the negated operator matches, so the Deny applies and blocks. + # Live DynamoDB also denies here. assert self._get(table, self._allow_all_deny_get_if( {"StringNotEquals": {"aws:NoSuchGlobalKey": "x"}})) == "DENY" + + +class TestRound3GlobalKeys(_TaggedTableBase): + """Round three keys on the identity path: aws:userid, aws:SourceIp, + aws:UserAgent, aws:ResourceAccount, and the organization keys absent for a + standalone account. Every expected outcome was measured live.""" + + def test_userid_present_denies_null_true(self, table): + # aws:userid is populated, so Null true is false and the Allow does not + # grant. + assert self._get(table, self._allow_get_if( + {"Null": {"aws:userid": "true"}})) == "DENY" + + def test_userid_like_aida_grants(self, table): + # A user's aws:userid begins with AIDA. + assert self._get(table, self._allow_get_if( + {"StringLike": {"aws:userid": "AIDA*"}})) == "ALLOW" + + def test_sourceip_loopback_grants(self, table): + # The test client connects over the IPv4 loopback, so aws:SourceIp is + # 127.0.0.1 and IpAddress on the loopback grants. + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:SourceIp": "127.0.0.1/32"}})) == "ALLOW" + + def test_sourceip_other_range_denies(self, table): + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:SourceIp": "10.0.0.0/8"}})) == "DENY" + + def test_sourceip_notipaddress_loopback_does_not_grant(self, table): + # NotIpAddress excludes the caller's own loopback address, so the Allow + # does not grant. + assert self._get(table, self._allow_get_if( + {"NotIpAddress": {"aws:SourceIp": "127.0.0.1/32"}})) == "DENY" + + def test_useragent_stringlike_grants(self, table): + # The boto3 client's User-Agent contains Botocore. + assert self._get(table, self._allow_get_if( + {"StringLike": {"aws:UserAgent": "*Botocore*"}})) == "ALLOW" + + def test_useragent_stringlike_no_match_denies(self, table): + assert self._get(table, self._allow_get_if( + {"StringLike": {"aws:UserAgent": "*ZZZNoSuchAgentZZZ*"}})) == "DENY" + + def test_resourceaccount_eq_grants(self, table): + # aws:ResourceAccount is the table owner account. + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:ResourceAccount": self.account_id}})) == "ALLOW" + + def test_resourceaccount_present_denies_null_true(self, table): + assert self._get(table, self._allow_get_if( + {"Null": {"aws:ResourceAccount": "true"}})) == "DENY" + + def test_principalorgid_absent_null_true_grants(self, table): + # Standalone account: the organization key is absent, so Null true + # grants. + assert self._get(table, self._allow_get_if( + {"Null": {"aws:PrincipalOrgID": "true"}})) == "ALLOW" + + def test_principalorgid_negated_allow_grants(self, table): + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:PrincipalOrgID": "o-example1"}})) == "ALLOW" diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 4ff72c34a..13c7de801 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1031,9 +1031,11 @@ def test_deny_tagkeys_foranyvalue_blocks(self): # -- Case 3: a key STS populates but ExtendDB cannot resolve (fail closed) - def test_deny_on_unresolvable_org_key_fails_closed(self): - # A Deny gated on aws:PrincipalOrgID, which ExtendDB cannot resolve, - # fails closed and blocks. This matches IAM when the key would match. + def test_deny_on_absent_org_key_does_not_block(self): + # An ExtendDB account is a standalone AWS account, so aws:PrincipalOrgID + # is absent; StringEquals on an absent key does not match, so the Deny is + # inert and assumption is allowed. Matches live IAM for a standalone + # account. user, arn = self._caller() try: trust = _trust( @@ -1041,14 +1043,14 @@ def test_deny_on_unresolvable_org_key_fails_closed(self): _stmt("Deny", {"AWS": "*"}, condition={"StringEquals": {"aws:PrincipalOrgID": "o-example1"}}), ) - assert self._assume(trust, arn) == DENY + assert self._assume(trust, arn) == ALLOW finally: self.mgmt.delete_user(self.account_id, user) - def test_allow_gate_on_unresolvable_org_key_does_not_grant(self): - # An Allow gated solely on aws:PrincipalOrgID does not grant, because the - # key is unresolvable. ExtendDB denies where IAM would allow; this is the - # documented fail-closed divergence for keys ExtendDB does not evaluate. + def test_allow_gate_on_absent_org_key_does_not_grant(self): + # An Allow gated solely on aws:PrincipalOrgID does not grant: the key is + # absent for a standalone account, so the positive operator does not + # match. Matches live IAM for a standalone account. user, arn = self._caller() try: trust = _trust(_stmt("Allow", {"AWS": "*"}, @@ -1057,10 +1059,10 @@ def test_allow_gate_on_unresolvable_org_key_does_not_grant(self): finally: self.mgmt.delete_user(self.account_id, user) - def test_deny_negated_on_unresolvable_org_key_fails_closed(self): - # StringNotEquals on an unresolvable key must not spuriously grant via - # the negated-absent rule: the Deny fails closed and blocks (ExtendDB - # over-denies here relative to IAM, the documented safe direction). + def test_deny_negated_on_absent_org_key_blocks(self): + # StringNotEquals on an absent organization key matches (the negated + # operator on an absent single-valued key), so the Deny applies and + # blocks. Matches live IAM for a standalone account. user, arn = self._caller() try: trust = _trust( @@ -1072,6 +1074,80 @@ def test_deny_negated_on_unresolvable_org_key_fails_closed(self): finally: self.mgmt.delete_user(self.account_id, user) + # -- Round three keys on the trust path (measured live) -- + + def test_sourceip_loopback_grants(self): + # The AssumeRole call arrives over the IPv4 loopback, so aws:SourceIp is + # 127.0.0.1 and IpAddress on the loopback grants. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"IpAddress": {"aws:SourceIp": "127.0.0.1/32"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_sourceip_other_range_does_not_grant(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"IpAddress": {"aws:SourceIp": "10.0.0.0/8"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_notipaddress_deny_blocks_caller_outside_range(self): + # Deny unless the caller is inside 10.0.0.0/8. The loopback caller is + # outside it, so NotIpAddress matches and the Deny blocks. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"NotIpAddress": {"aws:SourceIp": "10.0.0.0/8"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_useragent_stringlike_grants(self): + # The management client's User-Agent is python-requests. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringLike": {"aws:UserAgent": "*requests*"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_useragent_stringlike_no_match_does_not_grant(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringLike": {"aws:UserAgent": "*ZZZNoSuchAgentZZZ*"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_userid_like_aida_grants(self): + # The caller is an IAM user, so aws:userid begins with AIDA. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringLike": {"aws:userid": "AIDA*"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_userid_present_null_true_does_not_grant(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"Null": {"aws:userid": "true"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + # -- Tag key is resolved case-insensitively (stored `Team`, policy `team`) -- def test_tag_key_case_insensitive_positive_deny_blocks(self): From 245aa98c10f94b3410c4052a4a1d304a432100bd Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 01:40:34 +0000 Subject: [PATCH 21/58] docs: describe the final global-key resolution rule and the classified key list Update ADR-0022 and the admin guide for the revised rule: ExtendDB resolves every global key the service populates for its callers (adding aws:userid, aws:SourceIp, aws:UserAgent and aws:ResourceAccount), treats every other aws: global key as absent (the organization keys for a standalone account, the VPC, federation, MFA and service keys, and any unknown key), and keeps an empty fail-closed list. The admin guide gains the full classified key list: resolved, absent, and fail-closed (none). IpAddress and NotIpAddress are now evaluated as CIDR membership. differences-from-dynamodb.md is unchanged. --- docs/adr/0022-trust-policy-deny-over-allow.md | 58 +++++---- docs/manuals/05-admin-guide.md | 113 +++++++++++++----- 2 files changed, 119 insertions(+), 52 deletions(-) diff --git a/docs/adr/0022-trust-policy-deny-over-allow.md b/docs/adr/0022-trust-policy-deny-over-allow.md index 20e080087..07c2e93e1 100644 --- a/docs/adr/0022-trust-policy-deny-over-allow.md +++ b/docs/adr/0022-trust-policy-deny-over-allow.md @@ -97,31 +97,43 @@ implicitly denied. bare `"*"` principal, a missing `Principal`, and so on) starts getting a 400 at role creation. Existing stored roles are never re-validated, so no stored role is locked out at evaluation. -- **Condition-key resolution (three cases).** A condition key falls into one of - three cases, in both trust-policy and identity-policy evaluation. (1) The - context resolves the key to a value: evaluate normally. (2) The context - resolves it as absent the same way the service would: IAM absent-key - semantics apply, including that a negated operator on an absent single-valued - key matches. (3) An `aws:` global key the service may populate but the context +- **Condition-key resolution.** A condition key falls into one of three cases, + in both trust-policy and identity-policy evaluation. (1) The context resolves + the key to a value: evaluate normally. (2) The context resolves it as absent + the same way the service would: IAM absent-key semantics apply, including that + a negated operator on an absent single-valued key matches. (3) A documented + global key the service populates for one of ExtendDB's callers but the context cannot resolve: the statement fails closed whatever the operator, so a `Deny` blocks and an `Allow` does not grant, rather than the key being treated as - absent (which would let a negated operator spuriously match). Both contexts - resolve every global key ExtendDB has the data for: `aws:SecureTransport` - (from the listener TLS state), `aws:RequestedRegion` (the server region), - `aws:CurrentTime` and `aws:EpochTime` (the request time), `aws:PrincipalType`, - `aws:username` (for a user caller), `aws:ViaAWSService`, - `aws:PrincipalIsAWSService`, `aws:PrincipalArn`, `aws:PrincipalAccount`, the - tag keys (`aws:PrincipalTag/*`, `aws:RequestTag/*`, `aws:ResourceTag/*` on the - identity path, `dynamodb:ResourceTag/*`, each matched case-insensitively on - the key), `sts:ExternalId` and `aws:TagKeys`. Keys that are legitimately - absent for ExtendDB's callers keep absent-key semantics; these are an - allowlist that includes `aws:MultiFactorAuthPresent`, `aws:TokenIssueTime`, - the VPC, federation and source-service keys, and `aws:username` on a role - session. Any other unresolved `aws:` global key (for example - `aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, `aws:userid`, `aws:SourceIp`, - `aws:UserAgent`) is case 3 and fails closed; the allowlist, rather than a - denylist of keys the service populates, is what the evaluator maintains, so a - key AWS adds later fails closed rather than silently granting. + absent (which would let a negated operator spuriously match). ExtendDB resolves + every global key the service populates for a signed IAM-user or assumed-role + request, or an `AssumeRole` call, so case 3 is empty: the resolved set is + `aws:SecureTransport` (listener TLS state), `aws:RequestedRegion` (server + region), `aws:CurrentTime` and `aws:EpochTime` (request time), + `aws:PrincipalType`, `aws:username` (user caller), `aws:userid` + (`AIDA...` for a user, `:` for a role session), + `aws:PrincipalArn`, `aws:PrincipalAccount`, `aws:ResourceAccount`, + `aws:SourceIp` (connection peer address, evaluated with `IpAddress` and + `NotIpAddress` as IPv4 and IPv6 CIDR membership), `aws:UserAgent` (the + `User-Agent` header), `aws:ViaAWSService` and `aws:PrincipalIsAWSService` + (both false for a direct caller), the tag keys (`aws:PrincipalTag/*`, + `aws:RequestTag/*`, `aws:ResourceTag/*` on the identity path, + `dynamodb:ResourceTag/*`, each matched case-insensitively on the key), + `sts:ExternalId` and `aws:TagKeys`. 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 a key it does not recognize: the organization keys + (`aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, and the resource and source + organization keys), because an ExtendDB account is a standalone AWS account + where IAM leaves them unset; the VPC-endpoint, federation, MFA, + service-principal and cross-service keys, which the service does not populate + for a direct IAM-user or assumed-role caller over a public endpoint; and any + unknown or future `aws:` key. The evaluator keeps an explicit (currently + empty) fail-closed list, so a future global key the service begins populating + before ExtendDB resolves it can be made to fail closed by listing it, rather + than silently granting through a negated operator. `aws:SourceIp` and + `aws:UserAgent` come from the connection and the `User-Agent` header; behind a + proxy the peer address is used and `X-Forwarded-For` is never trusted. The + full classified key list is in the admin guide. - **Follow-up:** the decision logic was factored into a store-free `trust_decision` helper (with `action_permitted`, `deny_statement_blocks`, `allow_statement_grants` and `effect_of`) so the diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index a7a88efa6..09a729659 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -350,25 +350,25 @@ in-account caller on the strength of the trust policy by itself. What a trust-policy `Deny` does not do: -- A statement whose `Condition` uses an `aws:` global key ExtendDB cannot - resolve, or an operator it does not implement, fails closed: a `Deny` blocks - assumption and an `Allow` grants nothing, rather than the key being treated as - absent. ExtendDB resolves these trust-policy keys: `aws:PrincipalTag/` - (tag key matched case-insensitively), `sts:ExternalId`, `aws:PrincipalArn`, - `aws:PrincipalAccount`, `aws:RequestTag/`, `aws:TagKeys` (from the passed - session tags), `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, - `aws:EpochTime`, `aws:PrincipalType`, `aws:username` (for a user caller), - `aws:ViaAWSService` and `aws:PrincipalIsAWSService`. Keys that are absent for - ExtendDB's callers (for example `aws:MultiFactorAuthPresent`, the VPC, - federation and source-service keys, and `aws:username` for a role session) - keep absent-key semantics. An `aws:` global key ExtendDB does not resolve (for - example `aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, `aws:userid`, - `aws:SourceIp`, `aws:UserAgent`) causes a statement using it to fail closed. - The operators `IpAddress`, `NotIpAddress` and `BinaryEquals` are accepted at - role creation (IAM accepts them) but are not evaluated, so a statement using - one also fails closed. Write a conditional trust statement only on the - resolved keys and implemented operators unless you intend an unconditional - block. +- A statement whose `Condition` uses an operator ExtendDB does not implement + fails closed: a `Deny` blocks assumption and an `Allow` grants nothing, rather + than the operator being ignored. ExtendDB resolves every global key STS + populates for an `AssumeRole` call, so no documented key fails closed: + `aws:PrincipalTag/` (tag key matched case-insensitively), `sts:ExternalId`, + `aws:PrincipalArn`, `aws:PrincipalAccount`, `aws:ResourceAccount`, + `aws:RequestTag/`, `aws:TagKeys` (from the passed session tags), + `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, `aws:EpochTime`, + `aws:PrincipalType`, `aws:username` (for a user caller), `aws:userid` + (`AIDA...` for a user, `:` for a role session), + `aws:SourceIp` (the `AssumeRole` connection peer address), `aws:UserAgent` (the + `User-Agent` header), `aws:ViaAWSService` and `aws:PrincipalIsAWSService`. The + organization keys (`aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`) and every + other `aws:` global key the service does not populate for a direct caller (the + VPC, federation, MFA and source-service keys) are absent, as on a standalone + AWS account, and keep absent-key semantics. `IpAddress` and `NotIpAddress` are + evaluated as IPv4 and IPv6 CIDR membership; `BinaryEquals` is accepted at role + creation (IAM accepts it) but is not evaluated, so a statement using it fails + closed. See the classified key list below. - `NotPrincipal` is not allowed in a trust policy and is rejected when the role is created. - A `Resource` field is not allowed in a trust policy and is rejected at create. @@ -429,20 +429,75 @@ Inline policies can be attached to users, groups, and roles: #### Condition keys in identity policies -Identity-policy conditions on DynamoDB requests follow the same three-case rule -as trust policies. A condition key the request resolves is evaluated normally. A -key that is genuinely absent follows IAM absent-key semantics, including that a -negated operator (`StringNotEquals`, `StringNotLike`, `ArnNotEquals`) on an +Identity-policy conditions on DynamoDB requests follow the same rule as trust +policies. A condition key the request resolves is evaluated normally. A key that +is absent follows IAM absent-key semantics, including that a negated operator +(`StringNotEquals`, `StringNotLike`, `ArnNotEquals`, `NotIpAddress`) on an absent single-valued key matches, so a `Deny` gated on one applies when the -caller omits the key. An `aws:` global key ExtendDB cannot resolve (for example -`aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, `aws:userid`, `aws:SourceIp`, -`aws:UserAgent`) fails closed: a `Deny` on it blocks and an `Allow` on it does -not grant. ExtendDB resolves `aws:PrincipalArn`, `aws:PrincipalAccount`, -`aws:PrincipalType`, `aws:username` (for a user caller), `aws:SecureTransport`, +caller omits the key. ExtendDB resolves `aws:PrincipalArn`, `aws:PrincipalAccount`, +`aws:ResourceAccount`, `aws:PrincipalType`, `aws:username` (for a user caller), +`aws:userid`, `aws:SourceIp`, `aws:UserAgent`, `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, `aws:EpochTime`, `aws:ViaAWSService`, `aws:PrincipalIsAWSService`, `aws:PrincipalTag/`, `aws:ResourceTag/` and `dynamodb:ResourceTag/` (tag keys matched case-insensitively) on a -data-plane request. +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. + +#### Global condition key resolution + +The complete list of AWS global condition keys, classified by how ExtendDB +resolves each for its callers (a signed IAM-user or assumed-role DynamoDB +request, or an `AssumeRole` call). ExtendDB behaves as a standalone AWS account +reached directly over its endpoint. + +Resolved (evaluated against the request value): + +- `aws:PrincipalArn`, `aws:PrincipalAccount`, `aws:PrincipalType`, + `aws:username` (user caller only), `aws:userid`. +- `aws:ResourceAccount`. +- `aws:SourceIp` (connection peer address; `IpAddress` and `NotIpAddress` do + IPv4 and IPv6 CIDR membership). +- `aws:UserAgent` (`User-Agent` header). +- `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, + `aws:EpochTime`. +- `aws:ViaAWSService` and `aws:PrincipalIsAWSService` (both false for a direct + caller). +- `aws:PrincipalTag/`, `aws:RequestTag/` (trust path), + `aws:ResourceTag/`, `dynamodb:ResourceTag/`, `aws:TagKeys`, + `sts:ExternalId` (trust path). + +Absent (keep IAM absent-key semantics, as the service leaves them unset for +this caller): + +- Organization keys: `aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, + `aws:ResourceOrgID`, `aws:ResourceOrgPaths`, `aws:SourceOrgID`, + `aws:SourceOrgPaths` (a standalone account has no organization). +- Role-session and federation keys ExtendDB does not model: `aws:TokenIssueTime`, + `aws:MultiFactorAuthPresent`, `aws:MultiFactorAuthAge`, `aws:FederatedProvider`, + `aws:SourceIdentity`, `aws:AssumedRoot`, `aws:SignInSessionArn`. +- Network keys for VPC endpoints: `aws:SourceVpc`, `aws:SourceVpcArn`, + `aws:SourceVpce`, `aws:VpcSourceIp`, `aws:VpceAccount`, `aws:VpceOrgID`, + `aws:VpceOrgPaths`, `aws:Ec2InstanceSourceVpc`, + `aws:Ec2InstanceSourcePrivateIPv4`. +- Service-principal and cross-service keys: `aws:PrincipalServiceName`, + `aws:PrincipalServiceNamesList`, `aws:SourceAccount`, `aws:SourceArn`, + `aws:CalledVia`, `aws:CalledViaFirst`, `aws:CalledViaLast`, `aws:referer`, + and the service-prefixed keys (`ec2:`, `glue:`, `codebuild:`, `lambda:`, + `ssm:`, `identitystore:`). +- Any `aws:` key AWS defines that is not in the resolved set, and any unknown + `aws:` key, which the service also treats as absent. + +Fail closed (a statement using one does not evaluate): none. The resolved and +absent sets cover every documented global key for ExtendDB's callers. The +evaluator keeps an explicit, currently empty, fail-closed list so a future +global key the service begins populating before ExtendDB resolves it can be +added rather than silently granting through a negated operator. An operator the +evaluator does not implement (for example `BinaryEquals`) still fails closed. + +`aws:TokenIssueTime` is populated by the service for a role session and absent +for an IAM user. ExtendDB treats it as absent for both, which can only +over-deny a role-session policy that gates on its presence, never over-grant. ### Permissions Boundaries From 4a3ea0d654713dba58f38b36d325033ef7afe1e8 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 01:41:23 +0000 Subject: [PATCH 22/58] chore(auth): use split_once and apply rustfmt in the principal id helper --- crates/auth/src/policy/principal_id.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/auth/src/policy/principal_id.rs b/crates/auth/src/policy/principal_id.rs index ed1119414..1c61d6740 100644 --- a/crates/auth/src/policy/principal_id.rs +++ b/crates/auth/src/policy/principal_id.rs @@ -103,9 +103,7 @@ pub fn userid_for_caller_arn(caller_arn: &str) -> Option { if let Some(user_name) = resource.strip_prefix("user/") { Some(user_unique_id(account, user_name)) } else if let Some(rest) = resource.strip_prefix("assumed-role/") { - let mut it = rest.splitn(2, '/'); - let role = it.next()?; - let session = it.next()?; + let (role, session) = rest.split_once('/')?; Some(role_session_userid(account, role, session)) } else { None @@ -121,7 +119,10 @@ mod tests { let id = user_unique_id("123456789012", "alice"); assert!(id.starts_with("AIDA"), "{id}"); assert_eq!(id.len(), 4 + BODY_LEN); - assert!(id.bytes().all(|b| BASE32.contains(&b) || b"AIDA".contains(&b))); + assert!( + id.bytes() + .all(|b| BASE32.contains(&b) || b"AIDA".contains(&b)) + ); } #[test] From ea4d9ccfa659549948470ffe562ff0234d26321d Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 02:54:37 +0000 Subject: [PATCH 23/58] fix(auth): give a recreated principal a new aws:userid A user or role deleted and recreated with the same name kept the same aws:userid, so an identity or trust policy pinned to the old id still granted the new principal. IAM issues a new unique id for each incarnation. Derive the id from the principal's creation time in addition to its account and name, so each incarnation gets a distinct id. The creation time comes from the existing iam_users and iam_roles created_at columns, so there is no schema change; it is cached per principal and the cache entry is dropped when the principal is deleted. Every place that resolves aws:userid uses the same derivation: the identity path for a user and for a role session, and the trust path for the caller. --- crates/auth/src/cache_registry.rs | 24 ++++ crates/auth/src/policy/principal_id.rs | 102 +++++++++----- crates/server/src/authorization.rs | 21 ++- crates/server/src/authz_cache.rs | 129 ++++++++++++++++++ crates/server/src/management/assume_role.rs | 40 +++++- .../server/src/management/cache_invalidate.rs | 8 ++ crates/server/src/management/iam_role.rs | 4 + crates/server/src/management/iam_user.rs | 4 + .../src/authorization_store.rs | 58 ++++++++ .../src/authorization_store.rs | 59 ++++++++ .../src/authorization_store.rs | 50 +++++++ .../storage-sqlite/src/authorization_store.rs | 48 +++++++ crates/storage/src/authorization_store.rs | 21 +++ 13 files changed, 531 insertions(+), 37 deletions(-) diff --git a/crates/auth/src/cache_registry.rs b/crates/auth/src/cache_registry.rs index 16268df28..5dd5c21b6 100644 --- a/crates/auth/src/cache_registry.rs +++ b/crates/auth/src/cache_registry.rs @@ -72,6 +72,12 @@ pub trait AuthzCacheInvalidator: Send + Sync { user_name: &'a str, ) -> BoxFuture<'a, ()>; + fn invalidate_user_created_at<'a>( + &'a self, + account_id: &'a str, + user_name: &'a str, + ) -> BoxFuture<'a, ()>; + fn invalidate_role_policies<'a>( &'a self, account_id: &'a str, @@ -90,6 +96,12 @@ pub trait AuthzCacheInvalidator: Send + Sync { role_name: &'a str, ) -> BoxFuture<'a, ()>; + fn invalidate_role_created_at<'a>( + &'a self, + account_id: &'a str, + role_name: &'a str, + ) -> BoxFuture<'a, ()>; + fn invalidate_session<'a>( &'a self, account_id: &'a str, @@ -228,6 +240,12 @@ impl AuthCacheRegistry { } } + pub async fn invalidate_user_created_at(&self, account_id: &str, user_name: &str) { + if let Some(c) = &self.authz { + c.invalidate_user_created_at(account_id, user_name).await; + } + } + pub async fn invalidate_role_policies(&self, account_id: &str, role_name: &str) { if let Some(c) = &self.authz { c.invalidate_role_policies(account_id, role_name).await; @@ -246,6 +264,12 @@ impl AuthCacheRegistry { } } + pub async fn invalidate_role_created_at(&self, account_id: &str, role_name: &str) { + if let Some(c) = &self.authz { + c.invalidate_role_created_at(account_id, role_name).await; + } + } + pub async fn invalidate_session(&self, account_id: &str, role_name: &str, session_name: &str) { if let Some(c) = &self.authz { c.invalidate_session(account_id, role_name, session_name) diff --git a/crates/auth/src/policy/principal_id.rs b/crates/auth/src/policy/principal_id.rs index 1c61d6740..cd7d36f8d 100644 --- a/crates/auth/src/policy/principal_id.rs +++ b/crates/auth/src/policy/principal_id.rs @@ -66,22 +66,38 @@ fn id_body(parts: &[&str]) -> String { } /// The `aws:userid` value for an IAM user: its unique id, an `AIDA...` string. +/// +/// `created_at` is the user's creation time (any stable representation, in +/// practice RFC 3339). Including it means a user deleted and recreated with the +/// same name gets a different id, as IAM issues a new unique id each time. #[must_use] -pub fn user_unique_id(account_id: &str, user_name: &str) -> String { - format!("AIDA{}", id_body(&["user", account_id, user_name])) +pub fn user_unique_id(account_id: &str, user_name: &str, created_at: &str) -> String { + format!("AIDA{}", id_body(&["user", account_id, user_name, created_at])) } /// A role's unique id, an `AROA...` string. Used as the first half of a role -/// session's `aws:userid`. +/// session's `aws:userid`. `created_at` is the role's creation time, so a +/// recreated role gets a new id. #[must_use] -pub fn role_unique_id(account_id: &str, role_name: &str) -> String { - format!("AROA{}", id_body(&["role", account_id, role_name])) +pub fn role_unique_id(account_id: &str, role_name: &str, created_at: &str) -> String { + format!("AROA{}", id_body(&["role", account_id, role_name, created_at])) } /// The `aws:userid` value for a role session: `:`. +/// +/// `role_created_at` is the role's creation time (not the session's): the role +/// id half changes when the role is recreated, matching IAM. #[must_use] -pub fn role_session_userid(account_id: &str, role_name: &str, session_name: &str) -> String { - format!("{}:{session_name}", role_unique_id(account_id, role_name)) +pub fn role_session_userid( + account_id: &str, + role_name: &str, + session_name: &str, + role_created_at: &str, +) -> String { + format!( + "{}:{session_name}", + role_unique_id(account_id, role_name, role_created_at) + ) } /// The `aws:userid` value for a caller identified by ARN, for the trust path. @@ -89,22 +105,26 @@ pub fn role_session_userid(account_id: &str, role_name: &str, session_name: &str /// An IAM user ARN (`arn:aws:iam:::user/`) resolves to the /// user's `AIDA...` id; an assumed-role ARN /// (`arn:aws:sts:::assumed-role//`) to -/// `:`. Any other ARN form returns `None`, so a trust policy -/// conditioning on `aws:userid` for such a caller fails to match rather than -/// resolving to a fabricated value. +/// `:`. `created_at` is the caller principal's creation time +/// (the user's for a user ARN, the role's for an assumed-role ARN), looked up +/// by the caller; when the caller is not a known local principal it is `None` +/// and the id is derived without it. Any other ARN form returns `None`, so a +/// trust policy conditioning on `aws:userid` for such a caller fails to match +/// rather than resolving to a fabricated value. #[must_use] -pub fn userid_for_caller_arn(caller_arn: &str) -> Option { +pub fn userid_for_caller_arn(caller_arn: &str, created_at: Option<&str>) -> Option { let parts: Vec<&str> = caller_arn.splitn(6, ':').collect(); if parts.len() < 6 { return None; } let account = parts[4]; let resource = parts[5]; + let created_at = created_at.unwrap_or(""); if let Some(user_name) = resource.strip_prefix("user/") { - Some(user_unique_id(account, user_name)) + Some(user_unique_id(account, user_name, created_at)) } else if let Some(rest) = resource.strip_prefix("assumed-role/") { let (role, session) = rest.split_once('/')?; - Some(role_session_userid(account, role, session)) + Some(role_session_userid(account, role, session, created_at)) } else { None } @@ -114,9 +134,12 @@ pub fn userid_for_caller_arn(caller_arn: &str) -> Option { mod tests { use super::*; + const T1: &str = "2026-10-21T00:00:00Z"; + const T2: &str = "2026-10-21T01:00:00Z"; + #[test] fn user_id_has_the_service_prefix_and_length() { - let id = user_unique_id("123456789012", "alice"); + let id = user_unique_id("123456789012", "alice", T1); assert!(id.starts_with("AIDA"), "{id}"); assert_eq!(id.len(), 4 + BODY_LEN); assert!( @@ -127,7 +150,7 @@ mod tests { #[test] fn role_id_has_the_service_prefix_and_length() { - let id = role_unique_id("123456789012", "app-role"); + let id = role_unique_id("123456789012", "app-role", T1); assert!(id.starts_with("AROA"), "{id}"); assert_eq!(id.len(), 4 + BODY_LEN); } @@ -135,30 +158,44 @@ mod tests { #[test] fn ids_are_stable_and_unique() { assert_eq!( - user_unique_id("123456789012", "alice"), - user_unique_id("123456789012", "alice") + user_unique_id("123456789012", "alice", T1), + user_unique_id("123456789012", "alice", T1) ); assert_ne!( - user_unique_id("123456789012", "alice"), - user_unique_id("123456789012", "bob") + user_unique_id("123456789012", "alice", T1), + user_unique_id("123456789012", "bob", T1) ); assert_ne!( - user_unique_id("123456789012", "alice"), - user_unique_id("999999999999", "alice") + user_unique_id("123456789012", "alice", T1), + user_unique_id("999999999999", "alice", T1) ); // A user and a role with the same name get different ids (different // type prefix and domain separation). assert_ne!( - user_unique_id("123456789012", "shared"), - role_unique_id("123456789012", "shared") + user_unique_id("123456789012", "shared", T1), + role_unique_id("123456789012", "shared", T1) + ); + } + + #[test] + fn recreating_a_principal_changes_its_id() { + // Same account and name, different creation time: a new id, as IAM + // issues on delete and recreate. + assert_ne!( + user_unique_id("123456789012", "alice", T1), + user_unique_id("123456789012", "alice", T2) + ); + assert_ne!( + role_unique_id("123456789012", "app-role", T1), + role_unique_id("123456789012", "app-role", T2) ); } #[test] fn role_session_userid_is_role_id_colon_session() { - let rid = role_unique_id("123456789012", "app-role"); + let rid = role_unique_id("123456789012", "app-role", T1); assert_eq!( - role_session_userid("123456789012", "app-role", "sess-1"), + role_session_userid("123456789012", "app-role", "sess-1", T1), format!("{rid}:sess-1") ); } @@ -166,17 +203,20 @@ mod tests { #[test] fn userid_for_caller_arn_covers_user_and_role_session() { assert_eq!( - userid_for_caller_arn("arn:aws:iam::123456789012:user/alice"), - Some(user_unique_id("123456789012", "alice")) + userid_for_caller_arn("arn:aws:iam::123456789012:user/alice", Some(T1)), + Some(user_unique_id("123456789012", "alice", T1)) ); assert_eq!( - userid_for_caller_arn("arn:aws:sts::123456789012:assumed-role/app-role/sess-1"), - Some(role_session_userid("123456789012", "app-role", "sess-1")) + userid_for_caller_arn( + "arn:aws:sts::123456789012:assumed-role/app-role/sess-1", + Some(T1) + ), + Some(role_session_userid("123456789012", "app-role", "sess-1", T1)) ); assert_eq!( - userid_for_caller_arn("arn:aws:iam::123456789012:root"), + userid_for_caller_arn("arn:aws:iam::123456789012:root", Some(T1)), None ); - assert_eq!(userid_for_caller_arn("not-an-arn"), None); + assert_eq!(userid_for_caller_arn("not-an-arn", None), None); } } diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index d722c5efe..9dde91d2f 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -85,14 +85,15 @@ async fn check_user_authorization( ) -> Result<(), DynamoDbError> { let action = format!("dynamodb:{operation}"); - // Fetch all 5 authz inputs concurrently — they are independent cache + // Fetch all authz inputs concurrently; they are independent cache // lookups (or DB queries, on cold start). All come back pre-parsed. - let (user_policies, group_policies, boundary, principal_tags, resource_tags) = tokio::try_join!( + let (user_policies, group_policies, boundary, principal_tags, resource_tags, created_at) = tokio::try_join!( wrap_policies(cache.fetch_user_policies(account_id, user_name)), wrap_policies(cache.fetch_user_group_policies(account_id, user_name)), wrap_boundary(cache.fetch_user_boundary(account_id, user_name)), wrap_tags(cache.fetch_user_tags(account_id, user_name)), wrap_tags(cache.fetch_resource_tags(resource_arn)), + wrap_created_at(cache.fetch_user_created_at(account_id, user_name)), )?; // Combine identity policies. Each side is an `Arc>>`; @@ -114,7 +115,9 @@ async fn check_user_authorization( params.principal_type = Some("User".to_owned()); params.username = Some(user_name.to_owned()); params.userid = Some(extenddb_auth::policy::principal_id::user_unique_id( - account_id, user_name, + account_id, + user_name, + created_at.as_deref().map(String::as_str).unwrap_or_default(), )); // The target table's owner account. ExtendDB builds the resource ARN with // the caller's account, so the resource account is that account. @@ -164,11 +167,12 @@ async fn check_role_authorization( ) -> Result<(), DynamoDbError> { let action = format!("dynamodb:{operation}"); - let (identity_policies, boundary, (session_policy, principal_tags), resource_tags) = tokio::try_join!( + let (identity_policies, boundary, (session_policy, principal_tags), resource_tags, role_created_at) = tokio::try_join!( wrap_policies(cache.fetch_role_policies(account_id, role_name)), wrap_boundary(cache.fetch_role_boundary(account_id, role_name)), fetch_session_data_and_tags(cache, account_id, role_name, session_name), wrap_tags(cache.fetch_resource_tags(resource_arn)), + wrap_created_at(cache.fetch_role_created_at(account_id, role_name)), )?; let mut params = params; @@ -183,6 +187,7 @@ async fn check_role_authorization( account_id, role_name, session_name, + role_created_at.as_deref().map(String::as_str).unwrap_or_default(), )); params.resource_account = Some(account_id.to_owned()); let context = RequestContext::build(principal_tags, (*resource_tags).clone(), is_scan, params); @@ -265,6 +270,14 @@ async fn wrap_tags( fut.await.map_err(op_err_to_dynamo) } +async fn wrap_created_at( + fut: impl std::future::Future< + Output = extenddb_storage::management_store::OpResult>>, + >, +) -> Result>, DynamoDbError> { + fut.await.map_err(op_err_to_dynamo) +} + async fn fetch_session_data_and_tags( cache: &CachedAuthzStore, account_id: &str, diff --git a/crates/server/src/authz_cache.rs b/crates/server/src/authz_cache.rs index 7997735a9..de355f064 100644 --- a/crates/server/src/authz_cache.rs +++ b/crates/server/src/authz_cache.rs @@ -111,6 +111,11 @@ pub struct CachedAuthzStore { role_tags: SwrCache<(String, String), TagMap, OpError>, session_data: SwrCache<(String, String, String), Arc, OpError>, resource_tags: SwrCache, + /// Principal creation time (RFC 3339), part of the `aws:userid` derivation. + /// Invalidated with the principal's other entries so a delete and recreate + /// resolves a fresh id. + user_created_at: SwrCache<(String, String), Arc, OpError>, + role_created_at: SwrCache<(String, String), Arc, OpError>, } impl CachedAuthzStore { @@ -153,6 +158,9 @@ impl CachedAuthzStore { extenddb_cache::SwrCache::new(loader, cache_cfg) } } + // The principal creation-time caches reuse the per-principal tag cache + // sizing; capture it before the field initializers consume it. + let created_at_cfg = cfg.principal_tags.clone(); Self { user_policies: mk_cache( make_user_policies_loader(inner.clone()), @@ -194,6 +202,22 @@ impl CachedAuthzStore { cfg.session_data, pass_through, ), + user_created_at: mk_cache( + make_user_created_at_loader(inner.clone()), + extenddb_cache::SwrCacheConfig { + name: "user_created_at", + ..created_at_cfg.clone() + }, + pass_through, + ), + role_created_at: mk_cache( + make_role_created_at_loader(inner.clone()), + extenddb_cache::SwrCacheConfig { + name: "role_created_at", + ..created_at_cfg + }, + pass_through, + ), resource_tags: mk_cache( make_resource_tags_loader(inner), cfg.resource_tags, @@ -315,6 +339,30 @@ impl CachedAuthzStore { .unwrap_or_else(|| Arc::new(HashMap::new()))) } + /// Fetch a user's creation time (RFC 3339), part of its `aws:userid`. + /// `None` if the user does not exist. + pub async fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> OpResult>> { + self.user_created_at + .get((account_id.to_owned(), user_name.to_owned())) + .await + } + + /// Fetch a role's creation time (RFC 3339), part of its `aws:userid`. + /// `None` if the role does not exist. + pub async fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> OpResult>> { + self.role_created_at + .get((account_id.to_owned(), role_name.to_owned())) + .await + } + // ── Invalidation API ─────────────────────────────────────────────── pub async fn invalidate_user_policies(&self, account_id: &str, user_name: &str) { @@ -360,6 +408,12 @@ impl CachedAuthzStore { .await; } + pub async fn invalidate_user_created_at(&self, account_id: &str, user_name: &str) { + self.user_created_at + .invalidate(&(account_id.to_owned(), user_name.to_owned())) + .await; + } + pub async fn invalidate_role_policies(&self, account_id: &str, role_name: &str) { self.role_policies .invalidate(&(account_id.to_owned(), role_name.to_owned())) @@ -378,6 +432,12 @@ impl CachedAuthzStore { .await; } + pub async fn invalidate_role_created_at(&self, account_id: &str, role_name: &str) { + self.role_created_at + .invalidate(&(account_id.to_owned(), role_name.to_owned())) + .await; + } + pub async fn invalidate_session(&self, account_id: &str, role_name: &str, session_name: &str) { self.session_data .invalidate(&( @@ -452,6 +512,12 @@ impl CachedAuthzStore { if let Err(e) = self.user_tags.invalidate_if(pred_user.clone()) { log_err("user_tags", e); } + if let Err(e) = self.user_created_at.invalidate_if(pred_user.clone()) { + log_err("user_created_at", e); + } + if let Err(e) = self.role_created_at.invalidate_if(pred_user.clone()) { + log_err("role_created_at", e); + } if let Err(e) = self.role_policies.invalidate_if(pred_user.clone()) { log_err("role_policies", e); } @@ -483,6 +549,8 @@ impl CachedAuthzStore { self.role_tags.invalidate_all(); self.session_data.invalidate_all(); self.resource_tags.invalidate_all(); + self.user_created_at.invalidate_all(); + self.role_created_at.invalidate_all(); } /// Snapshot per-sub-cache counters for export to `/auth-cache-metrics`. @@ -568,6 +636,14 @@ impl extenddb_auth::AuthzCacheInvalidator for CachedAuthzStore { Box::pin(self.invalidate_user_tags(account_id, user_name)) } + fn invalidate_user_created_at<'a>( + &'a self, + account_id: &'a str, + user_name: &'a str, + ) -> BoxFuture<'a, ()> { + Box::pin(self.invalidate_user_created_at(account_id, user_name)) + } + fn invalidate_role_policies<'a>( &'a self, account_id: &'a str, @@ -592,6 +668,14 @@ impl extenddb_auth::AuthzCacheInvalidator for CachedAuthzStore { Box::pin(self.invalidate_role_tags(account_id, role_name)) } + fn invalidate_role_created_at<'a>( + &'a self, + account_id: &'a str, + role_name: &'a str, + ) -> BoxFuture<'a, ()> { + Box::pin(self.invalidate_role_created_at(account_id, role_name)) + } + fn invalidate_session<'a>( &'a self, account_id: &'a str, @@ -639,6 +723,7 @@ type BoundaryLoader = Loader<(String, String), Arc, OpError>; type TagsLoader = Loader<(String, String), TagMap, OpError>; type SessionLoader = Loader<(String, String, String), Arc, OpError>; type ResourceTagsLoader = Loader; +type CreatedAtLoader = Loader<(String, String), Arc, OpError>; // Policy loaders surface `Ok(None)` when the underlying store returns an // empty Vec — same rationale as the tag loaders below. "Principal exists @@ -779,6 +864,34 @@ fn make_resource_tags_loader(inner: Arc) -> ResourceTags ) } +fn make_user_created_at_loader(inner: Arc) -> CreatedAtLoader { + Arc::new(move |(account_id, user_name): (String, String)| + -> BoxFuture<'static, OpResult>>> { + let inner = inner.clone(); + async move { + Ok(inner + .fetch_user_created_at(&account_id, &user_name) + .await? + .map(Arc::new)) + } + .boxed() + }) +} + +fn make_role_created_at_loader(inner: Arc) -> CreatedAtLoader { + Arc::new(move |(account_id, role_name): (String, String)| + -> BoxFuture<'static, OpResult>>> { + let inner = inner.clone(); + async move { + Ok(inner + .fetch_role_created_at(&account_id, &role_name) + .await? + .map(Arc::new)) + } + .boxed() + }) +} + fn make_session_data_loader(inner: Arc) -> SessionLoader { Arc::new(move |(account_id, role_name, session_name): (String, String, String)| -> BoxFuture<'static, OpResult>>> { @@ -1012,6 +1125,22 @@ mod tests { .unwrap_or_default(); Box::pin(async move { Ok(v) }) } + + fn fetch_user_created_at( + &self, + _account_id: &str, + _user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + Box::pin(async move { Ok(None) }) + } + + fn fetch_role_created_at( + &self, + _account_id: &str, + _role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + Box::pin(async move { Ok(None) }) + } } fn fast_authz_cfg() -> AuthzCacheConfig { diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index 68e0bfaf9..eb664f22b 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -329,6 +329,7 @@ async fn evaluate_trust_policy( }; let principal_tags = fetch_caller_tags(caller_arn, store).await; + let caller_created_at = fetch_caller_created_at(caller_arn, store).await; let (current_time, epoch_time) = extenddb_auth::policy::context::request_time_strings(); let context = AssumeRoleContext { principal_tags, @@ -343,8 +344,12 @@ async fn evaluate_trust_policy( source_ip: source_ip.map(ToOwned::to_owned), user_agent: user_agent.map(ToOwned::to_owned), // aws:userid of the caller (an AIDA... id for a user, role-id:session - // for a role session), derived from the caller ARN. - userid: extenddb_auth::policy::principal_id::userid_for_caller_arn(caller_arn), + // for a role session), derived from the caller ARN and the caller + // principal's creation time so a recreated principal gets a new id. + userid: extenddb_auth::policy::principal_id::userid_for_caller_arn( + caller_arn, + caller_created_at.as_deref(), + ), // aws:ResourceAccount is the account that owns the role being assumed. resource_account: Some(account_id.to_owned()), current_time, @@ -897,6 +902,37 @@ async fn fetch_caller_tags( } } +/// Resolve the caller principal's creation time (RFC 3339), part of its +/// `aws:userid`. A user ARN resolves the user's creation time, an assumed-role +/// ARN the role's. `None` when the caller is not a local principal (an external +/// ARN), where `aws:userid` is then derived without it. +async fn fetch_caller_created_at( + caller_arn: &str, + store: &dyn extenddb_storage::CatalogStore, +) -> Option { + let parts: Vec<&str> = caller_arn.splitn(6, ':').collect(); + if parts.len() < 6 { + return None; + } + let account_id = parts[4]; + let resource = parts[5]; + let result = if let Some(user_name) = resource.strip_prefix("user/") { + store.fetch_user_created_at(account_id, user_name).await + } else if let Some(rest) = resource.strip_prefix("assumed-role/") { + let role = rest.split('/').next().unwrap_or(""); + store.fetch_role_created_at(account_id, role).await + } else { + Ok(None) + }; + match result { + Ok(v) => v, + Err(e) => { + tracing::error!("fetch_caller_created_at failed for {caller_arn}: {e:?}"); + None + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/server/src/management/cache_invalidate.rs b/crates/server/src/management/cache_invalidate.rs index 255a3dc1b..5f04d9c20 100644 --- a/crates/server/src/management/cache_invalidate.rs +++ b/crates/server/src/management/cache_invalidate.rs @@ -177,6 +177,9 @@ pub async fn apply( .invalidate_user_boundary(account_id, user_name) .await; auth_cache.invalidate_user_tags(account_id, user_name).await; + auth_cache + .invalidate_user_created_at(account_id, user_name) + .await; auth_cache .invalidate_principal_credentials(account_id, user_name) .await; @@ -185,6 +188,7 @@ pub async fn apply( "user_group_policies", "user_boundary", "user_tags", + "user_created_at", "principal_credentials", ] } @@ -201,6 +205,9 @@ pub async fn apply( auth_cache .invalidate_role_sessions(account_id, role_name) .await; + auth_cache + .invalidate_role_created_at(account_id, role_name) + .await; auth_cache .invalidate_principal_credentials(account_id, role_name) .await; @@ -209,6 +216,7 @@ pub async fn apply( "role_boundary", "role_tags", "role_sessions", + "role_created_at", "principal_credentials", ] } diff --git a/crates/server/src/management/iam_role.rs b/crates/server/src/management/iam_role.rs index e53bec101..19f5a84be 100755 --- a/crates/server/src/management/iam_role.rs +++ b/crates/server/src/management/iam_role.rs @@ -169,6 +169,10 @@ pub async fn delete_role( .auth_cache .invalidate_role_sessions(&account_id, &role_name) .await; + state + .auth_cache + .invalidate_role_created_at(&account_id, &role_name) + .await; StatusCode::NO_CONTENT.into_response() } Err(e) => op_err_to_response(OpError::from_storage(e)), diff --git a/crates/server/src/management/iam_user.rs b/crates/server/src/management/iam_user.rs index f60f5d703..82b10fb82 100755 --- a/crates/server/src/management/iam_user.rs +++ b/crates/server/src/management/iam_user.rs @@ -207,6 +207,10 @@ pub async fn delete_user( .auth_cache .invalidate_user_tags(&account_id, &user_name) .await; + state + .auth_cache + .invalidate_user_created_at(&account_id, &user_name) + .await; StatusCode::NO_CONTENT.into_response() } Err(e) => op_err_to_response(OpError::from_storage(e)), diff --git a/crates/storage-cassandra/src/authorization_store.rs b/crates/storage-cassandra/src/authorization_store.rs index 1b0da34fb..81d17f25e 100755 --- a/crates/storage-cassandra/src/authorization_store.rs +++ b/crates/storage-cassandra/src/authorization_store.rs @@ -479,4 +479,62 @@ impl AuthorizationStore for CassandraCatalogStore { Ok(tags) }) } + + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + let catalog_keyspace = self.catalog_keyspace(); + let session = self.session().clone(); + Box::pin(async move { + let query = format!( + "SELECT created_at FROM {catalog_keyspace}.iam_users \ + WHERE account_id = ? AND user_name = ?" + ); + let rows = crate::cassandra_util::query_rows::( + &session, + &query, + cdrs_tokio::query_values!(account_id.as_str(), user_name.as_str()), + "fetch_user_created_at", + ) + .await?; + Ok(rows.first().and_then(|row| { + crate::cassandra_util::get_timestamp::(row, "created_at", "fetch_user_created_at") + .ok() + .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()) + })) + }) + } + + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + let catalog_keyspace = self.catalog_keyspace(); + let session = self.session().clone(); + Box::pin(async move { + let query = format!( + "SELECT created_at FROM {catalog_keyspace}.iam_roles \ + WHERE account_id = ? AND role_name = ?" + ); + let rows = crate::cassandra_util::query_rows::( + &session, + &query, + cdrs_tokio::query_values!(account_id.as_str(), role_name.as_str()), + "fetch_role_created_at", + ) + .await?; + Ok(rows.first().and_then(|row| { + crate::cassandra_util::get_timestamp::(row, "created_at", "fetch_role_created_at") + .ok() + .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()) + })) + }) + } } diff --git a/crates/storage-mongodb/src/authorization_store.rs b/crates/storage-mongodb/src/authorization_store.rs index 6f7128088..2ff1ef85e 100644 --- a/crates/storage-mongodb/src/authorization_store.rs +++ b/crates/storage-mongodb/src/authorization_store.rs @@ -357,4 +357,63 @@ impl AuthorizationStore for MongoCatalogStore { .collect()) }) } + + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + Box::pin(async move { + let coll = self.catalog_db().collection::("iam_users"); + let doc = coll + .find_one(doc! { "account_id": &account_id, "user_name": &user_name }) + .await + .map_err(|e| { + tracing::error!("fetch_user_created_at: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(doc.and_then(|d| { + d.get_datetime("created_at") + .ok() + .and_then(|dt| bson_datetime_to_rfc3339(dt)) + })) + }) + } + + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + Box::pin(async move { + let coll = self.catalog_db().collection::("iam_roles"); + let doc = coll + .find_one(doc! { "account_id": &account_id, "role_name": &role_name }) + .await + .map_err(|e| { + tracing::error!("fetch_role_created_at: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(doc.and_then(|d| { + d.get_datetime("created_at") + .ok() + .and_then(|dt| bson_datetime_to_rfc3339(dt)) + })) + }) + } +} + +/// Format a BSON datetime as RFC 3339 UTC, matching how the other backends +/// return `created_at` for the `aws:userid` derivation and `aws:TokenIssueTime`. +fn bson_datetime_to_rfc3339(dt: &mongodb::bson::DateTime) -> Option { + time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(dt.timestamp_millis()) * 1_000_000) + .ok() + .and_then(|odt| { + odt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) } diff --git a/crates/storage-postgres/src/authorization_store.rs b/crates/storage-postgres/src/authorization_store.rs index 56ade5a0d..40bb63b39 100755 --- a/crates/storage-postgres/src/authorization_store.rs +++ b/crates/storage-postgres/src/authorization_store.rs @@ -252,4 +252,54 @@ impl AuthorizationStore for PostgresCatalogStore { }) }) } + + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + Box::pin(async move { + let row: Option<(time::OffsetDateTime,)> = sqlx::query_as( + "SELECT created_at FROM iam_users WHERE account_id = $1 AND user_name = $2", + ) + .bind(&account_id) + .bind(&user_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| { + tracing::error!("fetch_user_created_at: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(row.and_then(|(ts,)| { + ts.format(&time::format_description::well_known::Rfc3339).ok() + })) + }) + } + + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + Box::pin(async move { + let row: Option<(time::OffsetDateTime,)> = sqlx::query_as( + "SELECT created_at FROM iam_roles WHERE account_id = $1 AND role_name = $2", + ) + .bind(&account_id) + .bind(&role_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| { + tracing::error!("fetch_role_created_at: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(row.and_then(|(ts,)| { + ts.format(&time::format_description::well_known::Rfc3339).ok() + })) + }) + } } diff --git a/crates/storage-sqlite/src/authorization_store.rs b/crates/storage-sqlite/src/authorization_store.rs index edf5ac66f..24be66878 100644 --- a/crates/storage-sqlite/src/authorization_store.rs +++ b/crates/storage-sqlite/src/authorization_store.rs @@ -249,4 +249,52 @@ impl AuthorizationStore for SqliteCatalogStore { .map_err(|e| db_err("fetch_resource_tags", e)) }) } + + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + Box::pin(async move { + let row: Option<(String,)> = sqlx::query_as( + "SELECT created_at FROM iam_users WHERE account_id = ? AND user_name = ?", + ) + .bind(&account_id) + .bind(&user_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| db_err("fetch_user_created_at", e))?; + Ok(row.and_then(|(ts,)| { + parse_timestamp(&ts) + .ok() + .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()) + })) + }) + } + + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + Box::pin(async move { + let row: Option<(String,)> = sqlx::query_as( + "SELECT created_at FROM iam_roles WHERE account_id = ? AND role_name = ?", + ) + .bind(&account_id) + .bind(&role_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| db_err("fetch_role_created_at", e))?; + Ok(row.and_then(|(ts,)| { + parse_timestamp(&ts) + .ok() + .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()) + })) + }) + } } diff --git a/crates/storage/src/authorization_store.rs b/crates/storage/src/authorization_store.rs index f8490783c..79ca112a1 100755 --- a/crates/storage/src/authorization_store.rs +++ b/crates/storage/src/authorization_store.rs @@ -77,6 +77,27 @@ pub trait AuthorizationStore: Send + Sync { /// Fetch tags for a resource ARN (for condition key evaluation). fn fetch_resource_tags(&self, arn: &str) -> BoxFuture<'_, OpResult>>; + + /// Fetch a user's creation time (`iam_users.created_at`) as RFC 3339 UTC. + /// + /// Returns `None` if the user does not exist. The value is a stable part of + /// the user's `aws:userid` derivation, so a user deleted and recreated with + /// the same name (a new creation time) gets a new id, as IAM does. + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>>; + + /// Fetch a role's creation time (`iam_roles.created_at`) as RFC 3339 UTC. + /// + /// Returns `None` if the role does not exist. Used the same way as + /// [`AuthorizationStore::fetch_user_created_at`] for a role's `aws:userid`. + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>>; } /// Session data returned by [`AuthorizationStore::fetch_session_data`]. From ad99d50960feddff4d0609fe5962c6cd122636c6 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 02:56:42 +0000 Subject: [PATCH 24/58] fix(auth): resolve aws:TokenIssueTime for role sessions The service populates aws:TokenIssueTime with a role session's issue time and leaves it absent for an IAM user. ExtendDB left it absent for both, so a role-session policy conditioning on the key (for example a DateGreaterThan test) could not be satisfied. Resolve it for a role session from the session's stored issue time (iam_sessions.created_at) formatted as RFC 3339, threaded through the session-data fetch into the request context. IAM users keep it absent, so their absent-key behaviour is unchanged. The MongoDB session store now records the issue time at creation; the other backends already stored it. --- crates/auth/src/policy/context.rs | 37 +++++++++++++++++++ crates/server/src/authorization.rs | 9 ++++- crates/server/src/authz_cache.rs | 7 ++++ .../src/authorization_store.rs | 11 +++++- .../src/authorization_store.rs | 4 ++ .../storage-mongodb/src/management_store.rs | 1 + .../src/authorization_store.rs | 9 +++-- .../storage-sqlite/src/authorization_store.rs | 9 +++-- crates/storage/src/authorization_store.rs | 4 ++ 9 files changed, 82 insertions(+), 9 deletions(-) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index d2528e245..a320b4ccf 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -149,6 +149,10 @@ pub struct RequestParams { /// ExtendDB this is the account in the resource ARN, which is the caller's /// account (ExtendDB resources are same-account). pub resource_account: Option, + /// The session issue time (`aws:TokenIssueTime`), RFC 3339 UTC, set only for + /// a role-session caller. `None` for an IAM user, where the service leaves + /// the key absent. + pub token_issue_time: Option, } /// Context for evaluating conditions on `DynamoDB` operations. @@ -196,6 +200,9 @@ pub struct RequestContext { pub userid: Option, /// The target resource owner account (`aws:ResourceAccount`). pub resource_account: Option, + /// The session issue time (`aws:TokenIssueTime`) for a role-session caller; + /// `None` for an IAM user (absent key). + pub token_issue_time: Option, /// The request time formatted for `aws:CurrentTime` (RFC 3339 UTC). pub current_time: String, /// The request time formatted for `aws:EpochTime` (whole seconds). @@ -236,6 +243,7 @@ impl RequestContext { user_agent: params.user_agent, userid: params.userid, resource_account: params.resource_account, + token_issue_time: params.token_issue_time, current_time, epoch_time, } @@ -290,6 +298,7 @@ impl ConditionContext for RequestContext { "aws:UserAgent" => self.user_agent.as_deref().map(|v| vec![v]), "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]), // ExtendDB is not an AWS service calling on a principal's behalf, // so these are always false, matching the service for a direct // caller. @@ -491,6 +500,34 @@ mod tests { assert!(!ctx.is_unresolvable_key("aws:PrincipalOrgPaths")); } + #[test] + fn request_context_token_issue_time_present_for_role_session_absent_for_user() { + // A role session carries aws:TokenIssueTime (the session issue time). + let role_ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + token_issue_time: Some("2026-10-21T02:20:39Z".to_owned()), + ..Default::default() + }, + ); + assert_eq!( + role_ctx.resolve_key("aws:TokenIssueTime"), + Some(vec!["2026-10-21T02:20:39Z"]) + ); + // An IAM user leaves it absent: resolve_key returns None and the key is + // not fail-closed (standard absent-key semantics apply). + let user_ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams::default(), + ); + assert_eq!(user_ctx.resolve_key("aws:TokenIssueTime"), None); + assert!(!user_ctx.is_unresolvable_key("aws:TokenIssueTime")); + } + #[test] fn request_context_principal_tag() { let ctx = RequestContext::build( diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index 9dde91d2f..d959f1edd 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -167,7 +167,7 @@ async fn check_role_authorization( ) -> Result<(), DynamoDbError> { let action = format!("dynamodb:{operation}"); - let (identity_policies, boundary, (session_policy, principal_tags), resource_tags, role_created_at) = tokio::try_join!( + let (identity_policies, boundary, (session_policy, principal_tags, token_issue_time), resource_tags, role_created_at) = tokio::try_join!( wrap_policies(cache.fetch_role_policies(account_id, role_name)), wrap_boundary(cache.fetch_role_boundary(account_id, role_name)), fetch_session_data_and_tags(cache, account_id, role_name, session_name), @@ -190,6 +190,8 @@ async fn check_role_authorization( role_created_at.as_deref().map(String::as_str).unwrap_or_default(), )); params.resource_account = Some(account_id.to_owned()); + // aws:TokenIssueTime for a role session is the session's issue time. + params.token_issue_time = token_issue_time; let context = RequestContext::build(principal_tags, (*resource_tags).clone(), is_scan, params); // Convert the Arc> to a slice for the evaluator. We materialize @@ -287,6 +289,7 @@ async fn fetch_session_data_and_tags( ( Option>, HashMap, + Option, ), DynamoDbError, > { @@ -306,13 +309,15 @@ async fn fetch_session_data_and_tags( // is absent — acceptable; merging into-place is the simplest semantics. let mut tags: HashMap = (*role_tags).clone(); let mut session_policy = None; + let mut token_issue_time = None; if let Some(data) = session_data { session_policy = data.session_policy.clone(); + token_issue_time = data.token_issue_time.clone(); // Merge session tags (session wins on conflict). The session_data // value is shared via Arc; iterate borrowed and clone-on-insert. for (k, v) in &data.session_tags { tags.insert(k.clone(), v.clone()); } } - Ok((session_policy, tags)) + Ok((session_policy, tags, token_issue_time)) } diff --git a/crates/server/src/authz_cache.rs b/crates/server/src/authz_cache.rs index de355f064..dc6d559ea 100644 --- a/crates/server/src/authz_cache.rs +++ b/crates/server/src/authz_cache.rs @@ -39,6 +39,9 @@ use futures::future::BoxFuture; pub struct CachedSessionData { pub session_policy: Option>, pub session_tags: HashMap, + /// The session issue time (`aws:TokenIssueTime`), RFC 3339 UTC, or `None` + /// if the stored value could not be read. + pub token_issue_time: Option, } /// Configuration for the authorization cache. Each sub-cache is sized @@ -910,6 +913,7 @@ fn make_session_data_loader(inner: Arc) -> SessionLoader Ok(Some(Arc::new(CachedSessionData { session_policy, session_tags, + token_issue_time: data.created_at, }))) } .boxed() @@ -1287,6 +1291,7 @@ mod tests { Some(SessionData { session_policy: Some(allow_policy("dynamodb:Query")), session_tags: vec![("Project".to_owned(), "Atlas".to_owned())], + created_at: None, }), ); let cache = CachedAuthzStore::new(inner.clone(), fast_authz_cfg()); @@ -1324,6 +1329,7 @@ mod tests { Some(SessionData { session_policy: None, session_tags: vec![], + created_at: None, }), ); } @@ -1333,6 +1339,7 @@ mod tests { Some(SessionData { session_policy: None, session_tags: vec![], + created_at: None, }), ); let cache = CachedAuthzStore::new(inner.clone(), fast_authz_cfg()); diff --git a/crates/storage-cassandra/src/authorization_store.rs b/crates/storage-cassandra/src/authorization_store.rs index 81d17f25e..740735b97 100755 --- a/crates/storage-cassandra/src/authorization_store.rs +++ b/crates/storage-cassandra/src/authorization_store.rs @@ -261,7 +261,7 @@ impl AuthorizationStore for CassandraCatalogStore { Box::pin(async move { let query = format!( - "SELECT session_policy, session_tags FROM {catalog_keyspace}.iam_sessions \ + "SELECT session_policy, session_tags, created_at FROM {catalog_keyspace}.iam_sessions \ WHERE account_id = ? AND role_name = ? AND session_name = ? ALLOW FILTERING" ); @@ -323,6 +323,15 @@ impl AuthorizationStore for CassandraCatalogStore { Ok(Some(SessionData { session_policy, session_tags, + created_at: crate::cassandra_util::get_timestamp::( + row, + "created_at", + "fetch_session_data", + ) + .ok() + .and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339).ok() + }), })) }) } diff --git a/crates/storage-mongodb/src/authorization_store.rs b/crates/storage-mongodb/src/authorization_store.rs index 2ff1ef85e..1dfde9b2d 100644 --- a/crates/storage-mongodb/src/authorization_store.rs +++ b/crates/storage-mongodb/src/authorization_store.rs @@ -263,6 +263,10 @@ impl AuthorizationStore for MongoCatalogStore { Ok(Some(SessionData { session_policy, session_tags, + created_at: session_doc + .get_datetime("created_at") + .ok() + .and_then(|d| bson_datetime_to_rfc3339(d)), })) }) } diff --git a/crates/storage-mongodb/src/management_store.rs b/crates/storage-mongodb/src/management_store.rs index 4a034e666..bf38a1395 100644 --- a/crates/storage-mongodb/src/management_store.rs +++ b/crates/storage-mongodb/src/management_store.rs @@ -1840,6 +1840,7 @@ impl ManagementStore for MongoCatalogStore { "role_name": &role_name, "session_name": &session_name, "expires_at": expires_bson, + "created_at": now_bson(), }; if let Some(ref tags) = session_tags { diff --git a/crates/storage-postgres/src/authorization_store.rs b/crates/storage-postgres/src/authorization_store.rs index 40bb63b39..0e2eacd37 100755 --- a/crates/storage-postgres/src/authorization_store.rs +++ b/crates/storage-postgres/src/authorization_store.rs @@ -144,9 +144,9 @@ impl AuthorizationStore for PostgresCatalogStore { let role_name = role_name.to_owned(); let session_name = session_name.to_owned(); Box::pin(async move { - let row: Option<(Option, Option)> = + let row: Option<(Option, Option, time::OffsetDateTime)> = sqlx::query_as( - "SELECT session_policy, session_tags FROM iam_sessions \ + "SELECT session_policy, session_tags, created_at FROM iam_sessions \ WHERE account_id = $1 AND role_name = $2 AND session_name = $3 \ AND expires_at > now()", ) @@ -160,7 +160,7 @@ impl AuthorizationStore for PostgresCatalogStore { OpError::Internal("Database error".to_owned()) })?; - let Some((policy_value, tags_value)) = row else { + let Some((policy_value, tags_value, created_at)) = row else { return Ok(None); }; @@ -189,6 +189,9 @@ impl AuthorizationStore for PostgresCatalogStore { Ok(Some(SessionData { session_policy, session_tags, + created_at: created_at + .format(&time::format_description::well_known::Rfc3339) + .ok(), })) }) } diff --git a/crates/storage-sqlite/src/authorization_store.rs b/crates/storage-sqlite/src/authorization_store.rs index 24be66878..12fe64cca 100644 --- a/crates/storage-sqlite/src/authorization_store.rs +++ b/crates/storage-sqlite/src/authorization_store.rs @@ -136,8 +136,8 @@ impl AuthorizationStore for SqliteCatalogStore { let role_name = role_name.to_owned(); let session_name = session_name.to_owned(); Box::pin(async move { - let row: Option<(Option, Option, String)> = sqlx::query_as( - "SELECT session_policy, session_tags, expires_at FROM iam_sessions \ + let row: Option<(Option, Option, String, String)> = sqlx::query_as( + "SELECT session_policy, session_tags, expires_at, created_at FROM iam_sessions \ WHERE account_id = ? AND role_name = ? AND session_name = ?", ) .bind(&account_id) @@ -147,7 +147,7 @@ impl AuthorizationStore for SqliteCatalogStore { .await .map_err(|e| db_err("fetch_session_data", e))?; - let Some((session_policy, tags_json, expires_at)) = row else { + let Some((session_policy, tags_json, expires_at, created_at)) = row else { return Ok(None); }; @@ -193,6 +193,9 @@ impl AuthorizationStore for SqliteCatalogStore { Ok(Some(SessionData { session_policy, session_tags, + created_at: parse_timestamp(&created_at) + .ok() + .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()), })) }) } diff --git a/crates/storage/src/authorization_store.rs b/crates/storage/src/authorization_store.rs index 79ca112a1..87e374b3f 100755 --- a/crates/storage/src/authorization_store.rs +++ b/crates/storage/src/authorization_store.rs @@ -107,4 +107,8 @@ pub struct SessionData { pub session_policy: Option, /// Session tags as `(key, value)` pairs. pub session_tags: Vec<(String, String)>, + /// The session issue time (`iam_sessions.created_at`) formatted as RFC 3339 + /// UTC, which is the value of `aws:TokenIssueTime` for the role session. + /// `None` only if the stored value could not be read or parsed. + pub created_at: Option, } From dcaed99c2fc9fa11c52fe019096566ca57f45240 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 03:05:55 +0000 Subject: [PATCH 25/58] test(auth): wire tests for TokenIssueTime on role sessions and userid recreate aws:TokenIssueTime: a role session resolves it (Null true denies, a DateGreaterThan on a past point grants, on a future point denies) and an IAM user leaves it absent (Null true grants, a positive Date operator denies). aws:userid: a policy pinned to the id of a user's or role's first incarnation grants it and denies the same-named principal after a delete and recreate. The expected id is derived in the test from the creation time the management list returns, the same value the server hashes. --- tests/test_abac.py | 215 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/tests/test_abac.py b/tests/test_abac.py index e7a517ab9..b755beb2c 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -22,6 +22,8 @@ from __future__ import annotations +import base64 +import hashlib import os import uuid from typing import Any @@ -749,3 +751,216 @@ def test_principalorgid_absent_null_true_grants(self, table): def test_principalorgid_negated_allow_grants(self, table): assert self._get(table, self._allow_get_if( {"StringNotEquals": {"aws:PrincipalOrgID": "o-example1"}})) == "ALLOW" + + +# --------------------------------------------------------------------------- +# aws:TokenIssueTime (role sessions) and aws:userid across recreate +# --------------------------------------------------------------------------- + +# The service id derivation, reproduced here so a policy can be pinned to the +# exact id of a principal's first incarnation. It hashes the principal type, +# account, name and creation time, then base32-encodes the first 17 characters, +# with an AIDA (user) or AROA (role) prefix. The creation time is read verbatim +# from the management list response, the same RFC 3339 string the server hashes. +_B32_LEN = 17 + + +def _id_body(parts: list[str]) -> str: + h = hashlib.sha256() + for p in parts: + h.update(p.encode("utf-8")) + h.update(b"\x00") + return base64.b32encode(h.digest()).decode("ascii").rstrip("=")[:_B32_LEN] + + +def _derive_user_id(account_id: str, user_name: str, created_at: str) -> str: + return "AIDA" + _id_body(["user", account_id, user_name, created_at]) + + +def _derive_role_id(account_id: str, role_name: str, created_at: str) -> str: + return "AROA" + _id_body(["role", account_id, role_name, created_at]) + + +def _user_created_at(mgmt: ManagementClient, account_id: str, user_name: str) -> str: + resp = mgmt.list_users(account_id) + assert resp.status_code == 200, resp.text + for e in resp.json(): + if e["user_name"] == user_name: + return e["created_at"] + raise AssertionError(f"user {user_name} not found in list_users") + + +def _role_created_at(mgmt: ManagementClient, account_id: str, role_name: str) -> str: + resp = mgmt.list_roles(account_id) + assert resp.status_code == 200, resp.text + for e in resp.json(): + if e["role_name"] == role_name: + return e["created_at"] + raise AssertionError(f"role {role_name} not found in list_roles") + + +class TestTokenIssueTimeRoleSession(_TaggedTableBase): + """aws:TokenIssueTime is the session issue time for a role session and + is absent for an IAM user. Measured live against STS/DynamoDB.""" + + def _role_session_get(self, table: str, condition: dict) -> str: + """Create a user and a role whose identity policy allows GetItem under + `condition`, assume the role, GetItem with the session credentials, and + return the outcome.""" + user = f"tit-caller-{uuid.uuid4().hex[:8]}" + role = f"tit-role-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_user(self.account_id, user, "TestPass123!") + assert resp.status_code == 201, resp.text + caller_arn = f"arn:aws:iam::{self.account_id}:user/{user}" + trust = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Principal": {"AWS": caller_arn}, + "Action": "sts:AssumeRole"}]} + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, resp.text + resp = self.mgmt.put_role_policy( + self.account_id, role, "p", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": condition}]}) + assert resp.status_code == 204, resp.text + try: + resp = self.mgmt.assume_role( + self.account_id, role, caller_arn, "sess1") + assert resp.status_code == 201, resp.text + creds = resp.json() + client = boto3.client( + "dynamodb", endpoint_url=self.endpoint, + aws_access_key_id=creds["access_key_id"], + aws_secret_access_key=creds["secret_access_key"], + aws_session_token=creds["session_token"], + region_name=self.region, + config=BotoConfig(retries={"max_attempts": 0}), + verify=not self.endpoint.startswith("https://")) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + return "DENY" + finally: + self.mgmt.delete_role(self.account_id, role) + self.mgmt.delete_user(self.account_id, user) + + def test_role_session_token_issue_time_present_denies_null_true(self, table): + # Present for a role session, so Null true is false and the Allow does + # not grant. + assert self._role_session_get( + table, {"Null": {"aws:TokenIssueTime": "true"}}) == "DENY" + + def test_role_session_token_issue_time_after_recent_past_grants(self, table): + # The session was issued just now, which is after a point an hour ago. + past = "2000-01-01T00:00:00Z" + assert self._role_session_get( + table, {"DateGreaterThan": {"aws:TokenIssueTime": past}}) == "ALLOW" + + def test_role_session_token_issue_time_before_far_future_denies(self, table): + # The session issue time is not after a point far in the future. + future = "2999-01-01T00:00:00Z" + assert self._role_session_get( + table, {"DateGreaterThan": {"aws:TokenIssueTime": future}}) == "DENY" + + def test_user_token_issue_time_absent_null_true_grants(self, table): + # An IAM user leaves aws:TokenIssueTime absent, so Null true grants. + assert self._get(table, self._allow_get_if( + {"Null": {"aws:TokenIssueTime": "true"}})) == "ALLOW" + + def test_user_token_issue_time_absent_date_operator_denies(self, table): + # A positive Date operator on the absent key does not match, so the + # Allow does not grant. + assert self._get(table, self._allow_get_if( + {"DateGreaterThan": {"aws:TokenIssueTime": "2000-01-01T00:00:00Z"}})) == "DENY" + + +class TestUserIdRecreate(_TaggedTableBase): + """aws:userid changes when a principal is deleted and recreated with + the same name, so a policy pinned to the first incarnation's id denies the + second. Measured live against IAM/DynamoDB.""" + + def test_user_id_changes_on_recreate(self, table): + name = f"uidrc-{uuid.uuid4().hex[:8]}" + # First incarnation: pin a policy to its id, confirm it grants. + ak, sk = _create_user_with_key(self.mgmt, self.account_id, name) + created1 = _user_created_at(self.mgmt, self.account_id, name) + uid1 = _derive_user_id(self.account_id, name, created1) + pol = self._allow_get_if({"StringEquals": {"aws:userid": uid1}}) + assert self.mgmt.put_user_policy(self.account_id, name, "p", pol).status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + first = "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + first = "DENY" + assert first == "ALLOW", "policy pinned to the first incarnation id should grant" + + # Delete and recreate with the same name: a new id. + self.mgmt.delete_user(self.account_id, name) + ak2, sk2 = _create_user_with_key(self.mgmt, self.account_id, name) + created2 = _user_created_at(self.mgmt, self.account_id, name) + assert created2 != created1, "recreated user should have a new creation time" + # Reattach the SAME policy (still pinned to uid1). + assert self.mgmt.put_user_policy(self.account_id, name, "p", pol).status_code == 204 + client2 = _make_client(self.endpoint, ak2, sk2, self.region) + try: + client2.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + second = "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + second = "DENY" + finally: + self.mgmt.delete_user(self.account_id, name) + assert second == "DENY", "recreated principal must not match the old id" + + def test_role_id_changes_on_recreate(self, table): + caller = f"ridrc-caller-{uuid.uuid4().hex[:8]}" + role = f"ridrc-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, caller, "TestPass123!").status_code == 201 + caller_arn = f"arn:aws:iam::{self.account_id}:user/{caller}" + trust = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Principal": {"AWS": caller_arn}, + "Action": "sts:AssumeRole"}]} + + def assume_and_get(expected_userid: str) -> str: + pol = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringEquals": {"aws:userid": expected_userid}}}]} + assert self.mgmt.put_role_policy(self.account_id, role, "p", pol).status_code == 204 + resp = self.mgmt.assume_role(self.account_id, role, caller_arn, "sess1") + assert resp.status_code == 201, resp.text + creds = resp.json() + client = boto3.client( + "dynamodb", endpoint_url=self.endpoint, + aws_access_key_id=creds["access_key_id"], + aws_secret_access_key=creds["secret_access_key"], + aws_session_token=creds["session_token"], + region_name=self.region, + config=BotoConfig(retries={"max_attempts": 0}), + verify=not self.endpoint.startswith("https://")) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + return "DENY" + + try: + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + created1 = _role_created_at(self.mgmt, self.account_id, role) + uid1 = _derive_role_id(self.account_id, role, created1) + ":sess1" + assert assume_and_get(uid1) == "ALLOW", "role session should match the pinned id" + + # Recreate the role with the same name: a new role id. + self.mgmt.delete_role(self.account_id, role) + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + created2 = _role_created_at(self.mgmt, self.account_id, role) + assert created2 != created1, "recreated role should have a new creation time" + # The policy is still pinned to the first incarnation id. + assert assume_and_get(uid1) == "DENY", "recreated role must not match the old id" + finally: + self.mgmt.delete_role(self.account_id, role) + self.mgmt.delete_user(self.account_id, caller) From 141213afff357a109636c013888122c6488167e3 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 03:07:06 +0000 Subject: [PATCH 26/58] chore(auth): apply rustfmt and remove redundant closures Formatting and a clippy redundant-closure fix in the principal creation-time and TokenIssueTime code added for the two changes above. --- crates/auth/src/policy/principal_id.rs | 17 +++++++-- crates/server/src/authorization.rs | 18 ++++++++-- .../src/authorization_store.rs | 29 +++++++++++---- .../src/authorization_store.rs | 6 ++-- .../src/authorization_store.rs | 35 +++++++++++-------- .../storage-sqlite/src/authorization_store.rs | 21 ++++++----- 6 files changed, 86 insertions(+), 40 deletions(-) diff --git a/crates/auth/src/policy/principal_id.rs b/crates/auth/src/policy/principal_id.rs index cd7d36f8d..170d42a5c 100644 --- a/crates/auth/src/policy/principal_id.rs +++ b/crates/auth/src/policy/principal_id.rs @@ -72,7 +72,10 @@ fn id_body(parts: &[&str]) -> String { /// same name gets a different id, as IAM issues a new unique id each time. #[must_use] pub fn user_unique_id(account_id: &str, user_name: &str, created_at: &str) -> String { - format!("AIDA{}", id_body(&["user", account_id, user_name, created_at])) + format!( + "AIDA{}", + id_body(&["user", account_id, user_name, created_at]) + ) } /// A role's unique id, an `AROA...` string. Used as the first half of a role @@ -80,7 +83,10 @@ pub fn user_unique_id(account_id: &str, user_name: &str, created_at: &str) -> St /// recreated role gets a new id. #[must_use] pub fn role_unique_id(account_id: &str, role_name: &str, created_at: &str) -> String { - format!("AROA{}", id_body(&["role", account_id, role_name, created_at])) + format!( + "AROA{}", + id_body(&["role", account_id, role_name, created_at]) + ) } /// The `aws:userid` value for a role session: `:`. @@ -211,7 +217,12 @@ mod tests { "arn:aws:sts::123456789012:assumed-role/app-role/sess-1", Some(T1) ), - Some(role_session_userid("123456789012", "app-role", "sess-1", T1)) + Some(role_session_userid( + "123456789012", + "app-role", + "sess-1", + T1 + )) ); assert_eq!( userid_for_caller_arn("arn:aws:iam::123456789012:root", Some(T1)), diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index d959f1edd..2cbbaf436 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -117,7 +117,10 @@ async fn check_user_authorization( params.userid = Some(extenddb_auth::policy::principal_id::user_unique_id( account_id, user_name, - created_at.as_deref().map(String::as_str).unwrap_or_default(), + created_at + .as_deref() + .map(String::as_str) + .unwrap_or_default(), )); // The target table's owner account. ExtendDB builds the resource ARN with // the caller's account, so the resource account is that account. @@ -167,7 +170,13 @@ async fn check_role_authorization( ) -> Result<(), DynamoDbError> { let action = format!("dynamodb:{operation}"); - let (identity_policies, boundary, (session_policy, principal_tags, token_issue_time), resource_tags, role_created_at) = tokio::try_join!( + let ( + identity_policies, + boundary, + (session_policy, principal_tags, token_issue_time), + resource_tags, + role_created_at, + ) = tokio::try_join!( wrap_policies(cache.fetch_role_policies(account_id, role_name)), wrap_boundary(cache.fetch_role_boundary(account_id, role_name)), fetch_session_data_and_tags(cache, account_id, role_name, session_name), @@ -187,7 +196,10 @@ async fn check_role_authorization( account_id, role_name, session_name, - role_created_at.as_deref().map(String::as_str).unwrap_or_default(), + role_created_at + .as_deref() + .map(String::as_str) + .unwrap_or_default(), )); params.resource_account = Some(account_id.to_owned()); // aws:TokenIssueTime for a role session is the session's issue time. diff --git a/crates/storage-cassandra/src/authorization_store.rs b/crates/storage-cassandra/src/authorization_store.rs index 740735b97..b66bf0402 100755 --- a/crates/storage-cassandra/src/authorization_store.rs +++ b/crates/storage-cassandra/src/authorization_store.rs @@ -330,7 +330,8 @@ impl AuthorizationStore for CassandraCatalogStore { ) .ok() .and_then(|dt| { - dt.format(&time::format_description::well_known::Rfc3339).ok() + dt.format(&time::format_description::well_known::Rfc3339) + .ok() }), })) }) @@ -511,9 +512,16 @@ impl AuthorizationStore for CassandraCatalogStore { ) .await?; Ok(rows.first().and_then(|row| { - crate::cassandra_util::get_timestamp::(row, "created_at", "fetch_user_created_at") - .ok() - .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()) + crate::cassandra_util::get_timestamp::( + row, + "created_at", + "fetch_user_created_at", + ) + .ok() + .and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) })) }) } @@ -540,9 +548,16 @@ impl AuthorizationStore for CassandraCatalogStore { ) .await?; Ok(rows.first().and_then(|row| { - crate::cassandra_util::get_timestamp::(row, "created_at", "fetch_role_created_at") - .ok() - .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()) + crate::cassandra_util::get_timestamp::( + row, + "created_at", + "fetch_role_created_at", + ) + .ok() + .and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) })) }) } diff --git a/crates/storage-mongodb/src/authorization_store.rs b/crates/storage-mongodb/src/authorization_store.rs index 1dfde9b2d..bf3f33c7d 100644 --- a/crates/storage-mongodb/src/authorization_store.rs +++ b/crates/storage-mongodb/src/authorization_store.rs @@ -266,7 +266,7 @@ impl AuthorizationStore for MongoCatalogStore { created_at: session_doc .get_datetime("created_at") .ok() - .and_then(|d| bson_datetime_to_rfc3339(d)), + .and_then(bson_datetime_to_rfc3339), })) }) } @@ -381,7 +381,7 @@ impl AuthorizationStore for MongoCatalogStore { Ok(doc.and_then(|d| { d.get_datetime("created_at") .ok() - .and_then(|dt| bson_datetime_to_rfc3339(dt)) + .and_then(bson_datetime_to_rfc3339) })) }) } @@ -405,7 +405,7 @@ impl AuthorizationStore for MongoCatalogStore { Ok(doc.and_then(|d| { d.get_datetime("created_at") .ok() - .and_then(|dt| bson_datetime_to_rfc3339(dt)) + .and_then(bson_datetime_to_rfc3339) })) }) } diff --git a/crates/storage-postgres/src/authorization_store.rs b/crates/storage-postgres/src/authorization_store.rs index 0e2eacd37..c39814ceb 100755 --- a/crates/storage-postgres/src/authorization_store.rs +++ b/crates/storage-postgres/src/authorization_store.rs @@ -144,21 +144,24 @@ impl AuthorizationStore for PostgresCatalogStore { let role_name = role_name.to_owned(); let session_name = session_name.to_owned(); Box::pin(async move { - let row: Option<(Option, Option, time::OffsetDateTime)> = - sqlx::query_as( - "SELECT session_policy, session_tags, created_at FROM iam_sessions \ + let row: Option<( + Option, + Option, + time::OffsetDateTime, + )> = sqlx::query_as( + "SELECT session_policy, session_tags, created_at FROM iam_sessions \ WHERE account_id = $1 AND role_name = $2 AND session_name = $3 \ AND expires_at > now()", - ) - .bind(&account_id) - .bind(&role_name) - .bind(&session_name) - .fetch_optional(self.pool()) - .await - .map_err(|e| { - tracing::error!("fetch_session_data: {e}"); - OpError::Internal("Database error".to_owned()) - })?; + ) + .bind(&account_id) + .bind(&role_name) + .bind(&session_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| { + tracing::error!("fetch_session_data: {e}"); + OpError::Internal("Database error".to_owned()) + })?; let Some((policy_value, tags_value, created_at)) = row else { return Ok(None); @@ -276,7 +279,8 @@ impl AuthorizationStore for PostgresCatalogStore { OpError::Internal("Database error".to_owned()) })?; Ok(row.and_then(|(ts,)| { - ts.format(&time::format_description::well_known::Rfc3339).ok() + ts.format(&time::format_description::well_known::Rfc3339) + .ok() })) }) } @@ -301,7 +305,8 @@ impl AuthorizationStore for PostgresCatalogStore { OpError::Internal("Database error".to_owned()) })?; Ok(row.and_then(|(ts,)| { - ts.format(&time::format_description::well_known::Rfc3339).ok() + ts.format(&time::format_description::well_known::Rfc3339) + .ok() })) }) } diff --git a/crates/storage-sqlite/src/authorization_store.rs b/crates/storage-sqlite/src/authorization_store.rs index 12fe64cca..8c6a00cbe 100644 --- a/crates/storage-sqlite/src/authorization_store.rs +++ b/crates/storage-sqlite/src/authorization_store.rs @@ -193,9 +193,10 @@ impl AuthorizationStore for SqliteCatalogStore { Ok(Some(SessionData { session_policy, session_tags, - created_at: parse_timestamp(&created_at) - .ok() - .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()), + created_at: parse_timestamp(&created_at).ok().and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }), })) }) } @@ -270,9 +271,10 @@ impl AuthorizationStore for SqliteCatalogStore { .await .map_err(|e| db_err("fetch_user_created_at", e))?; Ok(row.and_then(|(ts,)| { - parse_timestamp(&ts) - .ok() - .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()) + parse_timestamp(&ts).ok().and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) })) }) } @@ -294,9 +296,10 @@ impl AuthorizationStore for SqliteCatalogStore { .await .map_err(|e| db_err("fetch_role_created_at", e))?; Ok(row.and_then(|(ts,)| { - parse_timestamp(&ts) - .ok() - .and_then(|dt| dt.format(&time::format_description::well_known::Rfc3339).ok()) + parse_timestamp(&ts).ok().and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) })) }) } From 91475f10335f10c7170f137fc3fb601e7fd680ea Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 03:57:01 +0000 Subject: [PATCH 27/58] fix(auth): evaluate an IPv4 client on a dual-stack listener as IPv4 An IPv4 client that reaches a server bound to :: arrives with the IPv4-mapped peer address ::ffff:a.b.c.d. aws:SourceIp carried that form, so an IPv4 CIDR in IpAddress never matched, and a Deny with NotIpAddress on an IPv4 range never fired. DynamoDB evaluates such a client as its IPv4 address. aws:SourceIp now converts a mapped address back to IPv4 on the data plane and on AssumeRole. --- crates/server/src/authorization.rs | 35 +++++++++++++++++++++ crates/server/src/handler.rs | 2 +- crates/server/src/management/assume_role.rs | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index 2cbbaf436..fd35916d6 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -333,3 +333,38 @@ async fn fetch_session_data_and_tags( } Ok((session_policy, tags, token_issue_time)) } + +/// The value of `aws:SourceIp` for a request that arrived from `peer`. +/// +/// An IPv4 client that reaches a dual-stack (`::`) listener appears as the +/// IPv4-mapped IPv6 address `::ffff:a.b.c.d`. The service evaluates such a +/// client as its IPv4 address, so an IPv4 CIDR in `IpAddress` or `NotIpAddress` +/// matches it. The mapped form is converted back to IPv4 here; any other +/// address is returned as is. +pub(crate) fn condition_source_ip(peer: &std::net::SocketAddr) -> String { + peer.ip().to_canonical().to_string() +} + +#[cfg(test)] +mod source_ip_tests { + use super::condition_source_ip; + use std::net::SocketAddr; + + #[test] + fn ipv4_mapped_peer_is_evaluated_as_ipv4() { + let mapped: SocketAddr = "[::ffff:127.0.0.1]:443".parse().unwrap(); + assert_eq!(condition_source_ip(&mapped), "127.0.0.1"); + let mapped_private: SocketAddr = "[::ffff:10.1.2.3]:443".parse().unwrap(); + assert_eq!(condition_source_ip(&mapped_private), "10.1.2.3"); + } + + #[test] + fn other_peers_are_unchanged() { + let v4: SocketAddr = "10.1.2.3:443".parse().unwrap(); + assert_eq!(condition_source_ip(&v4), "10.1.2.3"); + let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap(); + assert_eq!(condition_source_ip(&v6), "2001:db8::1"); + let loopback6: SocketAddr = "[::1]:443".parse().unwrap(); + assert_eq!(condition_source_ip(&loopback6), "::1"); + } +} diff --git a/crates/server/src/handler.rs b/crates/server/src/handler.rs index e1eb49215..7d609c3e0 100755 --- a/crates/server/src/handler.rs +++ b/crates/server/src/handler.rs @@ -121,7 +121,7 @@ pub(crate) async fn handle_request( // aws:SourceIp is the connection peer address; aws:UserAgent is the // User-Agent header. Behind a proxy only the peer address is trusted, // never an X-Forwarded-For header. - let source_ip = peer_addr.ip().to_string(); + let source_ip = crate::authorization::condition_source_ip(&peer_addr); let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()) diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index eb664f22b..5b3575db5 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -69,7 +69,7 @@ pub async fn assume_role( body: axum::body::Bytes, ) -> Response { // Authenticate via Basic auth — admin only. - let source_ip = addr.ip().to_string(); + let source_ip = crate::authorization::condition_source_ip(&addr); if let Err(e) = authenticate_admin( &headers, &*state.catalog_store, From 6d76fd3eec970ac980a0c8d337ed87a4d4c8ad0a Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 04:24:22 +0000 Subject: [PATCH 28/58] fix(auth): resolve condition key names case-insensitively The live service resolves an IAM condition key name without regard to case, including the aws: prefix and the tag prefix: aws:securetransport, AWS:SecureTransport and aws:SecureTransport resolve the same key, and aws:principaltag/team resolves the same tag as aws:PrincipalTag/team. ExtendDB matched key names exactly, so a miscased resolvable key read as absent: a Deny gated on a miscased key was dropped and a negated-operator Allow on one over-granted. Fold key-name case in both RequestContext and AssumeRoleContext resolve_key (lower-case the key for the global/dynamodb/sts match, strip the tag prefixes case-insensitively), and in is_multivalued_key and the fail-closed lookup. The tag sub-key was already matched case-insensitively. Policy write validation matches on operator names, not key names, and does not reject a miscased key, matching the service. --- crates/auth/src/policy/context.rs | 182 ++++++++++++++++++++++-------- tests/management_helpers.py | 3 + tests/test_abac.py | 45 ++++++++ tests/test_auth_integration.py | 76 +++++++++++++ 4 files changed, 260 insertions(+), 46 deletions(-) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index a320b4ccf..9ce0302a4 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -58,6 +58,24 @@ fn ci_tag_lookup<'a>(map: &'a HashMap, tag_key: &str) -> Option< .map(|(_, v)| vec![v.as_str()]) } +/// Strip `prefix` from the front of `s` case-insensitively, returning the +/// remainder (the tag sub-key) when it matches. +/// +/// The live service resolves a condition key name without regard to case, +/// including the tag prefix (`aws:PrincipalTag/`, `aws:RequestTag/`, +/// `aws:ResourceTag/`, `dynamodb:ResourceTag/`), so `aws:principaltag/team` and +/// `aws:PrincipalTag/team` resolve the same tag. The remainder is returned with +/// its original case; `ci_tag_lookup` then matches the tag sub-key +/// case-insensitively. +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + if head.eq_ignore_ascii_case(prefix) { + s.get(prefix.len()..) + } else { + None + } +} + /// Documented AWS global condition keys the live service populates for a /// request from one of ExtendDB's callers but that this build cannot resolve to /// the value the service would have. A statement using one fails closed: a @@ -89,7 +107,9 @@ const FAIL_CLOSED_GLOBAL_KEYS: &[&str] = &[]; /// matches, which is what the live service does for a key it does not populate. #[must_use] pub fn aws_key_is_unresolvable(key: &str) -> bool { - FAIL_CLOSED_GLOBAL_KEYS.contains(&key) + FAIL_CLOSED_GLOBAL_KEYS + .iter() + .any(|k| k.eq_ignore_ascii_case(key)) } /// The request time as the pair (`aws:CurrentTime` as RFC 3339 UTC, @@ -252,58 +272,63 @@ impl RequestContext { impl ConditionContext for RequestContext { fn resolve_key(&self, key: &str) -> Option> { - if let Some(tag_key) = key.strip_prefix("aws:PrincipalTag/") { + // Condition key names are resolved case-insensitively, matching the + // live service: `aws:securetransport` and `AWS:SecureTransport` resolve + // the same as `aws:SecureTransport`, and `aws:principaltag/team` the + // same as `aws:PrincipalTag/team`. The tag prefix is matched + // case-insensitively here; the tag sub-key is folded by `ci_tag_lookup`. + if let Some(tag_key) = strip_prefix_ci(key, "aws:PrincipalTag/") { ci_tag_lookup(&self.principal_tags, tag_key) - } else if let Some(tag_key) = key.strip_prefix("aws:ResourceTag/") { + } else if let Some(tag_key) = strip_prefix_ci(key, "aws:ResourceTag/") { // The service evaluates resource tags as aws:ResourceTag/*. ExtendDB // resolves them from the same resource-tag map it uses for // dynamodb:ResourceTag/*, case-insensitively on the key. ci_tag_lookup(&self.resource_tags, tag_key) - } else if let Some(tag_key) = key.strip_prefix("dynamodb:ResourceTag/") { + } else if let Some(tag_key) = strip_prefix_ci(key, "dynamodb:ResourceTag/") { ci_tag_lookup(&self.resource_tags, tag_key) } else { - match key { - "dynamodb:LeadingKeys" => self + match key.to_ascii_lowercase().as_str() { + "dynamodb:leadingkeys" => self .leading_keys .as_ref() .map(|v| v.iter().map(std::string::String::as_str).collect()), - "dynamodb:Attributes" => self + "dynamodb:attributes" => self .attributes .as_ref() .map(|v| v.iter().map(std::string::String::as_str).collect()), - "dynamodb:Select" => self.select.as_deref().map(|v| vec![v]), - "dynamodb:ReturnValues" => self.return_values.as_deref().map(|v| vec![v]), - "dynamodb:ReturnConsumedCapacity" => { + "dynamodb:select" => self.select.as_deref().map(|v| vec![v]), + "dynamodb:returnvalues" => self.return_values.as_deref().map(|v| vec![v]), + "dynamodb:returnconsumedcapacity" => { self.return_consumed_capacity.as_deref().map(|v| vec![v]) } - "dynamodb:FullTableScan" => self + "dynamodb:fulltablescan" => self .full_table_scan .map(|v| vec![if v { "true" } else { "false" }]), - "dynamodb:EnclosingOperation" => { + "dynamodb:enclosingoperation" => { self.enclosing_operation.as_deref().map(|v| vec![v]) } - "aws:PrincipalArn" => self.principal_arn.as_deref().map(|v| vec![v]), - "aws:PrincipalAccount" => self.principal_account.as_deref().map(|v| vec![v]), - "aws:RequestedRegion" => self.region.as_deref().map(|v| vec![v]), - "aws:SecureTransport" => Some(vec![if self.secure_transport { + "aws:principalarn" => self.principal_arn.as_deref().map(|v| vec![v]), + "aws:principalaccount" => self.principal_account.as_deref().map(|v| vec![v]), + "aws:requestedregion" => self.region.as_deref().map(|v| vec![v]), + "aws:securetransport" => Some(vec![if self.secure_transport { "true" } else { "false" }]), - "aws:CurrentTime" => Some(vec![self.current_time.as_str()]), - "aws:EpochTime" => Some(vec![self.epoch_time.as_str()]), - "aws:PrincipalType" => self.principal_type.as_deref().map(|v| vec![v]), + "aws:currenttime" => Some(vec![self.current_time.as_str()]), + "aws:epochtime" => Some(vec![self.epoch_time.as_str()]), + "aws:principaltype" => self.principal_type.as_deref().map(|v| vec![v]), "aws:username" => self.username.as_deref().map(|v| vec![v]), - "aws:SourceIp" => self.source_ip.as_deref().map(|v| vec![v]), - "aws:UserAgent" => self.user_agent.as_deref().map(|v| vec![v]), + "aws:sourceip" => self.source_ip.as_deref().map(|v| vec![v]), + "aws:useragent" => self.user_agent.as_deref().map(|v| vec![v]), "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]), + "aws:resourceaccount" => self.resource_account.as_deref().map(|v| vec![v]), + "aws:tokenissuetime" => self.token_issue_time.as_deref().map(|v| vec![v]), // ExtendDB is not an AWS service calling on a principal's behalf, // so these are always false, matching the service for a direct // caller. - "aws:ViaAWSService" => Some(vec!["false"]), - "aws:PrincipalIsAWSService" => Some(vec!["false"]), + "aws:viaawsservice" => Some(vec!["false"]), + "aws:principalisawsservice" => Some(vec!["false"]), _ => None, } } @@ -312,8 +337,13 @@ 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). - matches!(key, "dynamodb:LeadingKeys" | "dynamodb:Attributes") + // single-valued. Verified against real AWS IAM (BR-7085). Key names are + // matched case-insensitively, matching the service. + let lower = key.to_ascii_lowercase(); + matches!( + lower.as_str(), + "dynamodb:leadingkeys" | "dynamodb:attributes" + ) } fn is_unresolvable_key(&self, key: &str) -> bool { @@ -383,32 +413,34 @@ pub struct AssumeRoleContext { impl ConditionContext for AssumeRoleContext { fn resolve_key(&self, key: &str) -> Option> { - if let Some(tag_key) = key.strip_prefix("aws:PrincipalTag/") { + // Condition key names are resolved case-insensitively, matching the + // live service, including the tag prefixes. + if let Some(tag_key) = strip_prefix_ci(key, "aws:PrincipalTag/") { ci_tag_lookup(&self.principal_tags, tag_key) - } else if let Some(tag_key) = key.strip_prefix("aws:RequestTag/") { + } else if let Some(tag_key) = strip_prefix_ci(key, "aws:RequestTag/") { ci_tag_lookup(&self.request_tags, tag_key) } else { - match key { - "sts:ExternalId" => self.external_id.as_deref().map(|v| vec![v]), - "aws:PrincipalArn" => Some(vec![self.principal_arn.as_str()]), - "aws:PrincipalAccount" => self.principal_account.as_deref().map(|v| vec![v]), - "aws:RequestedRegion" => self.region.as_deref().map(|v| vec![v]), - "aws:SecureTransport" => Some(vec![if self.secure_transport { + match key.to_ascii_lowercase().as_str() { + "sts:externalid" => self.external_id.as_deref().map(|v| vec![v]), + "aws:principalarn" => Some(vec![self.principal_arn.as_str()]), + "aws:principalaccount" => self.principal_account.as_deref().map(|v| vec![v]), + "aws:requestedregion" => self.region.as_deref().map(|v| vec![v]), + "aws:securetransport" => Some(vec![if self.secure_transport { "true" } else { "false" }]), - "aws:CurrentTime" => Some(vec![self.current_time.as_str()]), - "aws:EpochTime" => Some(vec![self.epoch_time.as_str()]), - "aws:PrincipalType" => self.principal_type.as_deref().map(|v| vec![v]), + "aws:currenttime" => Some(vec![self.current_time.as_str()]), + "aws:epochtime" => Some(vec![self.epoch_time.as_str()]), + "aws:principaltype" => self.principal_type.as_deref().map(|v| vec![v]), "aws:username" => self.username.as_deref().map(|v| vec![v]), - "aws:SourceIp" => self.source_ip.as_deref().map(|v| vec![v]), - "aws:UserAgent" => self.user_agent.as_deref().map(|v| vec![v]), + "aws:sourceip" => self.source_ip.as_deref().map(|v| vec![v]), + "aws:useragent" => self.user_agent.as_deref().map(|v| vec![v]), "aws:userid" => self.userid.as_deref().map(|v| vec![v]), - "aws:ResourceAccount" => self.resource_account.as_deref().map(|v| vec![v]), - "aws:ViaAWSService" => Some(vec!["false"]), - "aws:PrincipalIsAWSService" => Some(vec!["false"]), - "aws:TagKeys" => { + "aws:resourceaccount" => self.resource_account.as_deref().map(|v| vec![v]), + "aws:viaawsservice" => Some(vec!["false"]), + "aws:principalisawsservice" => Some(vec!["false"]), + "aws:tagkeys" => { if self.request_tags.is_empty() { None } else { @@ -424,8 +456,8 @@ impl ConditionContext for AssumeRoleContext { // `aws:TagKeys` is the set of session-tag keys (`ArrayOfString`); every // other trust-policy key (`aws:PrincipalTag/*`, `aws:RequestTag/*`, // `sts:ExternalId`, `aws:PrincipalArn`, `aws:PrincipalAccount`) is - // single-valued. - key == "aws:TagKeys" + // single-valued. Matched case-insensitively, like the service. + key.eq_ignore_ascii_case("aws:TagKeys") } fn is_unresolvable_key(&self, key: &str) -> bool { @@ -597,6 +629,64 @@ mod tests { assert_eq!(ctx.resolve_key("aws:RequestTag/env"), Some(vec!["prod"])); } + #[test] + fn request_context_key_name_is_case_insensitive() { + // The service resolves a condition key NAME regardless of case, + // including the `aws:` prefix and the tag prefix. A miscased key must + // resolve to the same value as its canonical form. + let ctx = RequestContext::build( + HashMap::from([("Team".to_owned(), "contractors".to_owned())]), + HashMap::from([("Env".to_owned(), "prod".to_owned())]), + false, + RequestParams { + source_ip: Some("203.0.113.9".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + leading_keys: Some(vec!["k1".to_owned()]), + attributes: Some(vec!["pk".to_owned()]), + ..Default::default() + }, + ); + // Global keys in lower case and with an upper-cased prefix. + assert_eq!(ctx.resolve_key("aws:securetransport"), Some(vec!["true"])); + assert_eq!(ctx.resolve_key("AWS:SecureTransport"), Some(vec!["true"])); + assert_eq!(ctx.resolve_key("aws:principaltype"), Some(vec!["User"])); + assert_eq!(ctx.resolve_key("aws:sourceip"), Some(vec!["203.0.113.9"])); + // dynamodb: keys in lower case, single- and multi-valued. + assert_eq!(ctx.resolve_key("dynamodb:leadingkeys"), Some(vec!["k1"])); + assert_eq!(ctx.resolve_key("dynamodb:attributes"), Some(vec!["pk"])); + assert!(ctx.is_multivalued_key("dynamodb:leadingkeys")); + assert!(ctx.is_multivalued_key("DYNAMODB:Attributes")); + // Tag prefixes in lower/upper case; the sub-key case is already folded. + assert_eq!( + ctx.resolve_key("aws:principaltag/team"), + Some(vec!["contractors"]) + ); + assert_eq!(ctx.resolve_key("aws:resourcetag/ENV"), Some(vec!["prod"])); + assert_eq!( + ctx.resolve_key("dynamodb:resourcetag/env"), + Some(vec!["prod"]) + ); + } + + #[test] + fn assume_role_context_key_name_is_case_insensitive() { + let ctx = ar_ctx( + HashMap::from([("Team".to_owned(), "contractors".to_owned())]), + Some("ext-123".to_owned()), + HashMap::from([("env".to_owned(), "prod".to_owned())]), + ); + assert_eq!(ctx.resolve_key("sts:externalid"), Some(vec!["ext-123"])); + assert_eq!(ctx.resolve_key("STS:ExternalId"), Some(vec!["ext-123"])); + assert_eq!( + ctx.resolve_key("aws:principalarn"), + Some(vec!["arn:aws:iam::123456789012:user/alice"]) + ); + assert_eq!(ctx.resolve_key("aws:requesttag/env"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:tagkeys"), Some(vec!["env"])); + assert!(ctx.is_multivalued_key("aws:tagkeys")); + } + #[test] fn request_context_leading_keys() { let ctx = RequestContext::build( diff --git a/tests/management_helpers.py b/tests/management_helpers.py index 90fa23391..865dbf842 100755 --- a/tests/management_helpers.py +++ b/tests/management_helpers.py @@ -189,6 +189,7 @@ def assume_role( session_tags: dict | None = None, session_policy: dict | None = None, duration_seconds: int = 3600, + external_id: str | None = None, ) -> requests.Response: body: dict[str, Any] = { "caller_arn": caller_arn, @@ -199,6 +200,8 @@ def assume_role( body["session_tags"] = session_tags if session_policy is not None: body["session_policy"] = session_policy + if external_id is not None: + body["external_id"] = external_id return requests.post( f"{self.base_url}/accounts/{account_id}/roles/{role_name}/assume", json=body, diff --git a/tests/test_abac.py b/tests/test_abac.py index b755beb2c..0bd49fa24 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -753,6 +753,51 @@ def test_principalorgid_negated_allow_grants(self, table): {"StringNotEquals": {"aws:PrincipalOrgID": "o-example1"}})) == "ALLOW" +class TestConditionKeyNameCaseInsensitive(_TaggedTableBase): + """Condition key NAMES resolve case-insensitively on the identity path, + matching the live service. A miscased key resolves the same value as its + canonical form; a case-sensitive lookup would read it as absent and flip the + outcome.""" + + def test_principaltype_lowercase_key_allows(self, table): + # aws:principaltype resolves to User like aws:PrincipalType. + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:principaltype": "User"}})) == "ALLOW" + + def test_securetransport_lowercase_key_deny_guard_allows(self, table): + # aws:securetransport resolves to true over TLS, so the Deny is inert. + assert self._get(table, self._allow_all_deny_get_if( + {"Bool": {"aws:securetransport": "false"}})) == "ALLOW" + + def test_sourceip_lowercase_key_grants(self, table): + # aws:sourceip resolves like aws:SourceIp; 0.0.0.0/0 matches any caller. + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:sourceip": "0.0.0.0/0"}})) == "ALLOW" + + def test_leadingkeys_lowercase_key_grants(self, table): + # dynamodb:leadingkeys resolves like dynamodb:LeadingKeys; the GetItem + # key is k1. + assert self._get(table, self._allow_get_if( + {"ForAllValues:StringEquals": {"dynamodb:leadingkeys": ["k1"]}})) == "ALLOW" + + def test_resourcetag_lowercase_prefix_grants(self, table): + # The tag prefix aws:resourcetag/ (lower) resolves like aws:ResourceTag/; + # the table is tagged env=prod. + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:resourcetag/env": "prod"}})) == "ALLOW" + + def test_dynamodb_resourcetag_lowercase_prefix_grants(self, table): + assert self._get(table, self._allow_get_if( + {"StringEquals": {"dynamodb:resourcetag/env": "prod"}})) == "ALLOW" + + def test_principaltype_deny_lowercase_key_blocks(self, table): + # A Deny gated on a miscased key must still be enforced. aws:principaltype + # resolves to User, so the Deny applies and blocks. A case-sensitive + # lookup would read the key as absent, drop the Deny, and grant. + assert self._get(table, self._allow_all_deny_get_if( + {"StringEquals": {"aws:principaltype": "User"}})) == "DENY" + + # --------------------------------------------------------------------------- # aws:TokenIssueTime (role sessions) and aws:userid across recreate # --------------------------------------------------------------------------- diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 13c7de801..c8dfa0ab2 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1198,6 +1198,82 @@ def test_tag_key_case_insensitive_negated_deny_does_not_overdeny(self): finally: self.mgmt.delete_user(self.account_id, user) + # -- Condition key NAMES are resolved case-insensitively ---------- + + def test_keyname_case_insensitive_principaltype_lowercase_grants(self): + # aws:principaltype (lower) resolves like aws:PrincipalType; the caller + # is a user, so the gated Allow grants. Matches live STS. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:principaltype": "User"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_keyname_case_insensitive_principaltype_deny_lowercase_blocks(self): + # A Deny gated on a miscased key must still be enforced: aws:principaltype + # resolves to User, so the Deny applies. A case-sensitive lookup would + # drop the Deny and grant. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": "*"}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"aws:principaltype": "User"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_keyname_case_insensitive_securetransport_lowercase_guard(self): + # aws:securetransport (lower) resolves to true over TLS, so the Deny on + # =false is inert and the Allow grants. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"Bool": {"aws:securetransport": "false"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_keyname_case_insensitive_principaltag_prefix_lowercase(self): + # The tag prefix aws:principaltag/ (lower) resolves like aws:PrincipalTag/. + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:principaltag/team": "contractors"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_keyname_case_insensitive_externalid_lowercase(self): + # sts:externalid (lower) resolves like sts:ExternalId. Deny unless the + # external id equals secret; with the matching id the Deny is inert. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringNotEquals": {"sts:externalid": "secret"}}), + ) + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, resp.text + try: + with_id = self.mgmt.assume_role( + self.account_id, role, arn, "bb-session", external_id="secret") + assert with_id.status_code == ALLOW, with_id.text + no_id = self.mgmt.assume_role(self.account_id, role, arn, "bb-session") + assert no_id.status_code == DENY, no_id.text + finally: + self.mgmt.delete_role(self.account_id, role) + finally: + self.mgmt.delete_user(self.account_id, user) + # -- Resolved request-wide keys on the trust path (T8) ------------------ def test_tls_guard_does_not_block_over_tls(self): From cc269c05bccde3069eaa7f23e9539819ff94be0a Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 04:30:07 +0000 Subject: [PATCH 29/58] fix(auth): resolve aws:MultiFactorAuthPresent for role sessions The live service populates aws:MultiFactorAuthPresent for a role session (false for a session assumed without MFA) and leaves it absent for an IAM user with long-term keys. ExtendDB never resolved it, so the common deny-without-MFA guard (Deny if Bool aws:MultiFactorAuthPresent=false) was dropped for every role session and the request was granted where the service denies it. Resolve the key to false for a role-session caller on the identity path and leave it absent for an IAM user, matching the service measured live (role session DENY, IAM user ALLOW on the guard). aws:MultiFactorAuthAge stays absent for both callers, which also matches the service for a non-MFA session. --- crates/auth/src/policy/context.rs | 48 ++++++++++++++++++++ crates/server/src/authorization.rs | 3 ++ tests/test_abac.py | 72 ++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 9ce0302a4..46d049b32 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -173,6 +173,11 @@ pub struct RequestParams { /// a role-session caller. `None` for an IAM user, where the service leaves /// the key absent. pub token_issue_time: Option, + /// Whether the caller authenticated with MFA (`aws:MultiFactorAuthPresent`). + /// The service populates this for a role session (`false` for a session + /// 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, } /// Context for evaluating conditions on `DynamoDB` operations. @@ -223,6 +228,10 @@ pub struct RequestContext { /// The session issue time (`aws:TokenIssueTime`) for a role-session caller; /// `None` for an IAM user (absent key). pub token_issue_time: Option, + /// Whether the caller authenticated with MFA (`aws:MultiFactorAuthPresent`): + /// `Some(false)` for a role session assumed without MFA, `None` for an IAM + /// user (absent key), matching the service. + pub multi_factor_auth_present: Option, /// The request time formatted for `aws:CurrentTime` (RFC 3339 UTC). pub current_time: String, /// The request time formatted for `aws:EpochTime` (whole seconds). @@ -264,6 +273,7 @@ impl RequestContext { userid: params.userid, resource_account: params.resource_account, token_issue_time: params.token_issue_time, + multi_factor_auth_present: params.multi_factor_auth_present, current_time, epoch_time, } @@ -324,6 +334,11 @@ 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]), + // Populated for a role session (false for a session assumed + // without MFA), absent for an IAM user, matching the service. + "aws:multifactorauthpresent" => self + .multi_factor_auth_present + .map(|v| vec![if v { "true" } else { "false" }]), // ExtendDB is not an AWS service calling on a principal's behalf, // so these are always false, matching the service for a direct // caller. @@ -589,6 +604,39 @@ mod tests { ); } + #[test] + fn request_context_mfa_present_for_role_session_absent_for_user() { + // A role session carries aws:MultiFactorAuthPresent = false (assumed + // without MFA); an IAM user leaves it absent (standard absent-key + // semantics, not fail-closed). + let role_ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + multi_factor_auth_present: Some(false), + ..Default::default() + }, + ); + assert_eq!( + role_ctx.resolve_key("aws:MultiFactorAuthPresent"), + Some(vec!["false"]) + ); + // Case-insensitive on the key name too. + assert_eq!( + role_ctx.resolve_key("aws:multifactorauthpresent"), + Some(vec!["false"]) + ); + let user_ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams::default(), + ); + assert_eq!(user_ctx.resolve_key("aws:MultiFactorAuthPresent"), None); + assert!(!user_ctx.is_unresolvable_key("aws:MultiFactorAuthPresent")); + } + #[test] fn request_context_tag_key_is_case_insensitive() { // The service resolves the tag key case-insensitively; the value stays diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index fd35916d6..22b83137a 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -204,6 +204,9 @@ async fn check_role_authorization( params.resource_account = Some(account_id.to_owned()); // aws:TokenIssueTime for a role session is the session's issue time. params.token_issue_time = token_issue_time; + // A role session is assumed without MFA, so aws:MultiFactorAuthPresent is + // present and false, matching the service. An IAM user leaves it absent. + params.multi_factor_auth_present = Some(false); let context = RequestContext::build(principal_tags, (*resource_tags).clone(), is_scan, params); // Convert the Arc> to a slice for the evaluator. We materialize diff --git a/tests/test_abac.py b/tests/test_abac.py index 0bd49fa24..5719e0020 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -1009,3 +1009,75 @@ def assume_and_get(expected_userid: str) -> str: finally: self.mgmt.delete_role(self.account_id, role) self.mgmt.delete_user(self.account_id, caller) + + +class TestMultiFactorAuthPresentRoleSession(_TaggedTableBase): + """aws:MultiFactorAuthPresent is present and false for a role session + assumed without MFA, and absent for an IAM user. Measured live against + STS/DynamoDB (role session DENY, user ALLOW on the deny-without-MFA guard).""" + + def _role_session_get(self, table: str, policy: dict) -> str: + user = f"mfa-caller-{uuid.uuid4().hex[:8]}" + role = f"mfa-role-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, user, "TestPass123!").status_code == 201 + caller_arn = f"arn:aws:iam::{self.account_id}:user/{user}" + trust = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Principal": {"AWS": caller_arn}, + "Action": "sts:AssumeRole"}]} + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + assert self.mgmt.put_role_policy(self.account_id, role, "p", policy).status_code == 204 + try: + resp = self.mgmt.assume_role(self.account_id, role, caller_arn, "sess1") + assert resp.status_code == 201, resp.text + creds = resp.json() + client = boto3.client( + "dynamodb", endpoint_url=self.endpoint, + aws_access_key_id=creds["access_key_id"], + aws_secret_access_key=creds["secret_access_key"], + aws_session_token=creds["session_token"], + region_name=self.region, + config=BotoConfig(retries={"max_attempts": 0}), + verify=not self.endpoint.startswith("https://")) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + return "DENY" + finally: + self.mgmt.delete_role(self.account_id, role) + self.mgmt.delete_user(self.account_id, user) + + def test_role_session_deny_without_mfa_blocks(self, table): + # aws:MultiFactorAuthPresent=false is present for a role session, so the + # Deny fires. Live: DENY. + policy = {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*"}, + {"Effect": "Deny", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "false"}}}]} + assert self._role_session_get(table, policy) == "DENY" + + def test_role_session_mfa_present_null_false_grants(self, table): + # The key is present for a role session, so Null false grants. + policy = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"Null": {"aws:MultiFactorAuthPresent": "false"}}}]} + assert self._role_session_get(table, policy) == "ALLOW" + + def test_role_session_require_mfa_true_denies(self, table): + # The session is non-MFA (false), so a require-true guard does not grant. + policy = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}}}]} + assert self._role_session_get(table, policy) == "DENY" + + def test_user_deny_without_mfa_is_inert(self, table): + # An IAM user leaves aws:MultiFactorAuthPresent absent, so the Deny on + # Bool=false is inert and the request is allowed. Live: ALLOW. + assert self._get(table, self._allow_all_deny_get_if( + {"Bool": {"aws:MultiFactorAuthPresent": "false"}})) == "ALLOW" + + def test_user_mfa_absent_null_true_grants(self, table): + # The key is absent for an IAM user, so Null true grants. + assert self._get(table, self._allow_get_if( + {"Null": {"aws:MultiFactorAuthPresent": "true"}})) == "ALLOW" From 0889baceec8d662fc39c732cc34ef66507124189 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 04:33:45 +0000 Subject: [PATCH 30/58] fix(auth): reject a malformed CIDR in an identity policy at write A condition value for IpAddress or NotIpAddress that is not a valid CIDR or address was accepted and the statement then silently did nothing: an IpAddress allow never granted and, more importantly, an IpAddress or NotIpAddress Deny never blocked. Measured live: IAM rejects a malformed CIDR (10.0.0.0/33, 999.0.0.0/8, not-a-cidr, 10.0.0.0/) in an identity policy (PutUserPolicy, PutRolePolicy) and a managed policy (CreatePolicy) with MalformedPolicyDocument, and accepts a malformed CIDR in a trust policy (UpdateAssumeRolePolicy); live STS then drops the malformed-CIDR trust statement rather than failing closed. Match the service. PolicyDocument::from_json now rejects a malformed CIDR, so an identity policy and a permissions boundary are rejected at write and a stored identity policy fails closed at read (it no longer parses, so the request is denied). The trust path is left unchanged: it accepts the value at write and the evaluator drops the statement, which already matches the service. matcher gains is_valid_cidr; the boundary write path now strict-parses the document. --- crates/auth/src/policy/document.rs | 79 ++++++++++++++++++- crates/auth/src/policy/matcher.rs | 49 ++++++++++++ .../src/management/permissions_boundary.rs | 15 ++++ tests/test_abac.py | 60 ++++++++++++++ tests/test_auth_integration.py | 18 +++++ 5 files changed, 220 insertions(+), 1 deletion(-) diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index 4b84ccad9..963997449 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -174,6 +174,7 @@ impl PolicyDocument { let raw: Value = serde_json::from_str(json).map_err(|e| PolicyParseError(e.to_string()))?; let version = raw["Version"].as_str().unwrap_or("2012-10-17").to_owned(); let statements = parse_statements(&raw["Statement"])?; + validate_statement_cidrs(&statements)?; Ok(Self { version, statements, @@ -181,6 +182,49 @@ impl PolicyDocument { } } +/// Unwrap a condition operator to its base, dropping any `ForAllValues:` / +/// `ForAnyValue:` prefix and `IfExists` suffix. +fn base_operator(op: &ConditionOperator) -> &ConditionOperator { + match op { + ConditionOperator::ForAllValues(inner) + | ConditionOperator::ForAnyValue(inner) + | ConditionOperator::IfExists(inner) => base_operator(inner), + other => other, + } +} + +/// Reject a malformed CIDR in an `IpAddress` or `NotIpAddress` condition value. +/// +/// The live service validates these values when an identity or managed policy +/// is written ("legacy parsing") and rejects a malformed one with +/// `MalformedPolicyDocument`. `PolicyDocument::from_json` is the identity-policy +/// write and read path, so this rejects a malformed value at write and fails a +/// stored identity policy closed at read (it no longer parses, so the request +/// is denied). The trust-policy path does not use `from_json`: the live trust +/// write accepts a malformed CIDR and the trust evaluator drops the statement, +/// which ExtendDB already matches, so trust policies are intentionally not +/// validated here. +fn validate_statement_cidrs(statements: &[Statement]) -> Result<(), PolicyParseError> { + for stmt in statements { + for cond in &stmt.conditions { + if matches!( + base_operator(&cond.operator), + ConditionOperator::IpAddress | ConditionOperator::NotIpAddress + ) { + for v in &cond.values { + if !super::matcher::is_valid_cidr(v) { + return Err(PolicyParseError(format!( + "malformed CIDR in condition {}: {v}", + cond.key + ))); + } + } + } + } + } + Ok(()) +} + /// Parse the Statement field — may be a single object or an array. fn parse_statements(value: &Value) -> Result, PolicyParseError> { match value { @@ -556,4 +600,37 @@ mod tests { Some(PrincipalMatch::Principals(p)) if p == &["*"] )); } -} + + #[test] + fn reject_malformed_cidr_in_ipaddress() { + // The live service rejects these at identity-policy write with + // MalformedPolicyDocument; from_json is the write and read path. + for cidr in ["10.0.0.0/33", "999.0.0.0/8", "not-a-cidr", "10.0.0.0/"] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Deny", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"IpAddress":{{"aws:SourceIp":"{cidr}"}}}}}}]}}"# + ); + assert!( + PolicyDocument::from_json(&json).is_err(), + "should reject malformed CIDR {cidr}" + ); + } + } + + #[test] + fn reject_malformed_cidr_in_notipaddress_and_set_operator() { + let json = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Deny", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"ForAnyValue:NotIpAddress":{"aws:SourceIp":"10.0.0.0/33"}}}]}"#; + assert!(PolicyDocument::from_json(json).is_err()); + } + + #[test] + fn accept_well_formed_cidr() { + let json = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"IpAddress":{"aws:SourceIp":["10.0.0.0/8","2001:db8::/32"]}}}]}"#; + assert!(PolicyDocument::from_json(json).is_ok()); + } +} \ No newline at end of file diff --git a/crates/auth/src/policy/matcher.rs b/crates/auth/src/policy/matcher.rs index 1cb381e96..dbe4b435b 100755 --- a/crates/auth/src/policy/matcher.rs +++ b/crates/auth/src/policy/matcher.rs @@ -141,6 +141,37 @@ pub fn arn_match(pattern: &str, value: &str) -> bool { /// `NotIpAddress` (both evaluate false), rather than letting `NotIpAddress` /// pass by negation. Measured live on this account: an IPv4 `IpAddress` range /// and an IPv4 `NotIpAddress` range both fail to apply for an IPv6 caller. +/// Whether `cidr` is a well-formed IPv4 or IPv6 network in `address/prefix` +/// form, or a bare address. +/// +/// The live service validates `IpAddress` and `NotIpAddress` values in an +/// identity or managed policy at write ("legacy parsing"), rejecting a bad +/// address, a bad or empty prefix, or a prefix out of range for the family +/// (`/33` on IPv4, `/129` on IPv6) with `MalformedPolicyDocument`. This mirrors +/// the parse rules `ip_in_cidr` applies, so a value that passes here is one it +/// can evaluate. +#[must_use] +pub fn is_valid_cidr(cidr: &str) -> bool { + use std::net::IpAddr; + + let (net_str, prefix_str) = match cidr.split_once('/') { + Some((n, p)) => (n, Some(p)), + None => (cidr, None), + }; + let net: IpAddr = match net_str.parse() { + Ok(n) => n, + Err(_) => return false, + }; + match prefix_str { + None => true, + Some(p) => match (net, p.parse::()) { + (IpAddr::V4(_), Ok(prefix)) => prefix <= 32, + (IpAddr::V6(_), Ok(prefix)) => prefix <= 128, + _ => false, + }, + } +} + #[must_use] pub fn ip_in_cidr(cidr: &str, addr: &str) -> Option { use std::net::IpAddr; @@ -362,4 +393,22 @@ mod tests { assert_eq!(ip_in_cidr("203.0.113.0/24", "not-an-ip"), None); assert_eq!(ip_in_cidr("203.0.113.0/33", "203.0.113.9"), None); } + + #[test] + fn valid_cidr_accepts_well_formed_and_rejects_malformed() { + // Well-formed (accepted at write). + assert!(is_valid_cidr("10.0.0.0/8")); + assert!(is_valid_cidr("0.0.0.0/0")); + assert!(is_valid_cidr("203.0.113.9")); + assert!(is_valid_cidr("203.0.113.9/32")); + assert!(is_valid_cidr("2001:db8::/32")); + assert!(is_valid_cidr("::/0")); + // Malformed, the exact set the live service rejects with + // MalformedPolicyDocument. + assert!(!is_valid_cidr("10.0.0.0/33")); + assert!(!is_valid_cidr("999.0.0.0/8")); + assert!(!is_valid_cidr("not-a-cidr")); + assert!(!is_valid_cidr("10.0.0.0/")); + assert!(!is_valid_cidr("2001:db8::/129")); + } } diff --git a/crates/server/src/management/permissions_boundary.rs b/crates/server/src/management/permissions_boundary.rs index 346891da3..10ea49442 100755 --- a/crates/server/src/management/permissions_boundary.rs +++ b/crates/server/src/management/permissions_boundary.rs @@ -95,6 +95,21 @@ async fn set_boundary( .into_response(); } + // Strict parse-on-write: reject a document the policy evaluator cannot + // parse, including a malformed CIDR in an IpAddress/NotIpAddress condition. + // The live service validates a managed/boundary policy at write and rejects + // a malformed CIDR with MalformedPolicyDocument. + { + let json_str = document.to_string(); + if let Err(e) = extenddb_auth::policy::document::PolicyDocument::from_json(&json_str) { + return ( + StatusCode::BAD_REQUEST, + format!("Invalid permissions boundary document: {e}"), + ) + .into_response(); + } + } + let result = match principal_type { "user" => { state diff --git a/tests/test_abac.py b/tests/test_abac.py index 5719e0020..da5f12570 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -1081,3 +1081,63 @@ def test_user_mfa_absent_null_true_grants(self, table): # The key is absent for an IAM user, so Null true grants. assert self._get(table, self._allow_get_if( {"Null": {"aws:MultiFactorAuthPresent": "true"}})) == "ALLOW" + + +class TestMalformedCidrRejection: + """A malformed CIDR in an IpAddress/NotIpAddress condition is rejected + at identity-policy and permissions-boundary write, matching the live service + (MalformedPolicyDocument). The trust path accepts it (see + test_auth_integration).""" + + MALFORMED = ["10.0.0.0/33", "999.0.0.0/8", "not-a-cidr", "10.0.0.0/"] + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.mgmt = mgmt + self.account_id = account_id + + @staticmethod + def _policy(cidr, op="IpAddress"): + return {"Version": "2012-10-17", "Statement": [{ + "Effect": "Deny", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {op: {"aws:SourceIp": cidr}}}]} + + def test_put_user_policy_rejects_malformed_cidr(self): + user = f"cidr-u-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, user, "TestPass123!").status_code == 201 + try: + for cidr in self.MALFORMED: + resp = self.mgmt.put_user_policy(self.account_id, user, "p", self._policy(cidr)) + assert resp.status_code == 400, f"{cidr}: {resp.status_code} {resp.text}" + # NotIpAddress too. + resp = self.mgmt.put_user_policy( + self.account_id, user, "p", self._policy("10.0.0.0/33", "NotIpAddress")) + assert resp.status_code == 400, resp.text + # A well-formed CIDR is accepted. + resp = self.mgmt.put_user_policy(self.account_id, user, "p", self._policy("10.0.0.0/8")) + assert resp.status_code == 204, resp.text + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_put_role_policy_rejects_malformed_cidr(self): + role = f"cidr-r-{uuid.uuid4().hex[:8]}" + trust = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Principal": {"AWS": f"arn:aws:iam::{self.account_id}:root"}, + "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", self._policy("10.0.0.0/33")) + assert resp.status_code == 400, resp.text + finally: + self.mgmt.delete_role(self.account_id, role) + + def test_set_user_boundary_rejects_malformed_cidr(self): + user = f"cidr-b-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, user, "TestPass123!").status_code == 201 + try: + resp = self.mgmt.set_user_boundary( + self.account_id, user, self._policy("not-a-cidr")) + assert resp.status_code == 400, resp.text + finally: + self.mgmt.delete_user(self.account_id, user) diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index c8dfa0ab2..08fb4c133 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1381,6 +1381,9 @@ def test_create_rejects_malformed_trust_policy(self, trust): condition={"StringEquals": {"aws:PrincipalTag/team": 5}})), # numeric condition value _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, condition={"StringEquals": {"aws:NoSuchKey": "x"}})), # unknown condition key + _trust(_stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"}), + _stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"IpAddress": {"aws:SourceIp": "10.0.0.0/33"}})), # malformed CIDR (trust accepts) ]) def test_create_accepts_iam_accepted_trust_policy(self, trust): role = f"bbacc-{uuid.uuid4().hex[:8]}" @@ -1390,3 +1393,18 @@ def test_create_accepts_iam_accepted_trust_policy(self, trust): finally: if resp.status_code == 201: self.mgmt.delete_role(self.account_id, role) + + def test_malformed_cidr_trust_deny_drops_matching_service(self): + # Live STS accepts a malformed CIDR in a trust policy and drops the Deny + # (the condition never matches), so assumption is granted. ExtendDB + # matches: the trust write accepts it and ip_in_cidr returns no match. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"IpAddress": {"aws:SourceIp": "10.0.0.0/33"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) From e5ce6fcfe3426fb04bd7f9c59e8f8805780f2495 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 04:35:19 +0000 Subject: [PATCH 31/58] feat(server): export the creation-time caches in auth cache metrics The two per-principal creation-time caches (user_created_at, role_created_at), consulted on every user and role-session request to derive aws:userid and aws:TokenIssueTime, were not included in the /management/auth-cache-metrics snapshot, so their hit rate and size were invisible to an operator. Add them to AuthzCacheMetricsSnapshot and metrics_snapshot(), and to the metrics list in the cache design doc and the admin guide. --- crates/server/src/authz_cache.rs | 4 ++++ docs/design/12-auth-authz-cache.md | 4 +++- docs/manuals/05-admin-guide.md | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/server/src/authz_cache.rs b/crates/server/src/authz_cache.rs index dc6d559ea..30017c151 100644 --- a/crates/server/src/authz_cache.rs +++ b/crates/server/src/authz_cache.rs @@ -571,6 +571,8 @@ impl CachedAuthzStore { role_tags: self.role_tags.metrics().snapshot(), session_data: self.session_data.metrics().snapshot(), resource_tags: self.resource_tags.metrics().snapshot(), + user_created_at: self.user_created_at.metrics().snapshot(), + role_created_at: self.role_created_at.metrics().snapshot(), }) } @@ -596,6 +598,8 @@ pub struct AuthzCacheMetricsSnapshot { pub role_tags: extenddb_cache::SwrMetricsSnapshot, pub session_data: extenddb_cache::SwrMetricsSnapshot, pub resource_tags: extenddb_cache::SwrMetricsSnapshot, + pub user_created_at: extenddb_cache::SwrMetricsSnapshot, + pub role_created_at: extenddb_cache::SwrMetricsSnapshot, } impl extenddb_auth::AuthzCacheInvalidator for CachedAuthzStore { diff --git a/docs/design/12-auth-authz-cache.md b/docs/design/12-auth-authz-cache.md index 61b1e56a4..93b8f1874 100644 --- a/docs/design/12-auth-authz-cache.md +++ b/docs/design/12-auth-authz-cache.md @@ -341,7 +341,9 @@ auth, admin user). The response shape is: "role_boundary": { … }, "role_tags": { … }, "session_data": { … }, - "resource_tags": { … } + "resource_tags": { … }, + "user_created_at": { … }, + "role_created_at": { … } } } ``` diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index 09a729659..f81b269f7 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -124,7 +124,7 @@ In-memory stale-while-revalidate caches eliminate the per-request catalog roundt | `negative_ttl_seconds` | `5` | Negative-cache TTL for "not found" results. | | `max_entries` | `10000` | Per-cache LRU cap. | -Statistics are exposed at `/management/auth-cache-metrics` (JSON, admin-authenticated). The per-cache `pass_through` flag distinguishes "cache disabled" from "cache cold." +Statistics are exposed at `/management/auth-cache-metrics` (JSON, admin-authenticated). The per-cache `pass_through` flag distinguishes "cache disabled" from "cache cold." The report includes every authorization sub-cache, among them the two principal creation-time caches (`user_created_at`, `role_created_at`) consulted on each user and role-session request to derive `aws:userid` and `aws:TokenIssueTime`. **Invalidation timing**: From 982d35aa89fdc67ebe69870ada8bd5927e2b047f Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 04:37:00 +0000 Subject: [PATCH 32/58] docs: TokenIssueTime and MultiFactorAuthPresent resolve for role sessions The admin guide and ADR-0022 classified aws:TokenIssueTime as absent for every caller, and listed aws:MultiFactorAuthPresent among the absent keys. Both are resolved for a role session (the session issue time, and false for a session assumed without MFA) and absent for an IAM user, matching the service. Move both to the resolved list scoped to role sessions, keep aws:MultiFactorAuthAge absent, and rewrite the closing paragraphs so the documented behaviour matches the code. --- docs/adr/0022-trust-policy-deny-over-allow.md | 12 ++++++---- docs/manuals/05-admin-guide.md | 23 +++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs/adr/0022-trust-policy-deny-over-allow.md b/docs/adr/0022-trust-policy-deny-over-allow.md index 07c2e93e1..0c789036a 100644 --- a/docs/adr/0022-trust-policy-deny-over-allow.md +++ b/docs/adr/0022-trust-policy-deny-over-allow.md @@ -119,14 +119,18 @@ implicitly denied. (both false for a direct caller), the tag keys (`aws:PrincipalTag/*`, `aws:RequestTag/*`, `aws:ResourceTag/*` on the identity path, `dynamodb:ResourceTag/*`, each matched case-insensitively on the key), - `sts:ExternalId` and `aws:TagKeys`. Every other `aws:` global key is absent, + `sts:ExternalId` and `aws:TagKeys`, and, for a role-session caller only, + `aws:TokenIssueTime` (the session issue time) and `aws:MultiFactorAuthPresent` + (`false` for a session assumed without MFA), both absent for an IAM user. + 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 a key it does not recognize: the organization keys (`aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, and the resource and source organization keys), because an ExtendDB account is a standalone AWS account - where IAM leaves them unset; the VPC-endpoint, federation, MFA, - service-principal and cross-service keys, which the service does not populate - for a direct IAM-user or assumed-role caller over a public endpoint; and any + where IAM leaves them unset; the VPC-endpoint, federation, service-principal + and cross-service keys, and `aws:MultiFactorAuthAge`, which the service does + not populate for a direct IAM-user or assumed-role caller over a public + endpoint; and any unknown or future `aws:` key. The evaluator keeps an explicit (currently empty) fail-closed list, so a future global key the service begins populating before ExtendDB resolves it can be made to fail closed by listing it, rather diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index f81b269f7..c7f2a6cd1 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -466,6 +466,9 @@ Resolved (evaluated against the request value): - `aws:PrincipalTag/`, `aws:RequestTag/` (trust path), `aws:ResourceTag/`, `dynamodb:ResourceTag/`, `aws:TagKeys`, `sts:ExternalId` (trust path). +- 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). Absent (keep IAM absent-key semantics, as the service leaves them unset for this caller): @@ -473,9 +476,11 @@ this caller): - Organization keys: `aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, `aws:ResourceOrgID`, `aws:ResourceOrgPaths`, `aws:SourceOrgID`, `aws:SourceOrgPaths` (a standalone account has no organization). -- Role-session and federation keys ExtendDB does not model: `aws:TokenIssueTime`, - `aws:MultiFactorAuthPresent`, `aws:MultiFactorAuthAge`, `aws:FederatedProvider`, +- Role-session and federation keys ExtendDB does not model: + `aws:MultiFactorAuthAge`, `aws:FederatedProvider`, `aws:SourceIdentity`, `aws:AssumedRoot`, `aws:SignInSessionArn`. + (`aws:TokenIssueTime` and `aws:MultiFactorAuthPresent` are resolved for a + role session; see the resolved list above.) - Network keys for VPC endpoints: `aws:SourceVpc`, `aws:SourceVpcArn`, `aws:SourceVpce`, `aws:VpcSourceIp`, `aws:VpceAccount`, `aws:VpceOrgID`, `aws:VpceOrgPaths`, `aws:Ec2InstanceSourceVpc`, @@ -496,8 +501,18 @@ added rather than silently granting through a negated operator. An operator the evaluator does not implement (for example `BinaryEquals`) still fails closed. `aws:TokenIssueTime` is populated by the service for a role session and absent -for an IAM user. ExtendDB treats it as absent for both, which can only -over-deny a role-session policy that gates on its presence, never over-grant. +for an IAM user. ExtendDB resolves it for a role session from the session issue +time and leaves it absent for an IAM user, matching the service. A policy that +gates on its presence or compares it with a `Date` operator behaves as it does +on AWS for a role session. + +`aws:MultiFactorAuthPresent` is populated by the service for a role session +(`false` for a session assumed without MFA) and absent for an IAM user with +long-term keys. ExtendDB resolves it to `false` for a role session and leaves it +absent for an IAM user, so the common deny-without-MFA guard +(`Deny if Bool aws:MultiFactorAuthPresent=false`) applies to a role session and +is inert for an IAM user, matching the service. `aws:MultiFactorAuthAge` stays +absent, as the service leaves it unset for a non-MFA session. ### Permissions Boundaries From e4e750c1589d40d39ed461bb7bf45a66752acc10 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 04:37:13 +0000 Subject: [PATCH 33/58] docs: note aws:SourceIp is the direct peer and X-Forwarded-For is ignored aws:SourceIp is the address of the TCP connection the request arrived on. Behind a reverse proxy or load balancer that is the proxy's address for every caller, and ExtendDB does not read X-Forwarded-For, so an aws:SourceIp condition is meaningful only when clients connect to ExtendDB directly. State this in the admin guide so an operator does not write an IP allowlist that is silently ineffective behind a proxy. --- docs/manuals/05-admin-guide.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index c7f2a6cd1..93926f82d 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -457,7 +457,11 @@ Resolved (evaluated against the request value): `aws:username` (user caller only), `aws:userid`. - `aws:ResourceAccount`. - `aws:SourceIp` (connection peer address; `IpAddress` and `NotIpAddress` do - IPv4 and IPv6 CIDR membership). + IPv4 and IPv6 CIDR membership). This is the address of the TCP connection the + request arrived on. Behind a reverse proxy or load balancer that is the + proxy's address for every caller, and ExtendDB does not read `X-Forwarded-For` + (the header is caller-supplied and spoofable), so an `aws:SourceIp` condition + is meaningful only when clients connect to ExtendDB directly. - `aws:UserAgent` (`User-Agent` header). - `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, `aws:EpochTime`. From 0faba60b98b75133a6294e8a86c3b03857e947a7 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 04:37:26 +0000 Subject: [PATCH 34/58] docs: note organization-key conditions do not block on a standalone account An ExtendDB account belongs to no organization, so a condition on an organization key behaves as it does for a standalone AWS account: the key is absent, a Deny testing aws:PrincipalOrgID with a positive operator does not match and so does not block, and a negated operator on it matches. Add a sentence to the admin guide so an operator does not carry an organization-scoped Deny over from AWS expecting it to block. --- docs/manuals/05-admin-guide.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index 93926f82d..51ada0b9c 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -479,7 +479,12 @@ this caller): - Organization keys: `aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, `aws:ResourceOrgID`, `aws:ResourceOrgPaths`, `aws:SourceOrgID`, - `aws:SourceOrgPaths` (a standalone account has no organization). + `aws:SourceOrgPaths` (a standalone account has no organization). An ExtendDB + account belongs to no organization, so a condition on an organization key + behaves as it does for a standalone AWS account: the key is absent, a `Deny` + that tests `aws:PrincipalOrgID` with a positive operator does not match (and + so does not block), and a negated operator on it matches. Do not carry an + organization-scoped `Deny` over from AWS expecting it to block on ExtendDB. - Role-session and federation keys ExtendDB does not model: `aws:MultiFactorAuthAge`, `aws:FederatedProvider`, `aws:SourceIdentity`, `aws:AssumedRoot`, `aws:SignInSessionArn`. From bbe444c786bffab7d6ec785748e698911af84eab Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 04:38:12 +0000 Subject: [PATCH 35/58] docs: upgrade note for aws:TokenIssueTime on pre-upgrade MongoDB sessions On MongoDB the session store did not record a session creation time before this release, so aws:TokenIssueTime is absent for a role session created before the upgrade until it expires and is re-established. The SQL and Cassandra backends already recorded it and are unaffected. The direction is over-deny only and self-heals. Note it in the upgrade manual. --- docs/manuals/07-upgrade-manual.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index fe2bf5eb2..16a73477d 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -250,6 +250,23 @@ The same applies to `dynamodb:LeadingKeys`. Audit existing policies with `extenddb manage list-user-policies` / `list-role-policies` and update any that use bare operators on these keys. No catalog migration is required for this change. +### IAM: `aws:TokenIssueTime` on MongoDB role sessions created before the upgrade + +**What changed.** ExtendDB now resolves `aws:TokenIssueTime` for a role session from +the session's recorded creation time. On the MongoDB backend the session store did not +record a creation time before this release. + +**Who is affected.** MongoDB deployments only. A role session established before the +upgrade has no stored creation time, so `aws:TokenIssueTime` is absent for that session +until it expires and is re-established. The three SQL and the Cassandra backends already +recorded the session creation time and are not affected. + +**Impact.** The direction is over-deny only and self-heals: a policy that gates on +`aws:TokenIssueTime` (a presence check or a `Date` comparison) denies a pre-upgrade +MongoDB session that it would grant, and resolves correctly once the session is +re-established. No catalog migration is required; new sessions record the creation time +automatically. + --- ## License From 8c812eb751ba7ae51712299747f76ab37710c38e Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 04:38:28 +0000 Subject: [PATCH 36/58] docs: upgrade note for malformed-CIDR rejection in identity policies Document that a malformed CIDR in an IpAddress or NotIpAddress condition is now rejected when an identity policy or permissions boundary is written, that a policy already stored with one evaluates fail closed (it no longer parses, so the request is denied), and that trust policies are unchanged because the service accepts a malformed CIDR there and drops the statement at evaluation. --- docs/manuals/07-upgrade-manual.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 16a73477d..f412445d1 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -267,6 +267,26 @@ MongoDB session that it would grant, and resolves correctly once the session is re-established. No catalog migration is required; new sessions record the creation time automatically. +### IAM: a malformed CIDR in an identity policy is rejected at write + +**What changed.** A condition value for `IpAddress` or `NotIpAddress` that is not a +valid CIDR or address (for example `10.0.0.0/33`, `999.0.0.0/8`, `not-a-cidr`, +`10.0.0.0/`) is now rejected when an identity policy or a permissions boundary is +written, with a `MalformedPolicyDocument`-class error, matching the service. Before +this release the value was accepted and the statement silently did nothing (an +`IpAddress` or `NotIpAddress` `Deny` never blocked). + +**Who is affected.** Any deployment that stored an identity policy or permissions +boundary with a malformed CIDR. New writes with a malformed value now fail. A policy +already stored with a malformed value evaluates fail closed: it no longer parses, so the +request is denied rather than the statement being ignored. Audit stored policies with +`extenddb manage list-user-policies` / `list-role-policies` and correct any malformed CIDR +before upgrading so a legitimate request is not denied. + +**Trust policies are unchanged.** The live service accepts a malformed CIDR in a trust +policy at write and drops the statement at evaluation; ExtendDB matches this, so a trust +policy is not rejected for a malformed CIDR. No catalog migration is required. + --- ## License From faf4a4c1f8c44e9bef9d703366cde26152fbe225 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 06:27:29 +0000 Subject: [PATCH 37/58] fix(auth): fold condition key names under Unicode case folding The live service resolves a condition key name and a tag key under Unicode full case folding, so a name spelled with a non-ASCII letter that folds to a resolvable key is matched. ExtendDB folded ASCII only, so such a key read as absent and the negated-operator-on-absent rule granted an Allow and dropped a Deny the service enforces. Measured which comparison the service uses with discriminating cases: the st ligature (U+FB05) and sharp s (U+00DF) expand and match (st, ss), while the dotless i (U+0131) and dotted I (U+0130) do not fold to i. That is Unicode full case folding, not a per-character simple mapping and not an upper-then-lower comparison. Add a generated full-case-fold table (crates/auth/src/policy/casefold.rs, no new dependency) and fold the whole key once in resolve_key on both the request and trust contexts, in the tag-prefix match, the tag sub-key comparison, is_multivalued_key and the fail-closed lookup. ASCII keys still fold to ASCII lowercase, so the prior behaviour is unchanged. --- crates/auth/src/policy/casefold.rs | 1569 ++++++++++++++++++++++++++++ crates/auth/src/policy/context.rs | 152 ++- crates/auth/src/policy/mod.rs | 1 + tests/test_abac.py | 40 + tests/test_auth_integration.py | 12 + 5 files changed, 1734 insertions(+), 40 deletions(-) create mode 100644 crates/auth/src/policy/casefold.rs diff --git a/crates/auth/src/policy/casefold.rs b/crates/auth/src/policy/casefold.rs new file mode 100644 index 000000000..bcf14e061 --- /dev/null +++ b/crates/auth/src/policy/casefold.rs @@ -0,0 +1,1569 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Unicode full case folding, generated from the Unicode Case Folding data +//! (the full "C" + "F" mappings). The live service resolves a condition key +//! name and a tag key under full case folding, so a name spelled with a +//! non-ASCII letter that folds to a resolvable key (long s to s, the st +//! ligature to st, sharp s to ss) is matched. Measured against the service in +//! this form; the dotless and dotted i do not fold to i, matching the table. +//! +//! This file is generated; do not edit by hand. + +/// Code point to its full case fold. Code points not listed fold to +/// themselves. Sorted by code point for binary search. +pub(crate) static CASE_FOLD: &[(u32, &str)] = &[ + (0x41, "\u{61}"), + (0x42, "\u{62}"), + (0x43, "\u{63}"), + (0x44, "\u{64}"), + (0x45, "\u{65}"), + (0x46, "\u{66}"), + (0x47, "\u{67}"), + (0x48, "\u{68}"), + (0x49, "\u{69}"), + (0x4A, "\u{6a}"), + (0x4B, "\u{6b}"), + (0x4C, "\u{6c}"), + (0x4D, "\u{6d}"), + (0x4E, "\u{6e}"), + (0x4F, "\u{6f}"), + (0x50, "\u{70}"), + (0x51, "\u{71}"), + (0x52, "\u{72}"), + (0x53, "\u{73}"), + (0x54, "\u{74}"), + (0x55, "\u{75}"), + (0x56, "\u{76}"), + (0x57, "\u{77}"), + (0x58, "\u{78}"), + (0x59, "\u{79}"), + (0x5A, "\u{7a}"), + (0xB5, "\u{3bc}"), + (0xC0, "\u{e0}"), + (0xC1, "\u{e1}"), + (0xC2, "\u{e2}"), + (0xC3, "\u{e3}"), + (0xC4, "\u{e4}"), + (0xC5, "\u{e5}"), + (0xC6, "\u{e6}"), + (0xC7, "\u{e7}"), + (0xC8, "\u{e8}"), + (0xC9, "\u{e9}"), + (0xCA, "\u{ea}"), + (0xCB, "\u{eb}"), + (0xCC, "\u{ec}"), + (0xCD, "\u{ed}"), + (0xCE, "\u{ee}"), + (0xCF, "\u{ef}"), + (0xD0, "\u{f0}"), + (0xD1, "\u{f1}"), + (0xD2, "\u{f2}"), + (0xD3, "\u{f3}"), + (0xD4, "\u{f4}"), + (0xD5, "\u{f5}"), + (0xD6, "\u{f6}"), + (0xD8, "\u{f8}"), + (0xD9, "\u{f9}"), + (0xDA, "\u{fa}"), + (0xDB, "\u{fb}"), + (0xDC, "\u{fc}"), + (0xDD, "\u{fd}"), + (0xDE, "\u{fe}"), + (0xDF, "\u{73}\u{73}"), + (0x100, "\u{101}"), + (0x102, "\u{103}"), + (0x104, "\u{105}"), + (0x106, "\u{107}"), + (0x108, "\u{109}"), + (0x10A, "\u{10b}"), + (0x10C, "\u{10d}"), + (0x10E, "\u{10f}"), + (0x110, "\u{111}"), + (0x112, "\u{113}"), + (0x114, "\u{115}"), + (0x116, "\u{117}"), + (0x118, "\u{119}"), + (0x11A, "\u{11b}"), + (0x11C, "\u{11d}"), + (0x11E, "\u{11f}"), + (0x120, "\u{121}"), + (0x122, "\u{123}"), + (0x124, "\u{125}"), + (0x126, "\u{127}"), + (0x128, "\u{129}"), + (0x12A, "\u{12b}"), + (0x12C, "\u{12d}"), + (0x12E, "\u{12f}"), + (0x130, "\u{69}\u{307}"), + (0x132, "\u{133}"), + (0x134, "\u{135}"), + (0x136, "\u{137}"), + (0x139, "\u{13a}"), + (0x13B, "\u{13c}"), + (0x13D, "\u{13e}"), + (0x13F, "\u{140}"), + (0x141, "\u{142}"), + (0x143, "\u{144}"), + (0x145, "\u{146}"), + (0x147, "\u{148}"), + (0x149, "\u{2bc}\u{6e}"), + (0x14A, "\u{14b}"), + (0x14C, "\u{14d}"), + (0x14E, "\u{14f}"), + (0x150, "\u{151}"), + (0x152, "\u{153}"), + (0x154, "\u{155}"), + (0x156, "\u{157}"), + (0x158, "\u{159}"), + (0x15A, "\u{15b}"), + (0x15C, "\u{15d}"), + (0x15E, "\u{15f}"), + (0x160, "\u{161}"), + (0x162, "\u{163}"), + (0x164, "\u{165}"), + (0x166, "\u{167}"), + (0x168, "\u{169}"), + (0x16A, "\u{16b}"), + (0x16C, "\u{16d}"), + (0x16E, "\u{16f}"), + (0x170, "\u{171}"), + (0x172, "\u{173}"), + (0x174, "\u{175}"), + (0x176, "\u{177}"), + (0x178, "\u{ff}"), + (0x179, "\u{17a}"), + (0x17B, "\u{17c}"), + (0x17D, "\u{17e}"), + (0x17F, "\u{73}"), + (0x181, "\u{253}"), + (0x182, "\u{183}"), + (0x184, "\u{185}"), + (0x186, "\u{254}"), + (0x187, "\u{188}"), + (0x189, "\u{256}"), + (0x18A, "\u{257}"), + (0x18B, "\u{18c}"), + (0x18E, "\u{1dd}"), + (0x18F, "\u{259}"), + (0x190, "\u{25b}"), + (0x191, "\u{192}"), + (0x193, "\u{260}"), + (0x194, "\u{263}"), + (0x196, "\u{269}"), + (0x197, "\u{268}"), + (0x198, "\u{199}"), + (0x19C, "\u{26f}"), + (0x19D, "\u{272}"), + (0x19F, "\u{275}"), + (0x1A0, "\u{1a1}"), + (0x1A2, "\u{1a3}"), + (0x1A4, "\u{1a5}"), + (0x1A6, "\u{280}"), + (0x1A7, "\u{1a8}"), + (0x1A9, "\u{283}"), + (0x1AC, "\u{1ad}"), + (0x1AE, "\u{288}"), + (0x1AF, "\u{1b0}"), + (0x1B1, "\u{28a}"), + (0x1B2, "\u{28b}"), + (0x1B3, "\u{1b4}"), + (0x1B5, "\u{1b6}"), + (0x1B7, "\u{292}"), + (0x1B8, "\u{1b9}"), + (0x1BC, "\u{1bd}"), + (0x1C4, "\u{1c6}"), + (0x1C5, "\u{1c6}"), + (0x1C7, "\u{1c9}"), + (0x1C8, "\u{1c9}"), + (0x1CA, "\u{1cc}"), + (0x1CB, "\u{1cc}"), + (0x1CD, "\u{1ce}"), + (0x1CF, "\u{1d0}"), + (0x1D1, "\u{1d2}"), + (0x1D3, "\u{1d4}"), + (0x1D5, "\u{1d6}"), + (0x1D7, "\u{1d8}"), + (0x1D9, "\u{1da}"), + (0x1DB, "\u{1dc}"), + (0x1DE, "\u{1df}"), + (0x1E0, "\u{1e1}"), + (0x1E2, "\u{1e3}"), + (0x1E4, "\u{1e5}"), + (0x1E6, "\u{1e7}"), + (0x1E8, "\u{1e9}"), + (0x1EA, "\u{1eb}"), + (0x1EC, "\u{1ed}"), + (0x1EE, "\u{1ef}"), + (0x1F0, "\u{6a}\u{30c}"), + (0x1F1, "\u{1f3}"), + (0x1F2, "\u{1f3}"), + (0x1F4, "\u{1f5}"), + (0x1F6, "\u{195}"), + (0x1F7, "\u{1bf}"), + (0x1F8, "\u{1f9}"), + (0x1FA, "\u{1fb}"), + (0x1FC, "\u{1fd}"), + (0x1FE, "\u{1ff}"), + (0x200, "\u{201}"), + (0x202, "\u{203}"), + (0x204, "\u{205}"), + (0x206, "\u{207}"), + (0x208, "\u{209}"), + (0x20A, "\u{20b}"), + (0x20C, "\u{20d}"), + (0x20E, "\u{20f}"), + (0x210, "\u{211}"), + (0x212, "\u{213}"), + (0x214, "\u{215}"), + (0x216, "\u{217}"), + (0x218, "\u{219}"), + (0x21A, "\u{21b}"), + (0x21C, "\u{21d}"), + (0x21E, "\u{21f}"), + (0x220, "\u{19e}"), + (0x222, "\u{223}"), + (0x224, "\u{225}"), + (0x226, "\u{227}"), + (0x228, "\u{229}"), + (0x22A, "\u{22b}"), + (0x22C, "\u{22d}"), + (0x22E, "\u{22f}"), + (0x230, "\u{231}"), + (0x232, "\u{233}"), + (0x23A, "\u{2c65}"), + (0x23B, "\u{23c}"), + (0x23D, "\u{19a}"), + (0x23E, "\u{2c66}"), + (0x241, "\u{242}"), + (0x243, "\u{180}"), + (0x244, "\u{289}"), + (0x245, "\u{28c}"), + (0x246, "\u{247}"), + (0x248, "\u{249}"), + (0x24A, "\u{24b}"), + (0x24C, "\u{24d}"), + (0x24E, "\u{24f}"), + (0x345, "\u{3b9}"), + (0x370, "\u{371}"), + (0x372, "\u{373}"), + (0x376, "\u{377}"), + (0x37F, "\u{3f3}"), + (0x386, "\u{3ac}"), + (0x388, "\u{3ad}"), + (0x389, "\u{3ae}"), + (0x38A, "\u{3af}"), + (0x38C, "\u{3cc}"), + (0x38E, "\u{3cd}"), + (0x38F, "\u{3ce}"), + (0x390, "\u{3b9}\u{308}\u{301}"), + (0x391, "\u{3b1}"), + (0x392, "\u{3b2}"), + (0x393, "\u{3b3}"), + (0x394, "\u{3b4}"), + (0x395, "\u{3b5}"), + (0x396, "\u{3b6}"), + (0x397, "\u{3b7}"), + (0x398, "\u{3b8}"), + (0x399, "\u{3b9}"), + (0x39A, "\u{3ba}"), + (0x39B, "\u{3bb}"), + (0x39C, "\u{3bc}"), + (0x39D, "\u{3bd}"), + (0x39E, "\u{3be}"), + (0x39F, "\u{3bf}"), + (0x3A0, "\u{3c0}"), + (0x3A1, "\u{3c1}"), + (0x3A3, "\u{3c3}"), + (0x3A4, "\u{3c4}"), + (0x3A5, "\u{3c5}"), + (0x3A6, "\u{3c6}"), + (0x3A7, "\u{3c7}"), + (0x3A8, "\u{3c8}"), + (0x3A9, "\u{3c9}"), + (0x3AA, "\u{3ca}"), + (0x3AB, "\u{3cb}"), + (0x3B0, "\u{3c5}\u{308}\u{301}"), + (0x3C2, "\u{3c3}"), + (0x3CF, "\u{3d7}"), + (0x3D0, "\u{3b2}"), + (0x3D1, "\u{3b8}"), + (0x3D5, "\u{3c6}"), + (0x3D6, "\u{3c0}"), + (0x3D8, "\u{3d9}"), + (0x3DA, "\u{3db}"), + (0x3DC, "\u{3dd}"), + (0x3DE, "\u{3df}"), + (0x3E0, "\u{3e1}"), + (0x3E2, "\u{3e3}"), + (0x3E4, "\u{3e5}"), + (0x3E6, "\u{3e7}"), + (0x3E8, "\u{3e9}"), + (0x3EA, "\u{3eb}"), + (0x3EC, "\u{3ed}"), + (0x3EE, "\u{3ef}"), + (0x3F0, "\u{3ba}"), + (0x3F1, "\u{3c1}"), + (0x3F4, "\u{3b8}"), + (0x3F5, "\u{3b5}"), + (0x3F7, "\u{3f8}"), + (0x3F9, "\u{3f2}"), + (0x3FA, "\u{3fb}"), + (0x3FD, "\u{37b}"), + (0x3FE, "\u{37c}"), + (0x3FF, "\u{37d}"), + (0x400, "\u{450}"), + (0x401, "\u{451}"), + (0x402, "\u{452}"), + (0x403, "\u{453}"), + (0x404, "\u{454}"), + (0x405, "\u{455}"), + (0x406, "\u{456}"), + (0x407, "\u{457}"), + (0x408, "\u{458}"), + (0x409, "\u{459}"), + (0x40A, "\u{45a}"), + (0x40B, "\u{45b}"), + (0x40C, "\u{45c}"), + (0x40D, "\u{45d}"), + (0x40E, "\u{45e}"), + (0x40F, "\u{45f}"), + (0x410, "\u{430}"), + (0x411, "\u{431}"), + (0x412, "\u{432}"), + (0x413, "\u{433}"), + (0x414, "\u{434}"), + (0x415, "\u{435}"), + (0x416, "\u{436}"), + (0x417, "\u{437}"), + (0x418, "\u{438}"), + (0x419, "\u{439}"), + (0x41A, "\u{43a}"), + (0x41B, "\u{43b}"), + (0x41C, "\u{43c}"), + (0x41D, "\u{43d}"), + (0x41E, "\u{43e}"), + (0x41F, "\u{43f}"), + (0x420, "\u{440}"), + (0x421, "\u{441}"), + (0x422, "\u{442}"), + (0x423, "\u{443}"), + (0x424, "\u{444}"), + (0x425, "\u{445}"), + (0x426, "\u{446}"), + (0x427, "\u{447}"), + (0x428, "\u{448}"), + (0x429, "\u{449}"), + (0x42A, "\u{44a}"), + (0x42B, "\u{44b}"), + (0x42C, "\u{44c}"), + (0x42D, "\u{44d}"), + (0x42E, "\u{44e}"), + (0x42F, "\u{44f}"), + (0x460, "\u{461}"), + (0x462, "\u{463}"), + (0x464, "\u{465}"), + (0x466, "\u{467}"), + (0x468, "\u{469}"), + (0x46A, "\u{46b}"), + (0x46C, "\u{46d}"), + (0x46E, "\u{46f}"), + (0x470, "\u{471}"), + (0x472, "\u{473}"), + (0x474, "\u{475}"), + (0x476, "\u{477}"), + (0x478, "\u{479}"), + (0x47A, "\u{47b}"), + (0x47C, "\u{47d}"), + (0x47E, "\u{47f}"), + (0x480, "\u{481}"), + (0x48A, "\u{48b}"), + (0x48C, "\u{48d}"), + (0x48E, "\u{48f}"), + (0x490, "\u{491}"), + (0x492, "\u{493}"), + (0x494, "\u{495}"), + (0x496, "\u{497}"), + (0x498, "\u{499}"), + (0x49A, "\u{49b}"), + (0x49C, "\u{49d}"), + (0x49E, "\u{49f}"), + (0x4A0, "\u{4a1}"), + (0x4A2, "\u{4a3}"), + (0x4A4, "\u{4a5}"), + (0x4A6, "\u{4a7}"), + (0x4A8, "\u{4a9}"), + (0x4AA, "\u{4ab}"), + (0x4AC, "\u{4ad}"), + (0x4AE, "\u{4af}"), + (0x4B0, "\u{4b1}"), + (0x4B2, "\u{4b3}"), + (0x4B4, "\u{4b5}"), + (0x4B6, "\u{4b7}"), + (0x4B8, "\u{4b9}"), + (0x4BA, "\u{4bb}"), + (0x4BC, "\u{4bd}"), + (0x4BE, "\u{4bf}"), + (0x4C0, "\u{4cf}"), + (0x4C1, "\u{4c2}"), + (0x4C3, "\u{4c4}"), + (0x4C5, "\u{4c6}"), + (0x4C7, "\u{4c8}"), + (0x4C9, "\u{4ca}"), + (0x4CB, "\u{4cc}"), + (0x4CD, "\u{4ce}"), + (0x4D0, "\u{4d1}"), + (0x4D2, "\u{4d3}"), + (0x4D4, "\u{4d5}"), + (0x4D6, "\u{4d7}"), + (0x4D8, "\u{4d9}"), + (0x4DA, "\u{4db}"), + (0x4DC, "\u{4dd}"), + (0x4DE, "\u{4df}"), + (0x4E0, "\u{4e1}"), + (0x4E2, "\u{4e3}"), + (0x4E4, "\u{4e5}"), + (0x4E6, "\u{4e7}"), + (0x4E8, "\u{4e9}"), + (0x4EA, "\u{4eb}"), + (0x4EC, "\u{4ed}"), + (0x4EE, "\u{4ef}"), + (0x4F0, "\u{4f1}"), + (0x4F2, "\u{4f3}"), + (0x4F4, "\u{4f5}"), + (0x4F6, "\u{4f7}"), + (0x4F8, "\u{4f9}"), + (0x4FA, "\u{4fb}"), + (0x4FC, "\u{4fd}"), + (0x4FE, "\u{4ff}"), + (0x500, "\u{501}"), + (0x502, "\u{503}"), + (0x504, "\u{505}"), + (0x506, "\u{507}"), + (0x508, "\u{509}"), + (0x50A, "\u{50b}"), + (0x50C, "\u{50d}"), + (0x50E, "\u{50f}"), + (0x510, "\u{511}"), + (0x512, "\u{513}"), + (0x514, "\u{515}"), + (0x516, "\u{517}"), + (0x518, "\u{519}"), + (0x51A, "\u{51b}"), + (0x51C, "\u{51d}"), + (0x51E, "\u{51f}"), + (0x520, "\u{521}"), + (0x522, "\u{523}"), + (0x524, "\u{525}"), + (0x526, "\u{527}"), + (0x528, "\u{529}"), + (0x52A, "\u{52b}"), + (0x52C, "\u{52d}"), + (0x52E, "\u{52f}"), + (0x531, "\u{561}"), + (0x532, "\u{562}"), + (0x533, "\u{563}"), + (0x534, "\u{564}"), + (0x535, "\u{565}"), + (0x536, "\u{566}"), + (0x537, "\u{567}"), + (0x538, "\u{568}"), + (0x539, "\u{569}"), + (0x53A, "\u{56a}"), + (0x53B, "\u{56b}"), + (0x53C, "\u{56c}"), + (0x53D, "\u{56d}"), + (0x53E, "\u{56e}"), + (0x53F, "\u{56f}"), + (0x540, "\u{570}"), + (0x541, "\u{571}"), + (0x542, "\u{572}"), + (0x543, "\u{573}"), + (0x544, "\u{574}"), + (0x545, "\u{575}"), + (0x546, "\u{576}"), + (0x547, "\u{577}"), + (0x548, "\u{578}"), + (0x549, "\u{579}"), + (0x54A, "\u{57a}"), + (0x54B, "\u{57b}"), + (0x54C, "\u{57c}"), + (0x54D, "\u{57d}"), + (0x54E, "\u{57e}"), + (0x54F, "\u{57f}"), + (0x550, "\u{580}"), + (0x551, "\u{581}"), + (0x552, "\u{582}"), + (0x553, "\u{583}"), + (0x554, "\u{584}"), + (0x555, "\u{585}"), + (0x556, "\u{586}"), + (0x587, "\u{565}\u{582}"), + (0x10A0, "\u{2d00}"), + (0x10A1, "\u{2d01}"), + (0x10A2, "\u{2d02}"), + (0x10A3, "\u{2d03}"), + (0x10A4, "\u{2d04}"), + (0x10A5, "\u{2d05}"), + (0x10A6, "\u{2d06}"), + (0x10A7, "\u{2d07}"), + (0x10A8, "\u{2d08}"), + (0x10A9, "\u{2d09}"), + (0x10AA, "\u{2d0a}"), + (0x10AB, "\u{2d0b}"), + (0x10AC, "\u{2d0c}"), + (0x10AD, "\u{2d0d}"), + (0x10AE, "\u{2d0e}"), + (0x10AF, "\u{2d0f}"), + (0x10B0, "\u{2d10}"), + (0x10B1, "\u{2d11}"), + (0x10B2, "\u{2d12}"), + (0x10B3, "\u{2d13}"), + (0x10B4, "\u{2d14}"), + (0x10B5, "\u{2d15}"), + (0x10B6, "\u{2d16}"), + (0x10B7, "\u{2d17}"), + (0x10B8, "\u{2d18}"), + (0x10B9, "\u{2d19}"), + (0x10BA, "\u{2d1a}"), + (0x10BB, "\u{2d1b}"), + (0x10BC, "\u{2d1c}"), + (0x10BD, "\u{2d1d}"), + (0x10BE, "\u{2d1e}"), + (0x10BF, "\u{2d1f}"), + (0x10C0, "\u{2d20}"), + (0x10C1, "\u{2d21}"), + (0x10C2, "\u{2d22}"), + (0x10C3, "\u{2d23}"), + (0x10C4, "\u{2d24}"), + (0x10C5, "\u{2d25}"), + (0x10C7, "\u{2d27}"), + (0x10CD, "\u{2d2d}"), + (0x13F8, "\u{13f0}"), + (0x13F9, "\u{13f1}"), + (0x13FA, "\u{13f2}"), + (0x13FB, "\u{13f3}"), + (0x13FC, "\u{13f4}"), + (0x13FD, "\u{13f5}"), + (0x1C80, "\u{432}"), + (0x1C81, "\u{434}"), + (0x1C82, "\u{43e}"), + (0x1C83, "\u{441}"), + (0x1C84, "\u{442}"), + (0x1C85, "\u{442}"), + (0x1C86, "\u{44a}"), + (0x1C87, "\u{463}"), + (0x1C88, "\u{a64b}"), + (0x1C90, "\u{10d0}"), + (0x1C91, "\u{10d1}"), + (0x1C92, "\u{10d2}"), + (0x1C93, "\u{10d3}"), + (0x1C94, "\u{10d4}"), + (0x1C95, "\u{10d5}"), + (0x1C96, "\u{10d6}"), + (0x1C97, "\u{10d7}"), + (0x1C98, "\u{10d8}"), + (0x1C99, "\u{10d9}"), + (0x1C9A, "\u{10da}"), + (0x1C9B, "\u{10db}"), + (0x1C9C, "\u{10dc}"), + (0x1C9D, "\u{10dd}"), + (0x1C9E, "\u{10de}"), + (0x1C9F, "\u{10df}"), + (0x1CA0, "\u{10e0}"), + (0x1CA1, "\u{10e1}"), + (0x1CA2, "\u{10e2}"), + (0x1CA3, "\u{10e3}"), + (0x1CA4, "\u{10e4}"), + (0x1CA5, "\u{10e5}"), + (0x1CA6, "\u{10e6}"), + (0x1CA7, "\u{10e7}"), + (0x1CA8, "\u{10e8}"), + (0x1CA9, "\u{10e9}"), + (0x1CAA, "\u{10ea}"), + (0x1CAB, "\u{10eb}"), + (0x1CAC, "\u{10ec}"), + (0x1CAD, "\u{10ed}"), + (0x1CAE, "\u{10ee}"), + (0x1CAF, "\u{10ef}"), + (0x1CB0, "\u{10f0}"), + (0x1CB1, "\u{10f1}"), + (0x1CB2, "\u{10f2}"), + (0x1CB3, "\u{10f3}"), + (0x1CB4, "\u{10f4}"), + (0x1CB5, "\u{10f5}"), + (0x1CB6, "\u{10f6}"), + (0x1CB7, "\u{10f7}"), + (0x1CB8, "\u{10f8}"), + (0x1CB9, "\u{10f9}"), + (0x1CBA, "\u{10fa}"), + (0x1CBD, "\u{10fd}"), + (0x1CBE, "\u{10fe}"), + (0x1CBF, "\u{10ff}"), + (0x1E00, "\u{1e01}"), + (0x1E02, "\u{1e03}"), + (0x1E04, "\u{1e05}"), + (0x1E06, "\u{1e07}"), + (0x1E08, "\u{1e09}"), + (0x1E0A, "\u{1e0b}"), + (0x1E0C, "\u{1e0d}"), + (0x1E0E, "\u{1e0f}"), + (0x1E10, "\u{1e11}"), + (0x1E12, "\u{1e13}"), + (0x1E14, "\u{1e15}"), + (0x1E16, "\u{1e17}"), + (0x1E18, "\u{1e19}"), + (0x1E1A, "\u{1e1b}"), + (0x1E1C, "\u{1e1d}"), + (0x1E1E, "\u{1e1f}"), + (0x1E20, "\u{1e21}"), + (0x1E22, "\u{1e23}"), + (0x1E24, "\u{1e25}"), + (0x1E26, "\u{1e27}"), + (0x1E28, "\u{1e29}"), + (0x1E2A, "\u{1e2b}"), + (0x1E2C, "\u{1e2d}"), + (0x1E2E, "\u{1e2f}"), + (0x1E30, "\u{1e31}"), + (0x1E32, "\u{1e33}"), + (0x1E34, "\u{1e35}"), + (0x1E36, "\u{1e37}"), + (0x1E38, "\u{1e39}"), + (0x1E3A, "\u{1e3b}"), + (0x1E3C, "\u{1e3d}"), + (0x1E3E, "\u{1e3f}"), + (0x1E40, "\u{1e41}"), + (0x1E42, "\u{1e43}"), + (0x1E44, "\u{1e45}"), + (0x1E46, "\u{1e47}"), + (0x1E48, "\u{1e49}"), + (0x1E4A, "\u{1e4b}"), + (0x1E4C, "\u{1e4d}"), + (0x1E4E, "\u{1e4f}"), + (0x1E50, "\u{1e51}"), + (0x1E52, "\u{1e53}"), + (0x1E54, "\u{1e55}"), + (0x1E56, "\u{1e57}"), + (0x1E58, "\u{1e59}"), + (0x1E5A, "\u{1e5b}"), + (0x1E5C, "\u{1e5d}"), + (0x1E5E, "\u{1e5f}"), + (0x1E60, "\u{1e61}"), + (0x1E62, "\u{1e63}"), + (0x1E64, "\u{1e65}"), + (0x1E66, "\u{1e67}"), + (0x1E68, "\u{1e69}"), + (0x1E6A, "\u{1e6b}"), + (0x1E6C, "\u{1e6d}"), + (0x1E6E, "\u{1e6f}"), + (0x1E70, "\u{1e71}"), + (0x1E72, "\u{1e73}"), + (0x1E74, "\u{1e75}"), + (0x1E76, "\u{1e77}"), + (0x1E78, "\u{1e79}"), + (0x1E7A, "\u{1e7b}"), + (0x1E7C, "\u{1e7d}"), + (0x1E7E, "\u{1e7f}"), + (0x1E80, "\u{1e81}"), + (0x1E82, "\u{1e83}"), + (0x1E84, "\u{1e85}"), + (0x1E86, "\u{1e87}"), + (0x1E88, "\u{1e89}"), + (0x1E8A, "\u{1e8b}"), + (0x1E8C, "\u{1e8d}"), + (0x1E8E, "\u{1e8f}"), + (0x1E90, "\u{1e91}"), + (0x1E92, "\u{1e93}"), + (0x1E94, "\u{1e95}"), + (0x1E96, "\u{68}\u{331}"), + (0x1E97, "\u{74}\u{308}"), + (0x1E98, "\u{77}\u{30a}"), + (0x1E99, "\u{79}\u{30a}"), + (0x1E9A, "\u{61}\u{2be}"), + (0x1E9B, "\u{1e61}"), + (0x1E9E, "\u{73}\u{73}"), + (0x1EA0, "\u{1ea1}"), + (0x1EA2, "\u{1ea3}"), + (0x1EA4, "\u{1ea5}"), + (0x1EA6, "\u{1ea7}"), + (0x1EA8, "\u{1ea9}"), + (0x1EAA, "\u{1eab}"), + (0x1EAC, "\u{1ead}"), + (0x1EAE, "\u{1eaf}"), + (0x1EB0, "\u{1eb1}"), + (0x1EB2, "\u{1eb3}"), + (0x1EB4, "\u{1eb5}"), + (0x1EB6, "\u{1eb7}"), + (0x1EB8, "\u{1eb9}"), + (0x1EBA, "\u{1ebb}"), + (0x1EBC, "\u{1ebd}"), + (0x1EBE, "\u{1ebf}"), + (0x1EC0, "\u{1ec1}"), + (0x1EC2, "\u{1ec3}"), + (0x1EC4, "\u{1ec5}"), + (0x1EC6, "\u{1ec7}"), + (0x1EC8, "\u{1ec9}"), + (0x1ECA, "\u{1ecb}"), + (0x1ECC, "\u{1ecd}"), + (0x1ECE, "\u{1ecf}"), + (0x1ED0, "\u{1ed1}"), + (0x1ED2, "\u{1ed3}"), + (0x1ED4, "\u{1ed5}"), + (0x1ED6, "\u{1ed7}"), + (0x1ED8, "\u{1ed9}"), + (0x1EDA, "\u{1edb}"), + (0x1EDC, "\u{1edd}"), + (0x1EDE, "\u{1edf}"), + (0x1EE0, "\u{1ee1}"), + (0x1EE2, "\u{1ee3}"), + (0x1EE4, "\u{1ee5}"), + (0x1EE6, "\u{1ee7}"), + (0x1EE8, "\u{1ee9}"), + (0x1EEA, "\u{1eeb}"), + (0x1EEC, "\u{1eed}"), + (0x1EEE, "\u{1eef}"), + (0x1EF0, "\u{1ef1}"), + (0x1EF2, "\u{1ef3}"), + (0x1EF4, "\u{1ef5}"), + (0x1EF6, "\u{1ef7}"), + (0x1EF8, "\u{1ef9}"), + (0x1EFA, "\u{1efb}"), + (0x1EFC, "\u{1efd}"), + (0x1EFE, "\u{1eff}"), + (0x1F08, "\u{1f00}"), + (0x1F09, "\u{1f01}"), + (0x1F0A, "\u{1f02}"), + (0x1F0B, "\u{1f03}"), + (0x1F0C, "\u{1f04}"), + (0x1F0D, "\u{1f05}"), + (0x1F0E, "\u{1f06}"), + (0x1F0F, "\u{1f07}"), + (0x1F18, "\u{1f10}"), + (0x1F19, "\u{1f11}"), + (0x1F1A, "\u{1f12}"), + (0x1F1B, "\u{1f13}"), + (0x1F1C, "\u{1f14}"), + (0x1F1D, "\u{1f15}"), + (0x1F28, "\u{1f20}"), + (0x1F29, "\u{1f21}"), + (0x1F2A, "\u{1f22}"), + (0x1F2B, "\u{1f23}"), + (0x1F2C, "\u{1f24}"), + (0x1F2D, "\u{1f25}"), + (0x1F2E, "\u{1f26}"), + (0x1F2F, "\u{1f27}"), + (0x1F38, "\u{1f30}"), + (0x1F39, "\u{1f31}"), + (0x1F3A, "\u{1f32}"), + (0x1F3B, "\u{1f33}"), + (0x1F3C, "\u{1f34}"), + (0x1F3D, "\u{1f35}"), + (0x1F3E, "\u{1f36}"), + (0x1F3F, "\u{1f37}"), + (0x1F48, "\u{1f40}"), + (0x1F49, "\u{1f41}"), + (0x1F4A, "\u{1f42}"), + (0x1F4B, "\u{1f43}"), + (0x1F4C, "\u{1f44}"), + (0x1F4D, "\u{1f45}"), + (0x1F50, "\u{3c5}\u{313}"), + (0x1F52, "\u{3c5}\u{313}\u{300}"), + (0x1F54, "\u{3c5}\u{313}\u{301}"), + (0x1F56, "\u{3c5}\u{313}\u{342}"), + (0x1F59, "\u{1f51}"), + (0x1F5B, "\u{1f53}"), + (0x1F5D, "\u{1f55}"), + (0x1F5F, "\u{1f57}"), + (0x1F68, "\u{1f60}"), + (0x1F69, "\u{1f61}"), + (0x1F6A, "\u{1f62}"), + (0x1F6B, "\u{1f63}"), + (0x1F6C, "\u{1f64}"), + (0x1F6D, "\u{1f65}"), + (0x1F6E, "\u{1f66}"), + (0x1F6F, "\u{1f67}"), + (0x1F80, "\u{1f00}\u{3b9}"), + (0x1F81, "\u{1f01}\u{3b9}"), + (0x1F82, "\u{1f02}\u{3b9}"), + (0x1F83, "\u{1f03}\u{3b9}"), + (0x1F84, "\u{1f04}\u{3b9}"), + (0x1F85, "\u{1f05}\u{3b9}"), + (0x1F86, "\u{1f06}\u{3b9}"), + (0x1F87, "\u{1f07}\u{3b9}"), + (0x1F88, "\u{1f00}\u{3b9}"), + (0x1F89, "\u{1f01}\u{3b9}"), + (0x1F8A, "\u{1f02}\u{3b9}"), + (0x1F8B, "\u{1f03}\u{3b9}"), + (0x1F8C, "\u{1f04}\u{3b9}"), + (0x1F8D, "\u{1f05}\u{3b9}"), + (0x1F8E, "\u{1f06}\u{3b9}"), + (0x1F8F, "\u{1f07}\u{3b9}"), + (0x1F90, "\u{1f20}\u{3b9}"), + (0x1F91, "\u{1f21}\u{3b9}"), + (0x1F92, "\u{1f22}\u{3b9}"), + (0x1F93, "\u{1f23}\u{3b9}"), + (0x1F94, "\u{1f24}\u{3b9}"), + (0x1F95, "\u{1f25}\u{3b9}"), + (0x1F96, "\u{1f26}\u{3b9}"), + (0x1F97, "\u{1f27}\u{3b9}"), + (0x1F98, "\u{1f20}\u{3b9}"), + (0x1F99, "\u{1f21}\u{3b9}"), + (0x1F9A, "\u{1f22}\u{3b9}"), + (0x1F9B, "\u{1f23}\u{3b9}"), + (0x1F9C, "\u{1f24}\u{3b9}"), + (0x1F9D, "\u{1f25}\u{3b9}"), + (0x1F9E, "\u{1f26}\u{3b9}"), + (0x1F9F, "\u{1f27}\u{3b9}"), + (0x1FA0, "\u{1f60}\u{3b9}"), + (0x1FA1, "\u{1f61}\u{3b9}"), + (0x1FA2, "\u{1f62}\u{3b9}"), + (0x1FA3, "\u{1f63}\u{3b9}"), + (0x1FA4, "\u{1f64}\u{3b9}"), + (0x1FA5, "\u{1f65}\u{3b9}"), + (0x1FA6, "\u{1f66}\u{3b9}"), + (0x1FA7, "\u{1f67}\u{3b9}"), + (0x1FA8, "\u{1f60}\u{3b9}"), + (0x1FA9, "\u{1f61}\u{3b9}"), + (0x1FAA, "\u{1f62}\u{3b9}"), + (0x1FAB, "\u{1f63}\u{3b9}"), + (0x1FAC, "\u{1f64}\u{3b9}"), + (0x1FAD, "\u{1f65}\u{3b9}"), + (0x1FAE, "\u{1f66}\u{3b9}"), + (0x1FAF, "\u{1f67}\u{3b9}"), + (0x1FB2, "\u{1f70}\u{3b9}"), + (0x1FB3, "\u{3b1}\u{3b9}"), + (0x1FB4, "\u{3ac}\u{3b9}"), + (0x1FB6, "\u{3b1}\u{342}"), + (0x1FB7, "\u{3b1}\u{342}\u{3b9}"), + (0x1FB8, "\u{1fb0}"), + (0x1FB9, "\u{1fb1}"), + (0x1FBA, "\u{1f70}"), + (0x1FBB, "\u{1f71}"), + (0x1FBC, "\u{3b1}\u{3b9}"), + (0x1FBE, "\u{3b9}"), + (0x1FC2, "\u{1f74}\u{3b9}"), + (0x1FC3, "\u{3b7}\u{3b9}"), + (0x1FC4, "\u{3ae}\u{3b9}"), + (0x1FC6, "\u{3b7}\u{342}"), + (0x1FC7, "\u{3b7}\u{342}\u{3b9}"), + (0x1FC8, "\u{1f72}"), + (0x1FC9, "\u{1f73}"), + (0x1FCA, "\u{1f74}"), + (0x1FCB, "\u{1f75}"), + (0x1FCC, "\u{3b7}\u{3b9}"), + (0x1FD2, "\u{3b9}\u{308}\u{300}"), + (0x1FD3, "\u{3b9}\u{308}\u{301}"), + (0x1FD6, "\u{3b9}\u{342}"), + (0x1FD7, "\u{3b9}\u{308}\u{342}"), + (0x1FD8, "\u{1fd0}"), + (0x1FD9, "\u{1fd1}"), + (0x1FDA, "\u{1f76}"), + (0x1FDB, "\u{1f77}"), + (0x1FE2, "\u{3c5}\u{308}\u{300}"), + (0x1FE3, "\u{3c5}\u{308}\u{301}"), + (0x1FE4, "\u{3c1}\u{313}"), + (0x1FE6, "\u{3c5}\u{342}"), + (0x1FE7, "\u{3c5}\u{308}\u{342}"), + (0x1FE8, "\u{1fe0}"), + (0x1FE9, "\u{1fe1}"), + (0x1FEA, "\u{1f7a}"), + (0x1FEB, "\u{1f7b}"), + (0x1FEC, "\u{1fe5}"), + (0x1FF2, "\u{1f7c}\u{3b9}"), + (0x1FF3, "\u{3c9}\u{3b9}"), + (0x1FF4, "\u{3ce}\u{3b9}"), + (0x1FF6, "\u{3c9}\u{342}"), + (0x1FF7, "\u{3c9}\u{342}\u{3b9}"), + (0x1FF8, "\u{1f78}"), + (0x1FF9, "\u{1f79}"), + (0x1FFA, "\u{1f7c}"), + (0x1FFB, "\u{1f7d}"), + (0x1FFC, "\u{3c9}\u{3b9}"), + (0x2126, "\u{3c9}"), + (0x212A, "\u{6b}"), + (0x212B, "\u{e5}"), + (0x2132, "\u{214e}"), + (0x2160, "\u{2170}"), + (0x2161, "\u{2171}"), + (0x2162, "\u{2172}"), + (0x2163, "\u{2173}"), + (0x2164, "\u{2174}"), + (0x2165, "\u{2175}"), + (0x2166, "\u{2176}"), + (0x2167, "\u{2177}"), + (0x2168, "\u{2178}"), + (0x2169, "\u{2179}"), + (0x216A, "\u{217a}"), + (0x216B, "\u{217b}"), + (0x216C, "\u{217c}"), + (0x216D, "\u{217d}"), + (0x216E, "\u{217e}"), + (0x216F, "\u{217f}"), + (0x2183, "\u{2184}"), + (0x24B6, "\u{24d0}"), + (0x24B7, "\u{24d1}"), + (0x24B8, "\u{24d2}"), + (0x24B9, "\u{24d3}"), + (0x24BA, "\u{24d4}"), + (0x24BB, "\u{24d5}"), + (0x24BC, "\u{24d6}"), + (0x24BD, "\u{24d7}"), + (0x24BE, "\u{24d8}"), + (0x24BF, "\u{24d9}"), + (0x24C0, "\u{24da}"), + (0x24C1, "\u{24db}"), + (0x24C2, "\u{24dc}"), + (0x24C3, "\u{24dd}"), + (0x24C4, "\u{24de}"), + (0x24C5, "\u{24df}"), + (0x24C6, "\u{24e0}"), + (0x24C7, "\u{24e1}"), + (0x24C8, "\u{24e2}"), + (0x24C9, "\u{24e3}"), + (0x24CA, "\u{24e4}"), + (0x24CB, "\u{24e5}"), + (0x24CC, "\u{24e6}"), + (0x24CD, "\u{24e7}"), + (0x24CE, "\u{24e8}"), + (0x24CF, "\u{24e9}"), + (0x2C00, "\u{2c30}"), + (0x2C01, "\u{2c31}"), + (0x2C02, "\u{2c32}"), + (0x2C03, "\u{2c33}"), + (0x2C04, "\u{2c34}"), + (0x2C05, "\u{2c35}"), + (0x2C06, "\u{2c36}"), + (0x2C07, "\u{2c37}"), + (0x2C08, "\u{2c38}"), + (0x2C09, "\u{2c39}"), + (0x2C0A, "\u{2c3a}"), + (0x2C0B, "\u{2c3b}"), + (0x2C0C, "\u{2c3c}"), + (0x2C0D, "\u{2c3d}"), + (0x2C0E, "\u{2c3e}"), + (0x2C0F, "\u{2c3f}"), + (0x2C10, "\u{2c40}"), + (0x2C11, "\u{2c41}"), + (0x2C12, "\u{2c42}"), + (0x2C13, "\u{2c43}"), + (0x2C14, "\u{2c44}"), + (0x2C15, "\u{2c45}"), + (0x2C16, "\u{2c46}"), + (0x2C17, "\u{2c47}"), + (0x2C18, "\u{2c48}"), + (0x2C19, "\u{2c49}"), + (0x2C1A, "\u{2c4a}"), + (0x2C1B, "\u{2c4b}"), + (0x2C1C, "\u{2c4c}"), + (0x2C1D, "\u{2c4d}"), + (0x2C1E, "\u{2c4e}"), + (0x2C1F, "\u{2c4f}"), + (0x2C20, "\u{2c50}"), + (0x2C21, "\u{2c51}"), + (0x2C22, "\u{2c52}"), + (0x2C23, "\u{2c53}"), + (0x2C24, "\u{2c54}"), + (0x2C25, "\u{2c55}"), + (0x2C26, "\u{2c56}"), + (0x2C27, "\u{2c57}"), + (0x2C28, "\u{2c58}"), + (0x2C29, "\u{2c59}"), + (0x2C2A, "\u{2c5a}"), + (0x2C2B, "\u{2c5b}"), + (0x2C2C, "\u{2c5c}"), + (0x2C2D, "\u{2c5d}"), + (0x2C2E, "\u{2c5e}"), + (0x2C2F, "\u{2c5f}"), + (0x2C60, "\u{2c61}"), + (0x2C62, "\u{26b}"), + (0x2C63, "\u{1d7d}"), + (0x2C64, "\u{27d}"), + (0x2C67, "\u{2c68}"), + (0x2C69, "\u{2c6a}"), + (0x2C6B, "\u{2c6c}"), + (0x2C6D, "\u{251}"), + (0x2C6E, "\u{271}"), + (0x2C6F, "\u{250}"), + (0x2C70, "\u{252}"), + (0x2C72, "\u{2c73}"), + (0x2C75, "\u{2c76}"), + (0x2C7E, "\u{23f}"), + (0x2C7F, "\u{240}"), + (0x2C80, "\u{2c81}"), + (0x2C82, "\u{2c83}"), + (0x2C84, "\u{2c85}"), + (0x2C86, "\u{2c87}"), + (0x2C88, "\u{2c89}"), + (0x2C8A, "\u{2c8b}"), + (0x2C8C, "\u{2c8d}"), + (0x2C8E, "\u{2c8f}"), + (0x2C90, "\u{2c91}"), + (0x2C92, "\u{2c93}"), + (0x2C94, "\u{2c95}"), + (0x2C96, "\u{2c97}"), + (0x2C98, "\u{2c99}"), + (0x2C9A, "\u{2c9b}"), + (0x2C9C, "\u{2c9d}"), + (0x2C9E, "\u{2c9f}"), + (0x2CA0, "\u{2ca1}"), + (0x2CA2, "\u{2ca3}"), + (0x2CA4, "\u{2ca5}"), + (0x2CA6, "\u{2ca7}"), + (0x2CA8, "\u{2ca9}"), + (0x2CAA, "\u{2cab}"), + (0x2CAC, "\u{2cad}"), + (0x2CAE, "\u{2caf}"), + (0x2CB0, "\u{2cb1}"), + (0x2CB2, "\u{2cb3}"), + (0x2CB4, "\u{2cb5}"), + (0x2CB6, "\u{2cb7}"), + (0x2CB8, "\u{2cb9}"), + (0x2CBA, "\u{2cbb}"), + (0x2CBC, "\u{2cbd}"), + (0x2CBE, "\u{2cbf}"), + (0x2CC0, "\u{2cc1}"), + (0x2CC2, "\u{2cc3}"), + (0x2CC4, "\u{2cc5}"), + (0x2CC6, "\u{2cc7}"), + (0x2CC8, "\u{2cc9}"), + (0x2CCA, "\u{2ccb}"), + (0x2CCC, "\u{2ccd}"), + (0x2CCE, "\u{2ccf}"), + (0x2CD0, "\u{2cd1}"), + (0x2CD2, "\u{2cd3}"), + (0x2CD4, "\u{2cd5}"), + (0x2CD6, "\u{2cd7}"), + (0x2CD8, "\u{2cd9}"), + (0x2CDA, "\u{2cdb}"), + (0x2CDC, "\u{2cdd}"), + (0x2CDE, "\u{2cdf}"), + (0x2CE0, "\u{2ce1}"), + (0x2CE2, "\u{2ce3}"), + (0x2CEB, "\u{2cec}"), + (0x2CED, "\u{2cee}"), + (0x2CF2, "\u{2cf3}"), + (0xA640, "\u{a641}"), + (0xA642, "\u{a643}"), + (0xA644, "\u{a645}"), + (0xA646, "\u{a647}"), + (0xA648, "\u{a649}"), + (0xA64A, "\u{a64b}"), + (0xA64C, "\u{a64d}"), + (0xA64E, "\u{a64f}"), + (0xA650, "\u{a651}"), + (0xA652, "\u{a653}"), + (0xA654, "\u{a655}"), + (0xA656, "\u{a657}"), + (0xA658, "\u{a659}"), + (0xA65A, "\u{a65b}"), + (0xA65C, "\u{a65d}"), + (0xA65E, "\u{a65f}"), + (0xA660, "\u{a661}"), + (0xA662, "\u{a663}"), + (0xA664, "\u{a665}"), + (0xA666, "\u{a667}"), + (0xA668, "\u{a669}"), + (0xA66A, "\u{a66b}"), + (0xA66C, "\u{a66d}"), + (0xA680, "\u{a681}"), + (0xA682, "\u{a683}"), + (0xA684, "\u{a685}"), + (0xA686, "\u{a687}"), + (0xA688, "\u{a689}"), + (0xA68A, "\u{a68b}"), + (0xA68C, "\u{a68d}"), + (0xA68E, "\u{a68f}"), + (0xA690, "\u{a691}"), + (0xA692, "\u{a693}"), + (0xA694, "\u{a695}"), + (0xA696, "\u{a697}"), + (0xA698, "\u{a699}"), + (0xA69A, "\u{a69b}"), + (0xA722, "\u{a723}"), + (0xA724, "\u{a725}"), + (0xA726, "\u{a727}"), + (0xA728, "\u{a729}"), + (0xA72A, "\u{a72b}"), + (0xA72C, "\u{a72d}"), + (0xA72E, "\u{a72f}"), + (0xA732, "\u{a733}"), + (0xA734, "\u{a735}"), + (0xA736, "\u{a737}"), + (0xA738, "\u{a739}"), + (0xA73A, "\u{a73b}"), + (0xA73C, "\u{a73d}"), + (0xA73E, "\u{a73f}"), + (0xA740, "\u{a741}"), + (0xA742, "\u{a743}"), + (0xA744, "\u{a745}"), + (0xA746, "\u{a747}"), + (0xA748, "\u{a749}"), + (0xA74A, "\u{a74b}"), + (0xA74C, "\u{a74d}"), + (0xA74E, "\u{a74f}"), + (0xA750, "\u{a751}"), + (0xA752, "\u{a753}"), + (0xA754, "\u{a755}"), + (0xA756, "\u{a757}"), + (0xA758, "\u{a759}"), + (0xA75A, "\u{a75b}"), + (0xA75C, "\u{a75d}"), + (0xA75E, "\u{a75f}"), + (0xA760, "\u{a761}"), + (0xA762, "\u{a763}"), + (0xA764, "\u{a765}"), + (0xA766, "\u{a767}"), + (0xA768, "\u{a769}"), + (0xA76A, "\u{a76b}"), + (0xA76C, "\u{a76d}"), + (0xA76E, "\u{a76f}"), + (0xA779, "\u{a77a}"), + (0xA77B, "\u{a77c}"), + (0xA77D, "\u{1d79}"), + (0xA77E, "\u{a77f}"), + (0xA780, "\u{a781}"), + (0xA782, "\u{a783}"), + (0xA784, "\u{a785}"), + (0xA786, "\u{a787}"), + (0xA78B, "\u{a78c}"), + (0xA78D, "\u{265}"), + (0xA790, "\u{a791}"), + (0xA792, "\u{a793}"), + (0xA796, "\u{a797}"), + (0xA798, "\u{a799}"), + (0xA79A, "\u{a79b}"), + (0xA79C, "\u{a79d}"), + (0xA79E, "\u{a79f}"), + (0xA7A0, "\u{a7a1}"), + (0xA7A2, "\u{a7a3}"), + (0xA7A4, "\u{a7a5}"), + (0xA7A6, "\u{a7a7}"), + (0xA7A8, "\u{a7a9}"), + (0xA7AA, "\u{266}"), + (0xA7AB, "\u{25c}"), + (0xA7AC, "\u{261}"), + (0xA7AD, "\u{26c}"), + (0xA7AE, "\u{26a}"), + (0xA7B0, "\u{29e}"), + (0xA7B1, "\u{287}"), + (0xA7B2, "\u{29d}"), + (0xA7B3, "\u{ab53}"), + (0xA7B4, "\u{a7b5}"), + (0xA7B6, "\u{a7b7}"), + (0xA7B8, "\u{a7b9}"), + (0xA7BA, "\u{a7bb}"), + (0xA7BC, "\u{a7bd}"), + (0xA7BE, "\u{a7bf}"), + (0xA7C0, "\u{a7c1}"), + (0xA7C2, "\u{a7c3}"), + (0xA7C4, "\u{a794}"), + (0xA7C5, "\u{282}"), + (0xA7C6, "\u{1d8e}"), + (0xA7C7, "\u{a7c8}"), + (0xA7C9, "\u{a7ca}"), + (0xA7D0, "\u{a7d1}"), + (0xA7D6, "\u{a7d7}"), + (0xA7D8, "\u{a7d9}"), + (0xA7F5, "\u{a7f6}"), + (0xAB70, "\u{13a0}"), + (0xAB71, "\u{13a1}"), + (0xAB72, "\u{13a2}"), + (0xAB73, "\u{13a3}"), + (0xAB74, "\u{13a4}"), + (0xAB75, "\u{13a5}"), + (0xAB76, "\u{13a6}"), + (0xAB77, "\u{13a7}"), + (0xAB78, "\u{13a8}"), + (0xAB79, "\u{13a9}"), + (0xAB7A, "\u{13aa}"), + (0xAB7B, "\u{13ab}"), + (0xAB7C, "\u{13ac}"), + (0xAB7D, "\u{13ad}"), + (0xAB7E, "\u{13ae}"), + (0xAB7F, "\u{13af}"), + (0xAB80, "\u{13b0}"), + (0xAB81, "\u{13b1}"), + (0xAB82, "\u{13b2}"), + (0xAB83, "\u{13b3}"), + (0xAB84, "\u{13b4}"), + (0xAB85, "\u{13b5}"), + (0xAB86, "\u{13b6}"), + (0xAB87, "\u{13b7}"), + (0xAB88, "\u{13b8}"), + (0xAB89, "\u{13b9}"), + (0xAB8A, "\u{13ba}"), + (0xAB8B, "\u{13bb}"), + (0xAB8C, "\u{13bc}"), + (0xAB8D, "\u{13bd}"), + (0xAB8E, "\u{13be}"), + (0xAB8F, "\u{13bf}"), + (0xAB90, "\u{13c0}"), + (0xAB91, "\u{13c1}"), + (0xAB92, "\u{13c2}"), + (0xAB93, "\u{13c3}"), + (0xAB94, "\u{13c4}"), + (0xAB95, "\u{13c5}"), + (0xAB96, "\u{13c6}"), + (0xAB97, "\u{13c7}"), + (0xAB98, "\u{13c8}"), + (0xAB99, "\u{13c9}"), + (0xAB9A, "\u{13ca}"), + (0xAB9B, "\u{13cb}"), + (0xAB9C, "\u{13cc}"), + (0xAB9D, "\u{13cd}"), + (0xAB9E, "\u{13ce}"), + (0xAB9F, "\u{13cf}"), + (0xABA0, "\u{13d0}"), + (0xABA1, "\u{13d1}"), + (0xABA2, "\u{13d2}"), + (0xABA3, "\u{13d3}"), + (0xABA4, "\u{13d4}"), + (0xABA5, "\u{13d5}"), + (0xABA6, "\u{13d6}"), + (0xABA7, "\u{13d7}"), + (0xABA8, "\u{13d8}"), + (0xABA9, "\u{13d9}"), + (0xABAA, "\u{13da}"), + (0xABAB, "\u{13db}"), + (0xABAC, "\u{13dc}"), + (0xABAD, "\u{13dd}"), + (0xABAE, "\u{13de}"), + (0xABAF, "\u{13df}"), + (0xABB0, "\u{13e0}"), + (0xABB1, "\u{13e1}"), + (0xABB2, "\u{13e2}"), + (0xABB3, "\u{13e3}"), + (0xABB4, "\u{13e4}"), + (0xABB5, "\u{13e5}"), + (0xABB6, "\u{13e6}"), + (0xABB7, "\u{13e7}"), + (0xABB8, "\u{13e8}"), + (0xABB9, "\u{13e9}"), + (0xABBA, "\u{13ea}"), + (0xABBB, "\u{13eb}"), + (0xABBC, "\u{13ec}"), + (0xABBD, "\u{13ed}"), + (0xABBE, "\u{13ee}"), + (0xABBF, "\u{13ef}"), + (0xFB00, "\u{66}\u{66}"), + (0xFB01, "\u{66}\u{69}"), + (0xFB02, "\u{66}\u{6c}"), + (0xFB03, "\u{66}\u{66}\u{69}"), + (0xFB04, "\u{66}\u{66}\u{6c}"), + (0xFB05, "\u{73}\u{74}"), + (0xFB06, "\u{73}\u{74}"), + (0xFB13, "\u{574}\u{576}"), + (0xFB14, "\u{574}\u{565}"), + (0xFB15, "\u{574}\u{56b}"), + (0xFB16, "\u{57e}\u{576}"), + (0xFB17, "\u{574}\u{56d}"), + (0xFF21, "\u{ff41}"), + (0xFF22, "\u{ff42}"), + (0xFF23, "\u{ff43}"), + (0xFF24, "\u{ff44}"), + (0xFF25, "\u{ff45}"), + (0xFF26, "\u{ff46}"), + (0xFF27, "\u{ff47}"), + (0xFF28, "\u{ff48}"), + (0xFF29, "\u{ff49}"), + (0xFF2A, "\u{ff4a}"), + (0xFF2B, "\u{ff4b}"), + (0xFF2C, "\u{ff4c}"), + (0xFF2D, "\u{ff4d}"), + (0xFF2E, "\u{ff4e}"), + (0xFF2F, "\u{ff4f}"), + (0xFF30, "\u{ff50}"), + (0xFF31, "\u{ff51}"), + (0xFF32, "\u{ff52}"), + (0xFF33, "\u{ff53}"), + (0xFF34, "\u{ff54}"), + (0xFF35, "\u{ff55}"), + (0xFF36, "\u{ff56}"), + (0xFF37, "\u{ff57}"), + (0xFF38, "\u{ff58}"), + (0xFF39, "\u{ff59}"), + (0xFF3A, "\u{ff5a}"), + (0x10400, "\u{10428}"), + (0x10401, "\u{10429}"), + (0x10402, "\u{1042a}"), + (0x10403, "\u{1042b}"), + (0x10404, "\u{1042c}"), + (0x10405, "\u{1042d}"), + (0x10406, "\u{1042e}"), + (0x10407, "\u{1042f}"), + (0x10408, "\u{10430}"), + (0x10409, "\u{10431}"), + (0x1040A, "\u{10432}"), + (0x1040B, "\u{10433}"), + (0x1040C, "\u{10434}"), + (0x1040D, "\u{10435}"), + (0x1040E, "\u{10436}"), + (0x1040F, "\u{10437}"), + (0x10410, "\u{10438}"), + (0x10411, "\u{10439}"), + (0x10412, "\u{1043a}"), + (0x10413, "\u{1043b}"), + (0x10414, "\u{1043c}"), + (0x10415, "\u{1043d}"), + (0x10416, "\u{1043e}"), + (0x10417, "\u{1043f}"), + (0x10418, "\u{10440}"), + (0x10419, "\u{10441}"), + (0x1041A, "\u{10442}"), + (0x1041B, "\u{10443}"), + (0x1041C, "\u{10444}"), + (0x1041D, "\u{10445}"), + (0x1041E, "\u{10446}"), + (0x1041F, "\u{10447}"), + (0x10420, "\u{10448}"), + (0x10421, "\u{10449}"), + (0x10422, "\u{1044a}"), + (0x10423, "\u{1044b}"), + (0x10424, "\u{1044c}"), + (0x10425, "\u{1044d}"), + (0x10426, "\u{1044e}"), + (0x10427, "\u{1044f}"), + (0x104B0, "\u{104d8}"), + (0x104B1, "\u{104d9}"), + (0x104B2, "\u{104da}"), + (0x104B3, "\u{104db}"), + (0x104B4, "\u{104dc}"), + (0x104B5, "\u{104dd}"), + (0x104B6, "\u{104de}"), + (0x104B7, "\u{104df}"), + (0x104B8, "\u{104e0}"), + (0x104B9, "\u{104e1}"), + (0x104BA, "\u{104e2}"), + (0x104BB, "\u{104e3}"), + (0x104BC, "\u{104e4}"), + (0x104BD, "\u{104e5}"), + (0x104BE, "\u{104e6}"), + (0x104BF, "\u{104e7}"), + (0x104C0, "\u{104e8}"), + (0x104C1, "\u{104e9}"), + (0x104C2, "\u{104ea}"), + (0x104C3, "\u{104eb}"), + (0x104C4, "\u{104ec}"), + (0x104C5, "\u{104ed}"), + (0x104C6, "\u{104ee}"), + (0x104C7, "\u{104ef}"), + (0x104C8, "\u{104f0}"), + (0x104C9, "\u{104f1}"), + (0x104CA, "\u{104f2}"), + (0x104CB, "\u{104f3}"), + (0x104CC, "\u{104f4}"), + (0x104CD, "\u{104f5}"), + (0x104CE, "\u{104f6}"), + (0x104CF, "\u{104f7}"), + (0x104D0, "\u{104f8}"), + (0x104D1, "\u{104f9}"), + (0x104D2, "\u{104fa}"), + (0x104D3, "\u{104fb}"), + (0x10570, "\u{10597}"), + (0x10571, "\u{10598}"), + (0x10572, "\u{10599}"), + (0x10573, "\u{1059a}"), + (0x10574, "\u{1059b}"), + (0x10575, "\u{1059c}"), + (0x10576, "\u{1059d}"), + (0x10577, "\u{1059e}"), + (0x10578, "\u{1059f}"), + (0x10579, "\u{105a0}"), + (0x1057A, "\u{105a1}"), + (0x1057C, "\u{105a3}"), + (0x1057D, "\u{105a4}"), + (0x1057E, "\u{105a5}"), + (0x1057F, "\u{105a6}"), + (0x10580, "\u{105a7}"), + (0x10581, "\u{105a8}"), + (0x10582, "\u{105a9}"), + (0x10583, "\u{105aa}"), + (0x10584, "\u{105ab}"), + (0x10585, "\u{105ac}"), + (0x10586, "\u{105ad}"), + (0x10587, "\u{105ae}"), + (0x10588, "\u{105af}"), + (0x10589, "\u{105b0}"), + (0x1058A, "\u{105b1}"), + (0x1058C, "\u{105b3}"), + (0x1058D, "\u{105b4}"), + (0x1058E, "\u{105b5}"), + (0x1058F, "\u{105b6}"), + (0x10590, "\u{105b7}"), + (0x10591, "\u{105b8}"), + (0x10592, "\u{105b9}"), + (0x10594, "\u{105bb}"), + (0x10595, "\u{105bc}"), + (0x10C80, "\u{10cc0}"), + (0x10C81, "\u{10cc1}"), + (0x10C82, "\u{10cc2}"), + (0x10C83, "\u{10cc3}"), + (0x10C84, "\u{10cc4}"), + (0x10C85, "\u{10cc5}"), + (0x10C86, "\u{10cc6}"), + (0x10C87, "\u{10cc7}"), + (0x10C88, "\u{10cc8}"), + (0x10C89, "\u{10cc9}"), + (0x10C8A, "\u{10cca}"), + (0x10C8B, "\u{10ccb}"), + (0x10C8C, "\u{10ccc}"), + (0x10C8D, "\u{10ccd}"), + (0x10C8E, "\u{10cce}"), + (0x10C8F, "\u{10ccf}"), + (0x10C90, "\u{10cd0}"), + (0x10C91, "\u{10cd1}"), + (0x10C92, "\u{10cd2}"), + (0x10C93, "\u{10cd3}"), + (0x10C94, "\u{10cd4}"), + (0x10C95, "\u{10cd5}"), + (0x10C96, "\u{10cd6}"), + (0x10C97, "\u{10cd7}"), + (0x10C98, "\u{10cd8}"), + (0x10C99, "\u{10cd9}"), + (0x10C9A, "\u{10cda}"), + (0x10C9B, "\u{10cdb}"), + (0x10C9C, "\u{10cdc}"), + (0x10C9D, "\u{10cdd}"), + (0x10C9E, "\u{10cde}"), + (0x10C9F, "\u{10cdf}"), + (0x10CA0, "\u{10ce0}"), + (0x10CA1, "\u{10ce1}"), + (0x10CA2, "\u{10ce2}"), + (0x10CA3, "\u{10ce3}"), + (0x10CA4, "\u{10ce4}"), + (0x10CA5, "\u{10ce5}"), + (0x10CA6, "\u{10ce6}"), + (0x10CA7, "\u{10ce7}"), + (0x10CA8, "\u{10ce8}"), + (0x10CA9, "\u{10ce9}"), + (0x10CAA, "\u{10cea}"), + (0x10CAB, "\u{10ceb}"), + (0x10CAC, "\u{10cec}"), + (0x10CAD, "\u{10ced}"), + (0x10CAE, "\u{10cee}"), + (0x10CAF, "\u{10cef}"), + (0x10CB0, "\u{10cf0}"), + (0x10CB1, "\u{10cf1}"), + (0x10CB2, "\u{10cf2}"), + (0x118A0, "\u{118c0}"), + (0x118A1, "\u{118c1}"), + (0x118A2, "\u{118c2}"), + (0x118A3, "\u{118c3}"), + (0x118A4, "\u{118c4}"), + (0x118A5, "\u{118c5}"), + (0x118A6, "\u{118c6}"), + (0x118A7, "\u{118c7}"), + (0x118A8, "\u{118c8}"), + (0x118A9, "\u{118c9}"), + (0x118AA, "\u{118ca}"), + (0x118AB, "\u{118cb}"), + (0x118AC, "\u{118cc}"), + (0x118AD, "\u{118cd}"), + (0x118AE, "\u{118ce}"), + (0x118AF, "\u{118cf}"), + (0x118B0, "\u{118d0}"), + (0x118B1, "\u{118d1}"), + (0x118B2, "\u{118d2}"), + (0x118B3, "\u{118d3}"), + (0x118B4, "\u{118d4}"), + (0x118B5, "\u{118d5}"), + (0x118B6, "\u{118d6}"), + (0x118B7, "\u{118d7}"), + (0x118B8, "\u{118d8}"), + (0x118B9, "\u{118d9}"), + (0x118BA, "\u{118da}"), + (0x118BB, "\u{118db}"), + (0x118BC, "\u{118dc}"), + (0x118BD, "\u{118dd}"), + (0x118BE, "\u{118de}"), + (0x118BF, "\u{118df}"), + (0x16E40, "\u{16e60}"), + (0x16E41, "\u{16e61}"), + (0x16E42, "\u{16e62}"), + (0x16E43, "\u{16e63}"), + (0x16E44, "\u{16e64}"), + (0x16E45, "\u{16e65}"), + (0x16E46, "\u{16e66}"), + (0x16E47, "\u{16e67}"), + (0x16E48, "\u{16e68}"), + (0x16E49, "\u{16e69}"), + (0x16E4A, "\u{16e6a}"), + (0x16E4B, "\u{16e6b}"), + (0x16E4C, "\u{16e6c}"), + (0x16E4D, "\u{16e6d}"), + (0x16E4E, "\u{16e6e}"), + (0x16E4F, "\u{16e6f}"), + (0x16E50, "\u{16e70}"), + (0x16E51, "\u{16e71}"), + (0x16E52, "\u{16e72}"), + (0x16E53, "\u{16e73}"), + (0x16E54, "\u{16e74}"), + (0x16E55, "\u{16e75}"), + (0x16E56, "\u{16e76}"), + (0x16E57, "\u{16e77}"), + (0x16E58, "\u{16e78}"), + (0x16E59, "\u{16e79}"), + (0x16E5A, "\u{16e7a}"), + (0x16E5B, "\u{16e7b}"), + (0x16E5C, "\u{16e7c}"), + (0x16E5D, "\u{16e7d}"), + (0x16E5E, "\u{16e7e}"), + (0x16E5F, "\u{16e7f}"), + (0x1E900, "\u{1e922}"), + (0x1E901, "\u{1e923}"), + (0x1E902, "\u{1e924}"), + (0x1E903, "\u{1e925}"), + (0x1E904, "\u{1e926}"), + (0x1E905, "\u{1e927}"), + (0x1E906, "\u{1e928}"), + (0x1E907, "\u{1e929}"), + (0x1E908, "\u{1e92a}"), + (0x1E909, "\u{1e92b}"), + (0x1E90A, "\u{1e92c}"), + (0x1E90B, "\u{1e92d}"), + (0x1E90C, "\u{1e92e}"), + (0x1E90D, "\u{1e92f}"), + (0x1E90E, "\u{1e930}"), + (0x1E90F, "\u{1e931}"), + (0x1E910, "\u{1e932}"), + (0x1E911, "\u{1e933}"), + (0x1E912, "\u{1e934}"), + (0x1E913, "\u{1e935}"), + (0x1E914, "\u{1e936}"), + (0x1E915, "\u{1e937}"), + (0x1E916, "\u{1e938}"), + (0x1E917, "\u{1e939}"), + (0x1E918, "\u{1e93a}"), + (0x1E919, "\u{1e93b}"), + (0x1E91A, "\u{1e93c}"), + (0x1E91B, "\u{1e93d}"), + (0x1E91C, "\u{1e93e}"), + (0x1E91D, "\u{1e93f}"), + (0x1E91E, "\u{1e940}"), + (0x1E91F, "\u{1e941}"), + (0x1E920, "\u{1e942}"), + (0x1E921, "\u{1e943}"), +]; + +/// Fold a string under Unicode full case folding, matching the service's +/// resolution of condition key names and tag keys. ASCII letters fold to +/// ASCII lowercase; a non-ASCII code point folds per [`CASE_FOLD`] or to +/// itself. +#[must_use] +pub fn case_fold(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + let cp = c as u32; + match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) { + Ok(i) => out.push_str(CASE_FOLD[i].1), + Err(_) => out.push(c), + } + } + out +} + +/// Whether two strings are equal under Unicode full case folding. +#[must_use] +pub fn eq_fold(a: &str, b: &str) -> bool { + case_fold(a) == case_fold(b) +} diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 46d049b32..1d6230b03 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -46,34 +46,36 @@ pub trait ConditionContext { } /// Look up a tag map by key, case-insensitively on the key. -/// /// AWS resolves tag condition keys (`aws:PrincipalTag/`, `aws:RequestTag/`, /// `aws:ResourceTag/`, `dynamodb:ResourceTag/`) without regard to the case -/// of ``; a tag stored as `Team` is matched by a policy naming `team`. Tag -/// values stay case-sensitive. IAM forbids two tag keys that differ only in -/// case, so a case-insensitive match is unambiguous. -fn ci_tag_lookup<'a>(map: &'a HashMap, tag_key: &str) -> Option> { +/// of ``, under Unicode full case folding; a tag stored as `Team` is matched +/// by a policy naming `team`, and `cla\u{00df}` matches `class`. Tag values stay +/// case-sensitive. IAM forbids two tag keys that differ only in case, so a +/// case-insensitive match is unambiguous. +/// +/// `folded_tag_key` is already case-folded by the caller; each stored key is +/// folded before comparison. +fn ci_tag_lookup<'a>( + map: &'a HashMap, + folded_tag_key: &str, +) -> Option> { map.iter() - .find(|(k, _)| k.eq_ignore_ascii_case(tag_key)) + .find(|(k, _)| super::casefold::case_fold(k) == folded_tag_key) .map(|(_, v)| vec![v.as_str()]) } -/// Strip `prefix` from the front of `s` case-insensitively, returning the -/// remainder (the tag sub-key) when it matches. +/// Strip `prefix` from the front of an already case-folded key, returning the +/// remainder (the folded tag sub-key) when it matches. /// /// The live service resolves a condition key name without regard to case, /// including the tag prefix (`aws:PrincipalTag/`, `aws:RequestTag/`, -/// `aws:ResourceTag/`, `dynamodb:ResourceTag/`), so `aws:principaltag/team` and -/// `aws:PrincipalTag/team` resolve the same tag. The remainder is returned with -/// its original case; `ci_tag_lookup` then matches the tag sub-key -/// case-insensitively. -fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { - let head = s.get(..prefix.len())?; - if head.eq_ignore_ascii_case(prefix) { - s.get(prefix.len()..) - } else { - None - } +/// `aws:ResourceTag/`, `dynamodb:ResourceTag/`), under Unicode full case +/// folding. The caller folds the whole key first, so `prefix` is given in its +/// folded (lower-case ASCII) form and a plain prefix match suffices. The +/// returned sub-key is the folded form, which `ci_tag_lookup` compares against +/// each folded stored key. +fn strip_folded_prefix<'a>(folded_key: &'a str, folded_prefix: &str) -> Option<&'a str> { + folded_key.strip_prefix(folded_prefix) } /// Documented AWS global condition keys the live service populates for a @@ -107,9 +109,10 @@ const FAIL_CLOSED_GLOBAL_KEYS: &[&str] = &[]; /// matches, which is what the live service does for a key it does not populate. #[must_use] pub fn aws_key_is_unresolvable(key: &str) -> bool { + let folded = super::casefold::case_fold(key); FAIL_CLOSED_GLOBAL_KEYS .iter() - .any(|k| k.eq_ignore_ascii_case(key)) + .any(|k| super::casefold::case_fold(k) == folded) } /// The request time as the pair (`aws:CurrentTime` as RFC 3339 UTC, @@ -282,22 +285,24 @@ impl RequestContext { impl ConditionContext for RequestContext { fn resolve_key(&self, key: &str) -> Option> { - // Condition key names are resolved case-insensitively, matching the - // live service: `aws:securetransport` and `AWS:SecureTransport` resolve - // the same as `aws:SecureTransport`, and `aws:principaltag/team` the - // same as `aws:PrincipalTag/team`. The tag prefix is matched - // case-insensitively here; the tag sub-key is folded by `ci_tag_lookup`. - if let Some(tag_key) = strip_prefix_ci(key, "aws:PrincipalTag/") { + // Condition key names are resolved under Unicode full case folding, + // matching the live service: `aws:securetransport`, `AWS:SecureTransport` + // and `aws:SecureTran\u{017f}port` resolve the same, and + // `aws:principaltag/team` the same as `aws:PrincipalTag/team`. Fold the + // whole key once; the tag prefix is matched in its folded form and the + // folded sub-key is compared against each folded stored tag key. + let folded = super::casefold::case_fold(key); + if let Some(tag_key) = strip_folded_prefix(&folded, "aws:principaltag/") { ci_tag_lookup(&self.principal_tags, tag_key) - } else if let Some(tag_key) = strip_prefix_ci(key, "aws:ResourceTag/") { + } else if let Some(tag_key) = strip_folded_prefix(&folded, "aws:resourcetag/") { // The service evaluates resource tags as aws:ResourceTag/*. ExtendDB // resolves them from the same resource-tag map it uses for - // dynamodb:ResourceTag/*, case-insensitively on the key. + // dynamodb:ResourceTag/*, case-folded on the key. ci_tag_lookup(&self.resource_tags, tag_key) - } else if let Some(tag_key) = strip_prefix_ci(key, "dynamodb:ResourceTag/") { + } else if let Some(tag_key) = strip_folded_prefix(&folded, "dynamodb:resourcetag/") { ci_tag_lookup(&self.resource_tags, tag_key) } else { - match key.to_ascii_lowercase().as_str() { + match folded.as_str() { "dynamodb:leadingkeys" => self .leading_keys .as_ref() @@ -353,10 +358,10 @@ impl ConditionContext for RequestContext { // 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 - // matched case-insensitively, matching the service. - let lower = key.to_ascii_lowercase(); + // matched under Unicode full case folding, matching the service. + let folded = super::casefold::case_fold(key); matches!( - lower.as_str(), + folded.as_str(), "dynamodb:leadingkeys" | "dynamodb:attributes" ) } @@ -428,14 +433,15 @@ pub struct AssumeRoleContext { impl ConditionContext for AssumeRoleContext { fn resolve_key(&self, key: &str) -> Option> { - // Condition key names are resolved case-insensitively, matching the - // live service, including the tag prefixes. - if let Some(tag_key) = strip_prefix_ci(key, "aws:PrincipalTag/") { + // Condition key names are resolved under Unicode full case folding, + // matching the live service, including the tag prefixes. + let folded = super::casefold::case_fold(key); + if let Some(tag_key) = strip_folded_prefix(&folded, "aws:principaltag/") { ci_tag_lookup(&self.principal_tags, tag_key) - } else if let Some(tag_key) = strip_prefix_ci(key, "aws:RequestTag/") { + } else if let Some(tag_key) = strip_folded_prefix(&folded, "aws:requesttag/") { ci_tag_lookup(&self.request_tags, tag_key) } else { - match key.to_ascii_lowercase().as_str() { + match folded.as_str() { "sts:externalid" => self.external_id.as_deref().map(|v| vec![v]), "aws:principalarn" => Some(vec![self.principal_arn.as_str()]), "aws:principalaccount" => self.principal_account.as_deref().map(|v| vec![v]), @@ -471,8 +477,8 @@ impl ConditionContext for AssumeRoleContext { // `aws:TagKeys` is the set of session-tag keys (`ArrayOfString`); every // other trust-policy key (`aws:PrincipalTag/*`, `aws:RequestTag/*`, // `sts:ExternalId`, `aws:PrincipalArn`, `aws:PrincipalAccount`) is - // single-valued. Matched case-insensitively, like the service. - key.eq_ignore_ascii_case("aws:TagKeys") + // single-valued. Matched under Unicode full case folding, like the service. + super::casefold::case_fold(key) == "aws:tagkeys" } fn is_unresolvable_key(&self, key: &str) -> bool { @@ -717,6 +723,72 @@ mod tests { ); } + #[test] + fn request_context_key_name_unicode_case_folds() { + // The service resolves a key name under Unicode full case folding. + // Long s and Kelvin and the st ligature and sharp s fold to their ASCII + // twins; the dotless and dotted i do NOT fold to i, matching the service + // (measured). + let ctx = RequestContext::build( + HashMap::from([("class".to_owned(), "x".to_owned())]), + HashMap::new(), + false, + RequestParams { + secure_transport: true, + principal_type: Some("User".to_owned()), + region: Some("us-east-1".to_owned()), + leading_keys: Some(vec!["k1".to_owned()]), + ..Default::default() + }, + ); + // long s (U+017F) in aws:SecureTran[s]port. + assert_eq!( + ctx.resolve_key("aws:SecureTran\u{017f}port"), + Some(vec!["true"]) + ); + // st ligature (U+FB05) in aws:Reque[st]edRegion. + assert_eq!( + ctx.resolve_key("aws:Reque\u{fb05}edRegion"), + Some(vec!["us-east-1"]) + ); + // Kelvin sign (U+212A) in dynamodb:Leading[k]eys. + assert!(ctx.is_multivalued_key("dynamodb:Leading\u{212a}eys")); + assert_eq!( + ctx.resolve_key("dynamodb:Leading\u{212a}eys"), + Some(vec!["k1"]) + ); + // sharp s (U+00DF) folds to ss in a tag key: cla[ß] matches stored class. + assert_eq!( + ctx.resolve_key("aws:PrincipalTag/cla\u{00df}"), + Some(vec!["x"]) + ); + // dotless i (U+0131) does NOT fold to i: the key reads absent. + assert_eq!(ctx.resolve_key("aws:Pr\u{0131}ncipalType"), None); + // dotted I (U+0130) does NOT fold to i either. + assert_eq!(ctx.resolve_key("aws:Pr\u{0130}ncipalType"), None); + } + + #[test] + fn assume_role_context_key_name_unicode_case_folds() { + let ctx = ar_ctx( + HashMap::from([("class".to_owned(), "x".to_owned())]), + Some("ext-123".to_owned()), + HashMap::new(), + ); + // sts:External[s->ſ]... long s in externalId: sts:External[ı]d would NOT + // fold; use the st-ligature-free long s in a resolvable spot. + assert_eq!( + ctx.resolve_key("sts:External\u{0131}d"), + None, + "dotless i must not fold to i" + ); + // Tag key folds: cla[ß] -> class. + assert_eq!( + ctx.resolve_key("aws:PrincipalTag/cla\u{00df}"), + Some(vec!["x"]) + ); + } + #[test] fn assume_role_context_key_name_is_case_insensitive() { let ctx = ar_ctx( diff --git a/crates/auth/src/policy/mod.rs b/crates/auth/src/policy/mod.rs index d7ee3740f..2eac6a912 100755 --- a/crates/auth/src/policy/mod.rs +++ b/crates/auth/src/policy/mod.rs @@ -7,6 +7,7 @@ //! permissions boundary → session policy → identity allow → implicit deny. //! Supports IBAC, RBAC, and ABAC/FGAC patterns through a unified evaluator. +pub mod casefold; pub mod condition; pub mod context; pub mod document; diff --git a/tests/test_abac.py b/tests/test_abac.py index da5f12570..77f168b1c 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -798,6 +798,46 @@ def test_principaltype_deny_lowercase_key_blocks(self, table): {"StringEquals": {"aws:principaltype": "User"}})) == "DENY" +class TestConditionKeyNameUnicodeFold(_TaggedTableBase): + """Condition key NAMES and tag keys resolve under Unicode full case + folding on the identity path, matching the live service. A key spelled with + the long s (U+017F) or the Kelvin sign (U+212A) folds to its ASCII twin; a + case-sensitive or ASCII-only fold would read it as absent and flip the + outcome.""" + + LS = "\u017f" # long s -> s + KV = "\u212a" # Kelvin -> k + + def test_longs_securetransport_resolves(self, table): + # aws:SecureTran[s]port folds to securetransport=true; the positive gate + # grants. + assert self._get(table, self._allow_get_if( + {"Bool": {f"aws:SecureTran{self.LS}port": "true"}})) == "ALLOW" + + def test_longs_securetransport_negated_allow_does_not_overgrant(self, table): + # Allow if StringNotEquals aws:SecureTran[s]port=true. The key folds and + # resolves to true, so NotEquals is false and the Allow does not grant. + # ASCII-only folding would read the key as absent, the negated-absent + # rule would fire, and the request would be granted (the bypass). + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {f"aws:SecureTran{self.LS}port": "true"}})) == "DENY" + + def test_kelvin_leadingkeys_match_grants(self, table): + # dynamodb:Leading[k]eys folds to leadingkeys=[k1]; ForAllValues against + # [k1] holds, the Allow grants. + assert self._get(table, self._allow_get_if( + {"ForAllValues:StringEquals": {f"dynamodb:Leading{self.KV}eys": ["k1"]}})) == "ALLOW" + + def test_kelvin_leadingkeys_nonmatch_does_not_overgrant(self, table): + # Allow if ForAllValues:StringEquals dynamodb:Leading[k]eys=[zzz] on + # GetItem pk=k1. The key folds to leadingkeys=[k1], which is not zzz, so + # ForAllValues fails and the Allow does not grant. ASCII-only folding + # would read the key absent, ForAllValues would be vacuously true, and + # the request would be granted (the bypass). + assert self._get(table, self._allow_get_if( + {"ForAllValues:StringEquals": {f"dynamodb:Leading{self.KV}eys": ["zzz"]}})) == "DENY" + + # --------------------------------------------------------------------------- # aws:TokenIssueTime (role sessions) and aws:userid across recreate # --------------------------------------------------------------------------- diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 08fb4c133..a0bb50a1f 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1276,6 +1276,18 @@ def test_keyname_case_insensitive_externalid_lowercase(self): # -- Resolved request-wide keys on the trust path (T8) ------------------ + def test_keyname_unicode_longs_securetransport_resolves(self): + # aws:SecureTran[long-s]port folds to securetransport=true under + # Unicode case folding, so the positive gate grants. ASCII-only folding + # would read the key absent and the Allow would not grant. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"Bool": {"aws:SecureTran\u017fport": "true"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + def test_tls_guard_does_not_block_over_tls(self): # Allow, plus Deny if Bool aws:SecureTransport=false. The call is over # TLS so SecureTransport is true, the deny does not fire, assumption is From a4196fd27fd0c597d75f4a36f0b579b4d84ef329 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 06:32:10 +0000 Subject: [PATCH 38/58] fix(auth): resolve sts:RoleSessionName on the trust path STS populates sts:RoleSessionName with the session name on every AssumeRole call. AssumeRoleContext did not resolve it, so a trust-policy Deny gated on sts:RoleSessionName was dropped (the service denies, ExtendDB granted) and an Allow gated on it never granted. Measured live: a Deny on a matching session name denies on STS and an Allow on a matching session name grants, both against ExtendDB only after this change. Add a session_name field to AssumeRoleContext, resolve aws/sts key sts:rolesessionname from the AssumeRole call's session name, and thread it through evaluate_trust_policy. Other sts: keys for AssumeRole that depend on a parameter ExtendDB's endpoint does not accept (sts:SourceIdentity, sts:TransitiveTagKeys) stay absent, which is what the service does when the caller omits the parameter. --- crates/auth/src/policy/context.rs | 5 +++ crates/server/src/management/assume_role.rs | 35 ++++++++++++++++++++ tests/test_auth_integration.py | 36 +++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 1d6230b03..87a008011 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -394,6 +394,9 @@ pub struct AssumeRoleContext { pub principal_tags: HashMap, /// The external ID provided in the `AssumeRole` call (if any). pub external_id: Option, + /// The session name provided on the `AssumeRole` call + /// (`sts:RoleSessionName`). STS populates this for every `AssumeRole`. + pub session_name: Option, /// The caller ARN (`aws:PrincipalArn`). pub principal_arn: String, /// The caller's account id (`aws:PrincipalAccount`), the account field of @@ -443,6 +446,7 @@ impl ConditionContext for AssumeRoleContext { } else { match folded.as_str() { "sts:externalid" => self.external_id.as_deref().map(|v| vec![v]), + "sts:rolesessionname" => self.session_name.as_deref().map(|v| vec![v]), "aws:principalarn" => Some(vec![self.principal_arn.as_str()]), "aws:principalaccount" => self.principal_account.as_deref().map(|v| vec![v]), "aws:requestedregion" => self.region.as_deref().map(|v| vec![v]), @@ -506,6 +510,7 @@ mod tests { AssumeRoleContext { principal_tags, external_id, + session_name: Some("probe".to_owned()), principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), principal_account: Some("123456789012".to_owned()), request_tags, diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index 5b3575db5..b910c5a36 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -163,6 +163,7 @@ pub async fn assume_role( &trust_policy, caller_arn, request.external_id.as_deref(), + &request.session_name, has_session_tags, request_tags, &state.region, @@ -315,6 +316,7 @@ async fn evaluate_trust_policy( trust_policy: &Value, caller_arn: &str, external_id: Option<&str>, + session_name: &str, has_session_tags: bool, request_tags: std::collections::HashMap, region: &str, @@ -334,6 +336,9 @@ async fn evaluate_trust_policy( let context = AssumeRoleContext { principal_tags, external_id: external_id.map(String::from), + // sts:RoleSessionName is the session name on the AssumeRole call; STS + // populates it for every call. + session_name: Some(session_name.to_owned()), principal_arn: caller_arn.to_owned(), principal_account: caller_account(caller_arn).map(ToOwned::to_owned), request_tags, @@ -951,6 +956,7 @@ mod tests { AssumeRoleContext { principal_tags, external_id, + session_name: Some("bb-session".to_owned()), principal_arn: CALLER.to_owned(), principal_account: Some(ACCT.to_owned()), request_tags, @@ -1292,6 +1298,35 @@ mod tests { assert!(trust_decision(&stmts, CALLER, &ctx, false)); } + #[test] + fn trust_rolesessionname_resolves_from_session_name() { + // mk_ctx sets session_name = "bb-session". A Deny gated on the matching + // session name blocks; a non-matching name does not. An Allow gated on + // the matching name grants. + let ctx = ctx(); + let deny_match = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"sts:RoleSessionName": "bb-session"}}}), + ]; + assert!(!trust_decision(&deny_match, CALLER, &ctx, false)); + let deny_other = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"sts:RoleSessionName": "other"}}}), + ]; + assert!(trust_decision(&deny_other, CALLER, &ctx, false)); + let allow_gate = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"sts:RoleSessionName": "bb-session"}}})]; + assert!(trust_decision(&allow_gate, CALLER, &ctx, false)); + // Case-folded key name resolves too. + let allow_folded = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"sts:rolesessionname": "bb-session"}}})]; + assert!(trust_decision(&allow_folded, CALLER, &ctx, false)); + } + // ---- resolved keys: PrincipalArn / PrincipalAccount ------------------- #[test] diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index a0bb50a1f..9ee1b30a1 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1288,6 +1288,42 @@ def test_keyname_unicode_longs_securetransport_resolves(self): finally: self.mgmt.delete_user(self.account_id, user) + def test_rolesessionname_deny_blocks_matching_session(self): + # _assume uses session name "bb-session". A Deny gated on + # sts:RoleSessionName=bb-session must block. Before the fix the key was + # absent and the Deny was dropped (assume granted). + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"sts:RoleSessionName": "bb-session"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_rolesessionname_deny_other_does_not_block(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"sts:RoleSessionName": "other-name"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_rolesessionname_allow_gate_grants_matching_session(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"sts:RoleSessionName": "bb-session"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + def test_tls_guard_does_not_block_over_tls(self): # Allow, plus Deny if Bool aws:SecureTransport=false. The call is over # TLS so SecureTransport is true, the deny does not fire, assumption is From eb19695dc493932c28a8710a19228dfeca533044 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 06:33:04 +0000 Subject: [PATCH 39/58] test(auth): condition operator names are case sensitive, matching the service Measured live: the service treats a condition operator name, a set qualifier (ForAllValues/ForAnyValue) and the IfExists suffix as case sensitive and rejects a miscased one (stringequals, bool, forallvalues:StringEquals, StringEqualsifexists) with MalformedPolicyDocument at both PutUserPolicy and UpdateAssumeRolePolicy. ExtendDB already rejects these at write on both paths: the identity-policy parser and the trust-policy validator match operator names exactly. Add tests asserting the rejection on both paths so the matched behaviour is locked in; no behaviour change. --- crates/auth/src/policy/document.rs | 46 ++++++++++++++++++++- crates/server/src/management/assume_role.rs | 18 ++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index 963997449..cfa2ecf4c 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -633,4 +633,48 @@ mod tests { "Condition":{"IpAddress":{"aws:SourceIp":["10.0.0.0/8","2001:db8::/32"]}}}]}"#; assert!(PolicyDocument::from_json(json).is_ok()); } -} \ No newline at end of file + + #[test] + fn reject_miscased_operator_names() { + // The live service treats operator names, set qualifiers and the + // IfExists suffix as case-sensitive and rejects a miscased one with + // MalformedPolicyDocument (measured). ExtendDB rejects the same + // at parse (identity-policy write and read). + for op in [ + "stringequals", + "STRINGEQUALS", + "bool", + "forallvalues:StringEquals", + "ForAllValues:stringequals", + "StringEqualsifexists", + "StringEqualsIfexists", + ] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"{op}":{{"aws:PrincipalType":"User"}}}}}}]}}"# + ); + assert!( + PolicyDocument::from_json(&json).is_err(), + "miscased operator {op} must be rejected" + ); + } + // Canonical operators, qualifier and suffix are accepted. + for op in [ + "StringEquals", + "Bool", + "ForAllValues:StringEquals", + "StringEqualsIfExists", + ] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"{op}":{{"aws:PrincipalType":"User"}}}}}}]}}"# + ); + assert!( + PolicyDocument::from_json(&json).is_ok(), + "canonical operator {op} must be accepted" + ); + } + } +} diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index b910c5a36..3af5832f8 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -1708,6 +1708,24 @@ mod tests { ); } + #[test] + fn validate_rejects_miscased_condition_operators() { + // The live service treats operator names, set qualifiers and the + // IfExists suffix as case-sensitive and rejects a miscased one at the + // trust write (measured); ExtendDB matches. + for op in [ + "stringequals", + "bool", + "forallvalues:StringEquals", + "ForAllValues:stringequals", + "StringEqualsifexists", + ] { + rejected(json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {op: {"aws:PrincipalType": "User"}}}]})); + } + } + #[test] fn validate_rejects_condition_not_object() { rejected( From 68dae5afc4f9f5cbd30eec6ce7fe03a21b6789dd Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 06:35:55 +0000 Subject: [PATCH 40/58] docs: upgrade note detail for a stored malformed-CIDR identity policy Spell out what a stored malformed-CIDR identity policy does after upgrade: the failure is whole-document and scoped to the owning principal, so every request by that principal is denied until the policy is corrected, including a request a different valid policy of the same principal would allow. The caller receives AccessDeniedException with the message 'Not authorized to perform this action (policy evaluation error)', not a server error, and a different principal with well-formed policies is unaffected. --- docs/manuals/07-upgrade-manual.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index f412445d1..8bcbc8832 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -283,6 +283,16 @@ request is denied rather than the statement being ignored. Audit stored policies `extenddb manage list-user-policies` / `list-role-policies` and correct any malformed CIDR before upgrading so a legitimate request is not denied. +**What a stored malformed-CIDR policy does after upgrade.** The failure is +whole-document and scoped to the principal that owns the policy. Every request by +that principal is denied until the policy is corrected, including a request that a +different, valid policy of the same principal would allow (the malformed document +fails the request closed before the valid policy can grant). The caller receives +`AccessDeniedException` with the message "Not authorized to perform this action +(policy evaluation error)", not a server error: a parse failure on the authorization +path is mapped to a deny, not to `InternalServerError`. A different principal whose +policies are all well formed is unaffected. + **Trust policies are unchanged.** The live service accepts a malformed CIDR in a trust policy at write and drops the statement at evaluation; ExtendDB matches this, so a trust policy is not rejected for a malformed CIDR. No catalog migration is required. From 9298378b538a4bd3bd84d510c793cf2b517e6351 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 06:36:13 +0000 Subject: [PATCH 41/58] docs: note TokenIssueTime and MultiFactorAuthPresent are request-path only Both keys resolve on the DynamoDB request path for a role session; in a trust policy evaluated during AssumeRole they are absent, as on the service for an AssumeRole caller using long-term credentials. State the path scope in the admin guide so an operator does not expect them in a trust policy. --- docs/manuals/05-admin-guide.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index 51ada0b9c..f3191d21c 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -513,7 +513,9 @@ evaluator does not implement (for example `BinaryEquals`) still fails closed. for an IAM user. ExtendDB resolves it for a role session from the session issue time and leaves it absent for an IAM user, matching the service. A policy that gates on its presence or compares it with a `Date` operator behaves as it does -on AWS for a role session. +on AWS for a role session. This resolution is on the DynamoDB request path; in a +trust policy evaluated during `AssumeRole` the key is absent, as it is on the +service for an `AssumeRole` caller using long-term credentials. `aws:MultiFactorAuthPresent` is populated by the service for a role session (`false` for a session assumed without MFA) and absent for an IAM user with @@ -521,7 +523,10 @@ long-term keys. ExtendDB resolves it to `false` for a role session and leaves it absent for an IAM user, so the common deny-without-MFA guard (`Deny if Bool aws:MultiFactorAuthPresent=false`) applies to a role session and is inert for an IAM user, matching the service. `aws:MultiFactorAuthAge` stays -absent, as the service leaves it unset for a non-MFA session. +absent, as the service leaves it unset for a non-MFA session. Like +`aws:TokenIssueTime`, `aws:MultiFactorAuthPresent` is resolved on the DynamoDB +request path only; in a trust policy it is absent, as on the service for an +`AssumeRole` caller using long-term credentials. ### Permissions Boundaries From aa2e0964a821a71cdec8dc894ae82e97a69947a9 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 06:38:26 +0000 Subject: [PATCH 42/58] fix(auth): match the service on leading-zero octet and mapped IPv6 in a CIDR Measured live: the service accepts a leading-zero octet in an IpAddress or NotIpAddress value and reads it as decimal (072.0.0.0/8 matched the first octet 72, 0110.0.0.0/8 matched 110, not octal 72), accepts a zero-padded prefix and upper-case IPv6 hex, and rejects an IPv4-mapped IPv6 literal (::ffff:a.b.c.d) with MalformedPolicyDocument. ExtendDB rejected a leading-zero octet at write (the standard parser refuses it) and accepted a mapped IPv6 literal. Add a decimal IPv4 parser used by both is_valid_cidr and ip_in_cidr so a leading-zero octet is accepted at write and read as decimal at evaluation (write and evaluation agree), and reject a mapped IPv6 network literal. Unit tests for the leading-zero decimal reading, the mapped-IPv6 rejection, and the upper-case and zero-padded forms; wire tests for the leading-zero octet on the identity path. --- crates/auth/src/policy/matcher.rs | 95 ++++++++++++++++++++++++++++--- tests/test_abac.py | 19 +++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/crates/auth/src/policy/matcher.rs b/crates/auth/src/policy/matcher.rs index dbe4b435b..18e89a6e9 100755 --- a/crates/auth/src/policy/matcher.rs +++ b/crates/auth/src/policy/matcher.rs @@ -152,26 +152,69 @@ pub fn arn_match(pattern: &str, value: &str) -> bool { /// can evaluate. #[must_use] pub fn is_valid_cidr(cidr: &str) -> bool { - use std::net::IpAddr; - let (net_str, prefix_str) = match cidr.split_once('/') { Some((n, p)) => (n, Some(p)), None => (cidr, None), }; - let net: IpAddr = match net_str.parse() { - Ok(n) => n, - Err(_) => return false, + let net = match parse_network(net_str) { + Some(n) => n, + None => return false, }; match prefix_str { None => true, Some(p) => match (net, p.parse::()) { - (IpAddr::V4(_), Ok(prefix)) => prefix <= 32, - (IpAddr::V6(_), Ok(prefix)) => prefix <= 128, + (std::net::IpAddr::V4(_), Ok(prefix)) => prefix <= 32, + (std::net::IpAddr::V6(_), Ok(prefix)) => prefix <= 128, _ => false, }, } } +/// Parse an IPv4 address/network literal reading each octet as DECIMAL, so a +/// leading-zero octet is the same value as without it (`072` is 72, not octal +/// 58, and `0110` is 110). Measured: the live service reads a leading-zero octet +/// in an `IpAddress` value as decimal. Rejects a wrong octet count, an empty or +/// non-digit octet, or an octet greater than 255. +fn parse_ipv4_decimal(s: &str) -> Option { + let mut octets = [0u8; 4]; + let mut parts = s.split('.'); + for octet in &mut octets { + let part = parts.next()?; + if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let val: u32 = part.parse().ok()?; + if val > 255 { + return None; + } + *octet = u8::try_from(val).ok()?; + } + if parts.next().is_some() { + return None; + } + Some(std::net::Ipv4Addr::from(octets)) +} + +/// Parse a CIDR network literal, matching the live service. An IPv4 literal is +/// read with decimal octets (`parse_ipv4_decimal`); an IPv6 literal is parsed by +/// the standard library (upper-case hex accepted), but an IPv4-mapped IPv6 +/// address (`::ffff:a.b.c.d`) is rejected, because the service rejects it with +/// `MalformedPolicyDocument` and accepting it would store an `IpAddress` Deny +/// that silently never matches (the caller source IP is canonicalized to a bare +/// IPv4 of a different family). +fn parse_network(net_str: &str) -> Option { + use std::net::{IpAddr, Ipv6Addr}; + if net_str.contains(':') { + let v6: Ipv6Addr = net_str.parse().ok()?; + if v6.to_ipv4_mapped().is_some() { + return None; + } + Some(IpAddr::V6(v6)) + } else { + parse_ipv4_decimal(net_str).map(IpAddr::V4) + } +} + #[must_use] pub fn ip_in_cidr(cidr: &str, addr: &str) -> Option { use std::net::IpAddr; @@ -181,7 +224,7 @@ pub fn ip_in_cidr(cidr: &str, addr: &str) -> Option { Some((n, p)) => (n, Some(p)), None => (cidr, None), }; - let net: IpAddr = net_str.parse().ok()?; + let net: IpAddr = parse_network(net_str)?; match (ip, net) { (IpAddr::V4(ip), IpAddr::V4(net)) => { let prefix: u32 = match prefix_str { @@ -394,6 +437,42 @@ mod tests { assert_eq!(ip_in_cidr("203.0.113.0/33", "203.0.113.9"), None); } + #[test] + fn valid_cidr_rejects_mapped_ipv6_and_accepts_upper_and_padded() { + // The service rejects an IPv4-mapped IPv6 literal in a policy CIDR with + // MalformedPolicyDocument (measured). + assert!(!is_valid_cidr("::ffff:10.0.0.0/120")); + assert!(!is_valid_cidr("::ffff:72.21.196.65/128")); + assert!(!is_valid_cidr("::ffff:10.0.0.0")); + // Upper-case IPv6 hex and a zero-padded prefix are accepted (match service). + assert!(is_valid_cidr("2001:DB8::/32")); + assert!(is_valid_cidr("10.0.0.0/08")); + // Genuine IPv6 networks are still accepted. + assert!(is_valid_cidr("2001:db8::/32")); + assert!(is_valid_cidr("::/0")); + // A leading-zero octet is accepted and read as decimal (measured: the + // service reads 072 as 72, 0110 as 110). + assert!(is_valid_cidr("010.0.0.0/8")); + assert!(is_valid_cidr("072.021.196.065/32")); + assert!(is_valid_cidr("0110.0.0.0/8")); + // An octet over 255 is still rejected. + assert!(!is_valid_cidr("10.0.0.256/8")); + assert!(!is_valid_cidr("0256.0.0.0/8")); + assert!(!is_valid_cidr("10.0.0.0/33")); + } + + #[test] + fn ip_in_cidr_reads_leading_zero_octet_as_decimal() { + // 072.0.0.0/8 is the 72.0.0.0/8 network (decimal), not octal 58. + assert_eq!(ip_in_cidr("072.0.0.0/8", "72.21.196.65"), Some(true)); + assert_eq!(ip_in_cidr("072.0.0.0/8", "58.0.0.1"), Some(false)); + // 0110.0.0.0/8 is decimal 110, not octal 72. + assert_eq!(ip_in_cidr("0110.0.0.0/8", "110.1.2.3"), Some(true)); + assert_eq!(ip_in_cidr("0110.0.0.0/8", "72.1.2.3"), Some(false)); + // A mapped IPv6 network does not parse, so neither operator applies. + assert_eq!(ip_in_cidr("::ffff:10.0.0.0/120", "10.0.0.1"), None); + } + #[test] fn valid_cidr_accepts_well_formed_and_rejects_malformed() { // Well-formed (accepted at write). diff --git a/tests/test_abac.py b/tests/test_abac.py index 77f168b1c..20788ff46 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -838,6 +838,25 @@ def test_kelvin_leadingkeys_nonmatch_does_not_overgrant(self, table): {"ForAllValues:StringEquals": {f"dynamodb:Leading{self.KV}eys": ["zzz"]}})) == "DENY" +class TestLeadingZeroCidr(_TaggedTableBase): + """A leading-zero octet in an IpAddress CIDR is accepted at write and + read as decimal at evaluation, matching the live service (072 is 72, not + octal 58; 0110 is 110). The test client connects from 127.0.0.1.""" + + def test_leading_zero_octet_accepted_and_matches_decimal(self, table): + # 0127.0.0.0/8 is the 127.0.0.0/8 network (decimal); the loopback caller + # is in it, so the gate grants. Before the fix the write was rejected. + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:SourceIp": "0127.0.0.0/8"}})) == "ALLOW" + + def test_leading_zero_octet_not_octal(self, table): + # 0177.0.0.0/8 read as decimal is 177.0.0.0/8, which does not contain the + # loopback caller; octal 0177 would be 127 and would match. The service + # reads decimal, so the gate does not grant. + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:SourceIp": "0177.0.0.0/8"}})) == "DENY" + + # --------------------------------------------------------------------------- # aws:TokenIssueTime (role sessions) and aws:userid across recreate # --------------------------------------------------------------------------- From d0241979b9499c090dd7ea7c41d873eb4a0775db Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 07:15:23 +0000 Subject: [PATCH 43/58] chore(auth): rustfmt the miscased-operator rejection test --- crates/server/src/management/assume_role.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index 3af5832f8..7503c7826 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -1720,9 +1720,11 @@ mod tests { "ForAllValues:stringequals", "StringEqualsifexists", ] { - rejected(json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, + rejected( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", - "Condition": {op: {"aws:PrincipalType": "User"}}}]})); + "Condition": {op: {"aws:PrincipalType": "User"}}}]}), + ); } } From 363e2d9712509fccbc0577195707a05c9f6cf0bb Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 08:06:45 +0000 Subject: [PATCH 44/58] chore(auth): record case-fold table provenance and add a generator and guard test The case-fold table in crates/auth/src/policy/casefold.rs had no Unicode version and no checked-in generator. Add devtools/generate-casefold-table, which writes the table from Python's str.casefold (Unicode full case folding) and records the Unicode version in the file header (15.0.0), and regenerate the file through it. Add unit tests that the table is sorted with unique keys and spot-check entries of every kind: single-character folds (long s, Kelvin), multi-character expansions (sharp s, capital sharp s, st ligature), Greek final sigma, a Cherokee fold, and the code points that must fold to themselves or to a non-ASCII form (dotless i, dotted I). No Unicode data file is vendored. --- crates/auth/src/policy/casefold.rs | 53 ++++++++++-- devtools/generate-casefold-table | 126 +++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 7 deletions(-) create mode 100755 devtools/generate-casefold-table diff --git a/crates/auth/src/policy/casefold.rs b/crates/auth/src/policy/casefold.rs index bcf14e061..68a21e9d0 100644 --- a/crates/auth/src/policy/casefold.rs +++ b/crates/auth/src/policy/casefold.rs @@ -1,14 +1,17 @@ // Copyright 2026 ExtendDB contributors // SPDX-License-Identifier: Apache-2.0 -//! Unicode full case folding, generated from the Unicode Case Folding data -//! (the full "C" + "F" mappings). The live service resolves a condition key -//! name and a tag key under full case folding, so a name spelled with a -//! non-ASCII letter that folds to a resolvable key (long s to s, the st -//! ligature to st, sharp s to ss) is matched. Measured against the service in -//! this form; the dotless and dotted i do not fold to i, matching the table. +//! Unicode full case folding (the Case Folding data's full `C` + `F` +//! mappings). The live service resolves a condition key name and a tag key +//! on the `DynamoDB` request path under full case folding, so a name spelled +//! with a non-ASCII letter that folds to a resolvable key (long s to s, the +//! st ligature to st, sharp s to ss) is matched. The dotless i (U+0131) and +//! the dotted I (U+0130) do not fold to a plain ASCII `i` and so read as +//! absent, matching the service. //! -//! This file is generated; do not edit by hand. +//! Unicode version: 15.0.0. Generated by `devtools/generate-casefold-table` +//! from Python's `str.casefold`; do not edit by hand. Regenerate with +//! `devtools/generate-casefold-table > crates/auth/src/policy/casefold.rs`. /// Code point to its full case fold. Code points not listed fold to /// themselves. Sorted by code point for binary search. @@ -1567,3 +1570,39 @@ pub fn case_fold(s: &str) -> String { pub fn eq_fold(a: &str, b: &str) -> bool { case_fold(a) == case_fold(b) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_sorted_and_unique() { + for w in CASE_FOLD.windows(2) { + assert!( + w[0].0 < w[1].0, + "table must be sorted and unique by code point" + ); + } + } + + #[test] + fn spot_checks() { + // Single-character folds. + assert_eq!(case_fold("\u{017f}"), "s"); // long s + assert_eq!(case_fold("\u{212a}"), "k"); // Kelvin sign + assert_eq!(case_fold("A"), "a"); + // Multi-character expansions. + assert_eq!(case_fold("\u{00df}"), "ss"); // sharp s + assert_eq!(case_fold("\u{1e9e}"), "ss"); // capital sharp s + assert_eq!(case_fold("\u{fb05}"), "st"); // st ligature + // Greek final sigma folds to the same as sigma. + assert_eq!(case_fold("\u{03c2}"), case_fold("\u{03c3}")); + assert_eq!(case_fold("\u{03a3}"), case_fold("\u{03c3}")); + // Cherokee folds to another Cherokee code point (a non-ASCII target). + assert_eq!(case_fold("\u{13f8}"), "\u{13f0}"); + // Code points that must be absent from the table (fold to self). + assert_eq!(case_fold("\u{0131}"), "\u{0131}"); // dotless i + // Dotted I folds to 'i' plus a combining dot, not plain 'i'. + assert_eq!(case_fold("\u{0130}"), "i\u{0307}"); + } +} diff --git a/devtools/generate-casefold-table b/devtools/generate-casefold-table new file mode 100755 index 000000000..6ccb195b0 --- /dev/null +++ b/devtools/generate-casefold-table @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +"""Generate crates/auth/src/policy/casefold.rs from Python's str.casefold. + +Python's str.casefold implements Unicode full case folding (the Case Folding +data's C + F mappings), which is what the live service applies to condition key +names and tag keys on the DynamoDB request path. The table maps every code point +whose fold differs from itself to its folded form; a code point absent from the +table folds to itself (so the dotless i U+0131 and the dotted I U+0130, which do +not fold to plain ASCII i, are handled correctly). + +The Unicode version is whatever the running Python's unicodedata reports; it is +recorded in the generated file header. Run from the repository root: + + devtools/generate-casefold-table > crates/auth/src/policy/casefold.rs + +No Unicode data file is vendored; the data comes from the Python standard +library. +""" +import sys +import unicodedata + +OUT_PATH = "crates/auth/src/policy/casefold.rs" + + +def entries(): + for c in range(0x110000): + ch = chr(c) + f = ch.casefold() + if f != ch: + yield c, f + + +def esc(folded): + return "".join("\\u{%x}" % ord(x) for x in folded) + + +def main(): + uni = unicodedata.unidata_version + rows = list(entries()) + out = [] + w = out.append + w("// Copyright 2026 ExtendDB contributors") + w("// SPDX-License-Identifier: Apache-2.0") + w("") + w("//! Unicode full case folding (the Case Folding data's full `C` + `F`") + w("//! mappings). The live service resolves a condition key name and a tag key") + w("//! on the `DynamoDB` request path under full case folding, so a name spelled") + w("//! with a non-ASCII letter that folds to a resolvable key (long s to s, the") + w("//! st ligature to st, sharp s to ss) is matched. The dotless i (U+0131) and") + w("//! the dotted I (U+0130) do not fold to a plain ASCII `i` and so read as") + w("//! absent, matching the service.") + w("//!") + w(f"//! Unicode version: {uni}. Generated by `devtools/generate-casefold-table`") + w("//! from Python's `str.casefold`; do not edit by hand. Regenerate with") + w("//! `devtools/generate-casefold-table > crates/auth/src/policy/casefold.rs`.") + w("") + w("/// Code point to its full case fold. Code points not listed fold to") + w("/// themselves. Sorted by code point for binary search.") + w("pub(crate) static CASE_FOLD: &[(u32, &str)] = &[") + for c, f in rows: + w(' (0x%X, "%s"),' % (c, esc(f))) + w("];") + w("") + w("/// Fold a string under Unicode full case folding, matching the service's") + w("/// resolution of condition key names and tag keys. ASCII letters fold to") + w("/// ASCII lowercase; a non-ASCII code point folds per [`CASE_FOLD`] or to") + w("/// itself.") + w("#[must_use]") + w("pub fn case_fold(s: &str) -> String {") + w(" let mut out = String::with_capacity(s.len());") + w(" for c in s.chars() {") + w(" let cp = c as u32;") + w(" match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) {") + w(" Ok(i) => out.push_str(CASE_FOLD[i].1),") + w(" Err(_) => out.push(c),") + w(" }") + w(" }") + w(" out") + w("}") + w("") + w("/// Whether two strings are equal under Unicode full case folding.") + w("#[must_use]") + w("pub fn eq_fold(a: &str, b: &str) -> bool {") + w(" case_fold(a) == case_fold(b)") + w("}") + w("") + w("#[cfg(test)]") + w("mod tests {") + w(" use super::*;") + w("") + w(" #[test]") + w(" fn table_sorted_and_unique() {") + w(" for w in CASE_FOLD.windows(2) {") + w(" assert!(w[0].0 < w[1].0, \"table must be sorted and unique by code point\");") + w(" }") + w(" }") + w("") + w(" #[test]") + w(" fn spot_checks() {") + w(" // Single-character folds.") + w(" assert_eq!(case_fold(\"\\u{017f}\"), \"s\"); // long s") + w(" assert_eq!(case_fold(\"\\u{212a}\"), \"k\"); // Kelvin sign") + w(" assert_eq!(case_fold(\"A\"), \"a\");") + w(" // Multi-character expansions.") + w(" assert_eq!(case_fold(\"\\u{00df}\"), \"ss\"); // sharp s") + w(" assert_eq!(case_fold(\"\\u{1e9e}\"), \"ss\"); // capital sharp s") + w(" assert_eq!(case_fold(\"\\u{fb05}\"), \"st\"); // st ligature") + w(" // Greek final sigma folds to the same as sigma.") + w(" assert_eq!(case_fold(\"\\u{03c2}\"), case_fold(\"\\u{03c3}\"));") + w(" assert_eq!(case_fold(\"\\u{03a3}\"), case_fold(\"\\u{03c3}\"));") + w(" // Cherokee folds to another Cherokee code point (a non-ASCII target).") + w(" assert_eq!(case_fold(\"\\u{13f8}\"), \"\\u{13f0}\");") + w(" // Code points that must be absent from the table (fold to self).") + w(" assert_eq!(case_fold(\"\\u{0131}\"), \"\\u{0131}\"); // dotless i") + w(" // Dotted I folds to 'i' plus a combining dot, not plain 'i'.") + w(" assert_eq!(case_fold(\"\\u{0130}\"), \"i\\u{0307}\");") + w(" }") + w("}") + w("") + sys.stdout.write("\n".join(out)) + + +if __name__ == "__main__": + main() From 4230f38bd054a531bd00f578bc6a12f2181fccbd Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 08:29:22 +0000 Subject: [PATCH 45/58] fix(auth): resolve a tag condition key deterministically ci_tag_lookup returned the first fold match from a HashMap, whose iteration order is unspecified, so two stored tag keys that fold to the same string (for example mass and ma\u00df under Unicode folding) resolved at random: a StringEquals aws:PrincipalTag/mass was intermittently wrong across process restarts. Measured live: the service stores mass and ma\u00df as distinct keys and resolves aws:PrincipalTag/mass to the exact mass key every time; it rejects two keys that differ only in ASCII case on one resource (Team and team, 'Duplicate tag keys found... case insensitive'). Replace the lookup with a deterministic one: a byte-exact stored key wins, else the smallest fold-matching key by code point. Apply IAM's ASCII-case key uniqueness at tag_user and tag_role. Tests: a unit test over many hasher seeds asserting the exact key always wins and the deterministic fallback; the write paths reject an ASCII-case duplicate. --- crates/auth/src/policy/context.rs | 141 +++++++++++++++-------- crates/server/src/management/iam_role.rs | 8 ++ crates/server/src/management/iam_user.rs | 8 ++ crates/server/src/management/mod.rs | 20 ++++ tests/test_abac.py | 37 ++++++ 5 files changed, 163 insertions(+), 51 deletions(-) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 87a008011..33b63be8b 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -45,37 +45,49 @@ pub trait ConditionContext { } } -/// Look up a tag map by key, case-insensitively on the key. -/// AWS resolves tag condition keys (`aws:PrincipalTag/`, `aws:RequestTag/`, -/// `aws:ResourceTag/`, `dynamodb:ResourceTag/`) without regard to the case -/// of ``, under Unicode full case folding; a tag stored as `Team` is matched -/// by a policy naming `team`, and `cla\u{00df}` matches `class`. Tag values stay -/// case-sensitive. IAM forbids two tag keys that differ only in case, so a -/// case-insensitive match is unambiguous. +/// Look up a tag map by key, matching the live service. /// -/// `folded_tag_key` is already case-folded by the caller; each stored key is -/// folded before comparison. -fn ci_tag_lookup<'a>( +/// Resolve a tag condition key against a stored tag map, deterministically. +/// +/// The live service stores two tag keys that differ by more than ASCII case +/// (for example `mass` and `ma\u{00df}`) as distinct keys, and resolves a policy +/// key to the exact stored key when one matches exactly. ExtendDB mirrors that: +/// a byte-exact stored key wins; otherwise the key is resolved under `norm` +/// (Unicode case folding on the identity path, ASCII case on the trust path), +/// and when several stored keys normalize to the same string the smallest by +/// code point is chosen, so resolution is deterministic rather than depending on +/// `HashMap` iteration order. +/// +/// `sub_key` is the original (un-normalized) tag sub-key from the policy. +fn tag_lookup<'a>( map: &'a HashMap, - folded_tag_key: &str, + sub_key: &str, + norm: impl Fn(&str) -> String, ) -> Option> { + if let Some(v) = map.get(sub_key) { + return Some(vec![v.as_str()]); + } + let want = norm(sub_key); map.iter() - .find(|(k, _)| super::casefold::case_fold(k) == folded_tag_key) + .filter(|(k, _)| norm(k) == want) + .min_by(|a, b| a.0.cmp(b.0)) .map(|(_, v)| vec![v.as_str()]) } -/// Strip `prefix` from the front of an already case-folded key, returning the -/// remainder (the folded tag sub-key) when it matches. +/// Strip a tag prefix (`aws:PrincipalTag/`, `aws:RequestTag/`, +/// `aws:ResourceTag/`, `dynamodb:ResourceTag/`) from the front of `key` +/// ASCII-case-insensitively, returning the original (un-normalized) tag sub-key. /// -/// The live service resolves a condition key name without regard to case, -/// including the tag prefix (`aws:PrincipalTag/`, `aws:RequestTag/`, -/// `aws:ResourceTag/`, `dynamodb:ResourceTag/`), under Unicode full case -/// folding. The caller folds the whole key first, so `prefix` is given in its -/// folded (lower-case ASCII) form and a plain prefix match suffices. The -/// returned sub-key is the folded form, which `ci_tag_lookup` compares against -/// each folded stored key. -fn strip_folded_prefix<'a>(folded_key: &'a str, folded_prefix: &str) -> Option<&'a str> { - folded_key.strip_prefix(folded_prefix) +/// The prefix is ASCII, so the service (and ExtendDB) match it without regard to +/// ASCII case; the returned sub-key keeps its original bytes so `tag_lookup` can +/// prefer a byte-exact stored key. +fn strip_prefix_ci<'a>(key: &'a str, prefix: &str) -> Option<&'a str> { + let head = key.get(..prefix.len())?; + if head.eq_ignore_ascii_case(prefix) { + key.get(prefix.len()..) + } else { + None + } } /// Documented AWS global condition keys the live service populates for a @@ -286,23 +298,21 @@ impl RequestContext { impl ConditionContext for RequestContext { fn resolve_key(&self, key: &str) -> Option> { // Condition key names are resolved under Unicode full case folding, - // matching the live service: `aws:securetransport`, `AWS:SecureTransport` - // and `aws:SecureTran\u{017f}port` resolve the same, and - // `aws:principaltag/team` the same as `aws:PrincipalTag/team`. Fold the - // whole key once; the tag prefix is matched in its folded form and the - // folded sub-key is compared against each folded stored tag key. - let folded = super::casefold::case_fold(key); - if let Some(tag_key) = strip_folded_prefix(&folded, "aws:principaltag/") { - ci_tag_lookup(&self.principal_tags, tag_key) - } else if let Some(tag_key) = strip_folded_prefix(&folded, "aws:resourcetag/") { + // Tag keys are resolved deterministically: a byte-exact stored key + // wins, else the smallest fold-matching stored key. The tag prefix is + // ASCII and matched case-insensitively. Non-tag key names are matched + // 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:ResourceTag/") { // The service evaluates resource tags as aws:ResourceTag/*. ExtendDB // resolves them from the same resource-tag map it uses for - // dynamodb:ResourceTag/*, case-folded on the key. - ci_tag_lookup(&self.resource_tags, tag_key) - } else if let Some(tag_key) = strip_folded_prefix(&folded, "dynamodb:resourcetag/") { - ci_tag_lookup(&self.resource_tags, tag_key) + // dynamodb:ResourceTag/*. + tag_lookup(&self.resource_tags, sub, super::casefold::case_fold) + } else if let Some(sub) = strip_prefix_ci(key, "dynamodb:ResourceTag/") { + tag_lookup(&self.resource_tags, sub, super::casefold::case_fold) } else { - match folded.as_str() { + match super::casefold::case_fold(key).as_str() { "dynamodb:leadingkeys" => self .leading_keys .as_ref() @@ -438,13 +448,12 @@ impl ConditionContext for AssumeRoleContext { fn resolve_key(&self, key: &str) -> Option> { // Condition key names are resolved under Unicode full case folding, // matching the live service, including the tag prefixes. - let folded = super::casefold::case_fold(key); - if let Some(tag_key) = strip_folded_prefix(&folded, "aws:principaltag/") { - ci_tag_lookup(&self.principal_tags, tag_key) - } else if let Some(tag_key) = strip_folded_prefix(&folded, "aws:requesttag/") { - ci_tag_lookup(&self.request_tags, tag_key) + 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/") { + tag_lookup(&self.request_tags, sub, super::casefold::case_fold) } else { - match folded.as_str() { + match super::casefold::case_fold(key).as_str() { "sts:externalid" => self.external_id.as_deref().map(|v| vec![v]), "sts:rolesessionname" => self.session_name.as_deref().map(|v| vec![v]), "aws:principalarn" => Some(vec![self.principal_arn.as_str()]), @@ -601,6 +610,43 @@ mod tests { assert_eq!(ctx.resolve_key("aws:PrincipalTag/Missing"), None); } + #[test] + fn tag_lookup_fold_collision_is_deterministic_exact_wins() { + // mass and ma\u{00df} both fold to "mass". The exact stored key must win + // every time, and a non-exact fold-matching policy key must resolve to + // the smallest stored key by code point, regardless of HashMap seed. + let sharp = "ma\u{00df}"; + for _ in 0..64 { + let mut m = HashMap::new(); + m.insert("mass".to_owned(), "A".to_owned()); + m.insert(sharp.to_owned(), "B".to_owned()); + // exact byte match wins + assert_eq!( + tag_lookup(&m, "mass", crate::policy::casefold::case_fold), + Some(vec!["A"]) + ); + assert_eq!( + tag_lookup(&m, sharp, crate::policy::casefold::case_fold), + Some(vec!["B"]) + ); + // no exact: MASS folds to mass, matches both; smallest key "mass" wins + assert_eq!( + tag_lookup(&m, "MASS", crate::policy::casefold::case_fold), + Some(vec!["A"]) + ); + // ASCII normalizer (trust path): "mass" exact wins; sharp s is not + // folded to ss, so ma\u{00df} is a distinct key resolved exactly. + assert_eq!( + tag_lookup(&m, "mass", str::to_ascii_lowercase), + Some(vec!["A"]) + ); + assert_eq!( + tag_lookup(&m, sharp, str::to_ascii_lowercase), + Some(vec!["B"]) + ); + } + } + #[test] fn request_context_resource_tag() { let ctx = RequestContext::build( @@ -780,14 +826,7 @@ mod tests { Some("ext-123".to_owned()), HashMap::new(), ); - // sts:External[s->ſ]... long s in externalId: sts:External[ı]d would NOT - // fold; use the st-ligature-free long s in a resolvable spot. - assert_eq!( - ctx.resolve_key("sts:External\u{0131}d"), - None, - "dotless i must not fold to i" - ); - // Tag key folds: cla[ß] -> class. + assert_eq!(ctx.resolve_key("sts:External\u{0131}d"), None); assert_eq!( ctx.resolve_key("aws:PrincipalTag/cla\u{00df}"), Some(vec!["x"]) diff --git a/crates/server/src/management/iam_role.rs b/crates/server/src/management/iam_role.rs index 19f5a84be..469e6921a 100755 --- a/crates/server/src/management/iam_role.rs +++ b/crates/server/src/management/iam_role.rs @@ -211,6 +211,14 @@ pub async fn tag_role( .map(|t| (t.key.clone(), t.value.clone())) .collect(); + if let Some(dup) = super::duplicate_tag_key_ci(&tags) { + return ( + StatusCode::BAD_REQUEST, + format!("Duplicate tag keys found (tag keys are case insensitive): {dup}"), + ) + .into_response(); + } + match state .catalog_store .tag_role(&account_id, &role_name, &tags) diff --git a/crates/server/src/management/iam_user.rs b/crates/server/src/management/iam_user.rs index 82b10fb82..3918867c2 100755 --- a/crates/server/src/management/iam_user.rs +++ b/crates/server/src/management/iam_user.rs @@ -249,6 +249,14 @@ pub async fn tag_user( .map(|t| (t.key.clone(), t.value.clone())) .collect(); + if let Some(dup) = super::duplicate_tag_key_ci(&tags) { + return ( + StatusCode::BAD_REQUEST, + format!("Duplicate tag keys found (tag keys are case insensitive): {dup}"), + ) + .into_response(); + } + match state .catalog_store .tag_user(&account_id, &user_name, &tags) diff --git a/crates/server/src/management/mod.rs b/crates/server/src/management/mod.rs index b132b16b6..a453d331f 100755 --- a/crates/server/src/management/mod.rs +++ b/crates/server/src/management/mod.rs @@ -227,6 +227,26 @@ pub(crate) fn is_valid_iam_name(name: &str) -> bool { }) } +/// Reject a tag set with two keys that collide under ASCII case folding. +/// +/// The live service treats tag keys as case insensitive for uniqueness: a +/// `TagUser`/`TagRole`/`TagResource` request carrying both `Team` and `team` +/// is rejected ("Duplicate tag keys found. Please note that Tag keys are case +/// insensitive."). Two keys that differ by more than ASCII case (for example +/// `mass` and `ma\u{00df}`) are distinct and allowed. Returns the duplicate key +/// when found. +pub(crate) fn duplicate_tag_key_ci(tags: &[(String, String)]) -> Option { + let mut seen: Vec = Vec::with_capacity(tags.len()); + for (k, _) in tags { + let lower = k.to_ascii_lowercase(); + if seen.contains(&lower) { + return Some(k.clone()); + } + seen.push(lower); + } + None +} + /// Validate admin name: 1-64 chars, alphanumeric, hyphens, or underscores. fn is_valid_admin_name(name: &str) -> bool { !name.is_empty() diff --git a/tests/test_abac.py b/tests/test_abac.py index 20788ff46..7ba34bf2c 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -857,6 +857,43 @@ def test_leading_zero_octet_not_octal(self, table): {"IpAddress": {"aws:SourceIp": "0177.0.0.0/8"}})) == "DENY" +class TestTagCollisionDeterministic(_TaggedTableBase): + """Two stored tag keys that fold to the same string (mass and ma\u00df) + must resolve deterministically to the exact key, every time, not at random + HashMap order.""" + + SHARP = "\u00df" + + def test_fold_colliding_tag_resolves_exact_key(self, table): + user = f"gk-coll-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) + try: + r = self.mgmt.tag_user(self.account_id, user, {"mass": "A", f"ma{self.SHARP}": "B"}) + assert r.status_code in (200, 201, 204), r.text + client = _make_client(self.endpoint, ak, sk, self.region) + + def outcomes_for(value): + assert self.mgmt.put_user_policy(self.account_id, user, "p", { + "Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringEquals": {"aws:PrincipalTag/mass": value}}}]}).status_code == 204 + seen = set() + for _ in range(20): + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + seen.add("ALLOW") + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException" + seen.add("DENY") + return seen + + # Exact key mass=A resolves A every time; =B denies every time. + assert outcomes_for("A") == {"ALLOW"} + assert outcomes_for("B") == {"DENY"} + finally: + self.mgmt.delete_user(self.account_id, user) + + # --------------------------------------------------------------------------- # aws:TokenIssueTime (role sessions) and aws:userid across recreate # --------------------------------------------------------------------------- From 104712db6ec30d74a3ec8fde2b81f13904e56288 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 08:31:52 +0000 Subject: [PATCH 46/58] fix(auth): the AssumeRole trust path matches ASCII case only, not Unicode The Unicode case fold was applied to both evaluators. Live STS does not Unicode-fold a condition key name or tag key on the AssumeRole trust path: a key spelled with a non-ASCII letter reads absent there (sharp s and e-acute are stored by CreateRole but do not resolve at AssumeRole), while the DynamoDB request path does fold them. ExtendDB over-folded the trust path, granting an AssumeRole an Allow gated on a Unicode-spelled key that the service refuses. AssumeRoleContext now matches key names and tag keys ASCII-case-insensitively (to_ascii_lowercase), not case folding; RequestContext keeps full Unicode folding. Measured the write boundary: the service does not store a trust policy containing a character above U+00FF (CreateRole InternalFailure, or MalformedPolicyDocument in a Sid), so validate_trust_policy rejects a document with any character above U+00FF; a Latin-1 non-ASCII key is stored and reads absent at evaluation, matching the service. Tests: AssumeRole ASCII-only unit test, trust-write rejection unit test; wire tests that a Latin-1 key does not grant and a code point above U+00FF is rejected at create. --- crates/auth/src/policy/context.rs | 33 +++++++++----- crates/server/src/management/assume_role.rs | 49 +++++++++++++++++++++ tests/test_auth_integration.py | 28 +++++++++--- 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 33b63be8b..ee8a9ff7f 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -446,14 +446,17 @@ pub struct AssumeRoleContext { impl ConditionContext for AssumeRoleContext { fn resolve_key(&self, key: &str) -> Option> { - // Condition key names are resolved under Unicode full case folding, - // matching the live service, including the tag prefixes. + // The live AssumeRole trust path matches condition key names and tag + // keys ASCII-case-insensitively only; unlike the DynamoDB request path + // it does NOT apply Unicode case folding (measured: a key spelled with a + // non-ASCII letter reads absent on live STS). Normalize with + // `to_ascii_lowercase`, not `case_fold`. if let Some(sub) = strip_prefix_ci(key, "aws:PrincipalTag/") { - tag_lookup(&self.principal_tags, sub, super::casefold::case_fold) + tag_lookup(&self.principal_tags, sub, str::to_ascii_lowercase) } else if let Some(sub) = strip_prefix_ci(key, "aws:RequestTag/") { - tag_lookup(&self.request_tags, sub, super::casefold::case_fold) + tag_lookup(&self.request_tags, sub, str::to_ascii_lowercase) } else { - match super::casefold::case_fold(key).as_str() { + match key.to_ascii_lowercase().as_str() { "sts:externalid" => self.external_id.as_deref().map(|v| vec![v]), "sts:rolesessionname" => self.session_name.as_deref().map(|v| vec![v]), "aws:principalarn" => Some(vec![self.principal_arn.as_str()]), @@ -490,8 +493,9 @@ impl ConditionContext for AssumeRoleContext { // `aws:TagKeys` is the set of session-tag keys (`ArrayOfString`); every // other trust-policy key (`aws:PrincipalTag/*`, `aws:RequestTag/*`, // `sts:ExternalId`, `aws:PrincipalArn`, `aws:PrincipalAccount`) is - // single-valued. Matched under Unicode full case folding, like the service. - super::casefold::case_fold(key) == "aws:tagkeys" + // single-valued. Matched ASCII-case-insensitively, like the live trust + // path (no Unicode folding). + key.eq_ignore_ascii_case("aws:TagKeys") } fn is_unresolvable_key(&self, key: &str) -> bool { @@ -820,17 +824,22 @@ mod tests { } #[test] - fn assume_role_context_key_name_unicode_case_folds() { + fn assume_role_context_folds_ascii_only_not_unicode() { + // The trust path matches ASCII-case-insensitively only; it does NOT + // Unicode-fold, unlike the identity path. A key name or tag key spelled + // with a non-ASCII letter that would fold to a resolvable ASCII key + // reads as absent, matching live STS. let ctx = ar_ctx( HashMap::from([("class".to_owned(), "x".to_owned())]), Some("ext-123".to_owned()), HashMap::new(), ); + assert_eq!(ctx.resolve_key("STS:EXTERNALID"), Some(vec!["ext-123"])); + assert_eq!(ctx.resolve_key("aws:PrincipalTag/CLASS"), Some(vec!["x"])); + // Non-ASCII spellings do NOT resolve on the trust path. assert_eq!(ctx.resolve_key("sts:External\u{0131}d"), None); - assert_eq!( - ctx.resolve_key("aws:PrincipalTag/cla\u{00df}"), - Some(vec!["x"]) - ); + assert_eq!(ctx.resolve_key("aws:PrincipalTag/cla\u{00df}"), None); + assert_eq!(ctx.resolve_key("aws:Reque\u{fb05}edRegion"), None); } #[test] diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index 7503c7826..fd9d36396 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -684,12 +684,36 @@ pub(crate) fn validate_trust_policy(trust_policy: &Value) -> Result<(), String> if statements.is_empty() { return Err("Trust policy Statement must not be empty".to_owned()); } + // The live service does not store a trust policy that contains a character + // above U+00FF anywhere in the document (a key name, a value, a Sid, or a + // tag key): CreateRole returns InternalFailure (MalformedPolicyDocument for + // a Sid). Latin-1 characters (up to U+00FF, including the sharp s and + // accented letters) are stored, though a non-ASCII condition key name or + // tag key then reads as absent at evaluation (the trust path matches + // ASCII-case only). Reject a document with a character above U+00FF to + // match what the service will store. + if json_has_char_above_latin1(trust_policy) { + return Err("Trust policy must not contain a character above U+00FF".to_owned()); + } for stmt in &statements { validate_trust_statement(stmt)?; } Ok(()) } +/// Whether any object key or string value in `v` contains a code point above +/// U+00FF. The live service will not store a trust policy with one. +fn json_has_char_above_latin1(v: &Value) -> bool { + match v { + Value::String(s) => s.chars().any(|c| c as u32 > 0xFF), + Value::Array(a) => a.iter().any(json_has_char_above_latin1), + Value::Object(o) => o + .iter() + .any(|(k, val)| k.chars().any(|c| c as u32 > 0xFF) || json_has_char_above_latin1(val)), + _ => false, + } +} + /// A statement field, treating JSON null as absent. fn non_null(v: Option<&Value>) -> Option<&Value> { v.filter(|v| !v.is_null()) @@ -1700,6 +1724,31 @@ mod tests { ); } + #[test] + fn validate_rejects_char_above_latin1() { + // The service will not store a trust policy with a code point above + // U+00FF (CreateRole InternalFailure). A key name with U+0100 is + // rejected; a Latin-1 key (sharp s) is accepted (stored, reads absent + // at evaluation). + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:Reque\u{0100}edRegion": "us-east-1"}}}]}), + ); + // A high code point in a value is rejected too. + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:RequestedRegion": "us-east-1\u{212a}"}}}]}), + ); + // Latin-1 is accepted. + ok( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:Reque\u{00df}edRegion": "us-east-1"}}}]}), + ); + } + #[test] fn validate_rejects_unknown_condition_operator() { rejected( diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 9ee1b30a1..3bd36430a 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1276,15 +1276,31 @@ def test_keyname_case_insensitive_externalid_lowercase(self): # -- Resolved request-wide keys on the trust path (T8) ------------------ - def test_keyname_unicode_longs_securetransport_resolves(self): - # aws:SecureTran[long-s]port folds to securetransport=true under - # Unicode case folding, so the positive gate grants. ASCII-only folding - # would read the key absent and the Allow would not grant. + def test_trust_unicode_key_does_not_grant(self): + # The trust path matches ASCII-case only (no Unicode folding). A + # Latin-1 key (sharp s) is stored but reads absent, so an Allow gated on + # aws:SecureTran[sharp-s]port does not grant, matching live STS (which + # also leaves it absent). user, arn = self._caller() try: trust = _trust(_stmt("Allow", {"AWS": arn}, - condition={"Bool": {"aws:SecureTran\u017fport": "true"}})) - assert self._assume(trust, arn) == ALLOW + condition={"Bool": {"aws:SecureTran\u00dfport": "true"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_trust_rejects_char_above_latin1(self): + # The service will not store a trust policy with a code point above + # U+00FF (CreateRole InternalFailure); ExtendDB rejects it at create. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:Reque\u0100edRegion": "us-east-1"}})) + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + if resp.status_code == 201: + self.mgmt.delete_role(self.account_id, role) + assert resp.status_code == REJECT, resp.text finally: self.mgmt.delete_user(self.account_id, user) From 6f7094b2630b53fb817e668a0e5c1f04232495c6 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 08:35:57 +0000 Subject: [PATCH 47/58] fix(auth): apply IAM tag key and value charset rules at write The live service rejects a tag key or value outside its grammar ([\p{L}\p{Z}\p{N}_.:/=+\-@], excluding combining marks and most punctuation), a key over 128 or value over 256 characters, and the reserved aws: key prefix, with ValidationError; ExtendDB accepted them. Add validate_iam_tags and apply it at tag_user, tag_role and the AssumeRole session tags, alongside the ASCII-case duplicate-key check. Measured: the service accepts letters, digits, spaces, the listed symbols, a precomposed accented letter and an empty value, and rejects a combining mark, # and !, the aws: prefix, and over-length key or value. Unit and wire tests for the accepted and rejected sets. --- crates/server/src/management/assume_role.rs | 41 ++++++++++ crates/server/src/management/iam_role.rs | 14 ++-- crates/server/src/management/iam_user.rs | 14 ++-- crates/server/src/management/mod.rs | 87 +++++++++++++++++++++ tests/test_abac.py | 15 ++++ 5 files changed, 153 insertions(+), 18 deletions(-) diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index fd9d36396..579d423b3 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -152,6 +152,20 @@ pub async fn assume_role( let has_session_tags = request.session_tags.as_ref().is_some_and(value_has_entries); let request_tags = parse_session_tags(request.session_tags.as_ref()); + if let Some(st) = request.session_tags.as_ref() { + let pairs = session_tag_pairs(st); + if let Err(e) = super::validate_iam_tags(&pairs) { + return (StatusCode::BAD_REQUEST, e).into_response(); + } + if let Some(dup) = super::duplicate_tag_key_ci(&pairs) { + return ( + StatusCode::BAD_REQUEST, + format!("Duplicate session tag keys found (tag keys are case insensitive): {dup}"), + ) + .into_response(); + } + } + // Evaluate trust policy against the provided caller ARN. aws:SourceIp and // aws:UserAgent are the AssumeRole connection's peer address and User-Agent, // which is what STS records for the call. @@ -603,6 +617,33 @@ fn value_has_entries(v: &Value) -> bool { } } +/// Extract session-tag `(key, value)` pairs preserving every entry (including +/// keys that collide by ASCII case), for write validation. Object and list +/// forms; scalar values coerced to strings. +fn session_tag_pairs(session_tags: &Value) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + match session_tags { + Value::Object(obj) => { + for (k, v) in obj { + if let Some(s) = scalar_to_string(v) { + pairs.push((k.clone(), s)); + } + } + } + Value::Array(arr) => { + for entry in arr { + let key = entry.get("Key").and_then(Value::as_str); + let value = entry.get("Value").and_then(scalar_to_string); + if let (Some(k), Some(v)) = (key, value) { + pairs.push((k.to_owned(), v)); + } + } + } + _ => {} + } + pairs +} + /// Parse the request `session_tags` into a key/value map for condition /// evaluation (`aws:RequestTag/*`, `aws:TagKeys`). Accepts the object form /// `{"env": "prod"}` and the list form `[{"Key": "env", "Value": "prod"}]`. diff --git a/crates/server/src/management/iam_role.rs b/crates/server/src/management/iam_role.rs index 469e6921a..a05d131b2 100755 --- a/crates/server/src/management/iam_role.rs +++ b/crates/server/src/management/iam_role.rs @@ -193,15 +193,8 @@ pub async fn tag_role( } for tag in &body.tags { - if tag.key.is_empty() || tag.key.len() > 128 { - return (StatusCode::BAD_REQUEST, "tag key must be 1-128 characters").into_response(); - } - if tag.value.len() > 256 { - return ( - StatusCode::BAD_REQUEST, - "tag value must not exceed 256 characters", - ) - .into_response(); + if tag.key.is_empty() { + return (StatusCode::BAD_REQUEST, "tag key must not be empty").into_response(); } } @@ -211,6 +204,9 @@ pub async fn tag_role( .map(|t| (t.key.clone(), t.value.clone())) .collect(); + if let Err(e) = super::validate_iam_tags(&tags) { + return (StatusCode::BAD_REQUEST, e).into_response(); + } if let Some(dup) = super::duplicate_tag_key_ci(&tags) { return ( StatusCode::BAD_REQUEST, diff --git a/crates/server/src/management/iam_user.rs b/crates/server/src/management/iam_user.rs index 3918867c2..8e9a8d583 100755 --- a/crates/server/src/management/iam_user.rs +++ b/crates/server/src/management/iam_user.rs @@ -231,15 +231,8 @@ pub async fn tag_user( } for tag in &body.tags { - if tag.key.is_empty() || tag.key.len() > 128 { - return (StatusCode::BAD_REQUEST, "tag key must be 1-128 characters").into_response(); - } - if tag.value.len() > 256 { - return ( - StatusCode::BAD_REQUEST, - "tag value must not exceed 256 characters", - ) - .into_response(); + if tag.key.is_empty() { + return (StatusCode::BAD_REQUEST, "tag key must not be empty").into_response(); } } @@ -249,6 +242,9 @@ pub async fn tag_user( .map(|t| (t.key.clone(), t.value.clone())) .collect(); + if let Err(e) = super::validate_iam_tags(&tags) { + return (StatusCode::BAD_REQUEST, e).into_response(); + } if let Some(dup) = super::duplicate_tag_key_ci(&tags) { return ( StatusCode::BAD_REQUEST, diff --git a/crates/server/src/management/mod.rs b/crates/server/src/management/mod.rs index a453d331f..daa1c3eca 100755 --- a/crates/server/src/management/mod.rs +++ b/crates/server/src/management/mod.rs @@ -247,6 +247,45 @@ pub(crate) fn duplicate_tag_key_ci(tags: &[(String, String)]) -> Option None } +/// Validate a tag set against the live IAM service's tag rules, returning an +/// error string for the first violation. Each key is 1-128 characters and each +/// value at most 256 characters (counted in characters, not bytes), drawn from +/// IAM's grammar (`[\p{L}\p{Z}\p{N}_.:/=+\-@]`, which excludes combining +/// marks and most punctuation), and a key must not use the reserved `aws:` +/// prefix. The empty value and a leading or trailing space are allowed, matching +/// the service. +pub(crate) fn validate_iam_tags(tags: &[(String, String)]) -> Result<(), String> { + for (k, v) in tags { + let n = k.chars().count(); + if n == 0 || n > 128 { + return Err("tag key must be 1-128 characters".to_owned()); + } + if k.to_ascii_lowercase().starts_with("aws:") { + return Err("tag key must not begin with the reserved prefix aws:".to_owned()); + } + if !k.chars().all(is_iam_tag_char) { + return Err(format!("tag key contains an invalid character: {k}")); + } + if v.chars().count() > 256 { + return Err("tag value must not exceed 256 characters".to_owned()); + } + if !v.chars().all(is_iam_tag_char) { + return Err("tag value contains an invalid character".to_owned()); + } + } + Ok(()) +} + +/// One character of IAM's tag grammar: a Unicode letter or number, an ASCII +/// space, or one of `_ . : / = + - @`. Combining marks and other punctuation +/// are excluded, matching the service's `[\p{L}\p{Z}\p{N}_.:/=+\-@]`. +fn is_iam_tag_char(c: char) -> bool { + c.is_alphabetic() + || c.is_numeric() + || c == ' ' + || matches!(c, '_' | '.' | ':' | '/' | '=' | '+' | '-' | '@') +} + /// Validate admin name: 1-64 chars, alphanumeric, hyphens, or underscores. fn is_valid_admin_name(name: &str) -> bool { !name.is_empty() @@ -255,3 +294,51 @@ fn is_valid_admin_name(name: &str) -> bool { .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } + +#[cfg(test)] +mod tag_validation_tests { + use super::{duplicate_tag_key_ci, validate_iam_tags}; + + fn t(k: &str, v: &str) -> Vec<(String, String)> { + vec![(k.to_owned(), v.to_owned())] + } + + #[test] + fn accepts_service_valid_tags() { + assert!(validate_iam_tags(&t("team", "prod")).is_ok()); + assert!(validate_iam_tags(&t("a_.:/=+-@b", "v_.:/=+-@")).is_ok()); + assert!(validate_iam_tags(&t("my team", "east coast")).is_ok()); // space ok + assert!(validate_iam_tags(&t("team", "")).is_ok()); // empty value ok + assert!(validate_iam_tags(&t(" team", "prod")).is_ok()); // leading space ok + assert!(validate_iam_tags(&t("equipe\u{00e9}", "prod")).is_ok()); // precomposed letter + } + + #[test] + fn rejects_service_invalid_tags() { + assert!(validate_iam_tags(&t("Equipe\u{0301}", "prod")).is_err()); // combining mark + assert!(validate_iam_tags(&t("a#b", "prod")).is_err()); + assert!(validate_iam_tags(&t("a!b", "prod")).is_err()); + assert!(validate_iam_tags(&t("aws:team", "prod")).is_err()); // reserved prefix + assert!(validate_iam_tags(&t("AWS:team", "prod")).is_err()); // case-insensitive prefix + assert!(validate_iam_tags(&t(&"k".repeat(129), "prod")).is_err()); // key too long + assert!(validate_iam_tags(&t("team", &"v".repeat(257))).is_err()); // value too long + assert!(validate_iam_tags(&t("team", "v\u{0301}")).is_err()); // combining mark in value + } + + #[test] + fn duplicate_ascii_case_key_detected() { + let dup = duplicate_tag_key_ci(&[ + ("Team".to_owned(), "1".to_owned()), + ("team".to_owned(), "2".to_owned()), + ]); + assert_eq!(dup.as_deref(), Some("team")); + // mass and sharp-s differ by more than ASCII case: distinct, allowed. + assert!( + duplicate_tag_key_ci(&[ + ("mass".to_owned(), "A".to_owned()), + ("ma\u{00df}".to_owned(), "B".to_owned()), + ]) + .is_none() + ); + } +} diff --git a/tests/test_abac.py b/tests/test_abac.py index 7ba34bf2c..92d25fce3 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -893,6 +893,21 @@ def outcomes_for(value): finally: self.mgmt.delete_user(self.account_id, user) + def test_rejects_invalid_and_dup_tag_keys_at_write(self): + # A combining-mark tag key and the reserved aws: prefix are rejected + # at write (ValidationError on the service); two keys differing only + # in ASCII case are a duplicate. + user = f"gk-tv-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, user, "TestPass123!").status_code == 201 + try: + assert self.mgmt.tag_user(self.account_id, user, {"Equipe\u0301": "x"}).status_code == 400 + assert self.mgmt.tag_user(self.account_id, user, {"aws:team": "x"}).status_code == 400 + assert self.mgmt.tag_user(self.account_id, user, {"Team": "1", "team": "2"}).status_code == 400 + # a valid tag is accepted + assert self.mgmt.tag_user(self.account_id, user, {"team": "prod"}).status_code in (200, 201, 204) + finally: + self.mgmt.delete_user(self.account_id, user) + # --------------------------------------------------------------------------- # aws:TokenIssueTime (role sessions) and aws:userid across recreate From cabfd0629922b9dbede5e0938061d67fac383e1d Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 08:36:37 +0000 Subject: [PATCH 48/58] perf(auth): ASCII fast path in case_fold case_fold allocated and binary-searched the fold table for every character of every condition key name and tag key, including the all-ASCII keys that are the overwhelmingly common case. Add an ASCII fast path: an all-ASCII input folds to its ASCII lowercase directly, since every ASCII letter's fold is its ASCII lowercase. The byte-exact tag lookup already resolves the common case without folding at all. Regenerated through devtools/generate-casefold-table; a unit test pins the fast path's equivalence with the table on ASCII input. --- crates/auth/src/policy/casefold.rs | 29 +++++++++++++++++++++++++---- devtools/generate-casefold-table | 24 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/crates/auth/src/policy/casefold.rs b/crates/auth/src/policy/casefold.rs index 68a21e9d0..37ddfd955 100644 --- a/crates/auth/src/policy/casefold.rs +++ b/crates/auth/src/policy/casefold.rs @@ -1554,6 +1554,12 @@ pub(crate) static CASE_FOLD: &[(u32, &str)] = &[ /// itself. #[must_use] pub fn case_fold(s: &str) -> String { + // Fast path: an all-ASCII key (the overwhelmingly common case) folds + // to its ASCII lowercase without touching the table, since every ASCII + // letter's fold is its ASCII lowercase. + if s.is_ascii() { + return s.to_ascii_lowercase(); + } let mut out = String::with_capacity(s.len()); for c in s.chars() { let cp = c as u32; @@ -1578,10 +1584,7 @@ mod tests { #[test] fn table_sorted_and_unique() { for w in CASE_FOLD.windows(2) { - assert!( - w[0].0 < w[1].0, - "table must be sorted and unique by code point" - ); + assert!(w[0].0 < w[1].0, "table must be sorted and unique by code point"); } } @@ -1605,4 +1608,22 @@ mod tests { // Dotted I folds to 'i' plus a combining dot, not plain 'i'. assert_eq!(case_fold("\u{0130}"), "i\u{0307}"); } + + #[test] + fn ascii_fast_path_matches_table() { + // The ASCII fast path must agree with a per-code-point table fold + // on every ASCII string. + for s in ["", "AWS:SecureTransport", "dynamodb:LeadingKeys", + "aws:PrincipalTag/Team", "MiXeD123_.-/", "abc"] { + let table: String = s.chars().map(|c| { + let cp = c as u32; + match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) { + Ok(i) => CASE_FOLD[i].1.to_owned(), + Err(_) => c.to_string(), + } + }).collect(); + assert_eq!(case_fold(s), table, "fast path diverged on {s:?}"); + assert_eq!(case_fold(s), s.to_ascii_lowercase()); + } + } } diff --git a/devtools/generate-casefold-table b/devtools/generate-casefold-table index 6ccb195b0..d1d4e7d02 100755 --- a/devtools/generate-casefold-table +++ b/devtools/generate-casefold-table @@ -69,6 +69,12 @@ def main(): w("/// itself.") w("#[must_use]") w("pub fn case_fold(s: &str) -> String {") + w(" // Fast path: an all-ASCII key (the overwhelmingly common case) folds") + w(" // to its ASCII lowercase without touching the table, since every ASCII") + w(" // letter's fold is its ASCII lowercase.") + w(" if s.is_ascii() {") + w(" return s.to_ascii_lowercase();") + w(" }") w(" let mut out = String::with_capacity(s.len());") w(" for c in s.chars() {") w(" let cp = c as u32;") @@ -117,6 +123,24 @@ def main(): w(" // Dotted I folds to 'i' plus a combining dot, not plain 'i'.") w(" assert_eq!(case_fold(\"\\u{0130}\"), \"i\\u{0307}\");") w(" }") + w("") + w(" #[test]") + w(" fn ascii_fast_path_matches_table() {") + w(" // The ASCII fast path must agree with a per-code-point table fold") + w(" // on every ASCII string.") + w(" for s in [\"\", \"AWS:SecureTransport\", \"dynamodb:LeadingKeys\",") + w(" \"aws:PrincipalTag/Team\", \"MiXeD123_.-/\", \"abc\"] {") + w(" let table: String = s.chars().map(|c| {") + w(" let cp = c as u32;") + w(" match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) {") + w(" Ok(i) => CASE_FOLD[i].1.to_owned(),") + w(" Err(_) => c.to_string(),") + w(" }") + w(" }).collect();") + w(" assert_eq!(case_fold(s), table, \"fast path diverged on {s:?}\");") + w(" assert_eq!(case_fold(s), s.to_ascii_lowercase());") + w(" }") + w(" }") w("}") w("") sys.stdout.write("\n".join(out)) From 6dd89a173affd4f76e426524f7e440d7f237038c Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 08:37:22 +0000 Subject: [PATCH 49/58] docs: upgrade notes for Unicode key folding, deterministic tags, tag rules, CIDR forms Add an upgrade-manual entry: a stored identity policy whose condition key name or tag key contains a non-ASCII letter that folds to a resolvable key now evaluates it on the request path (a Deny applies, a negated Allow no longer grants); the trust path matches ASCII case only. Fold-colliding tag keys resolve deterministically (exact key, then smallest by code point). Tag writes now apply IAM's key/value charset, length, aws: prefix and ASCII-case uniqueness rules, with tags already stored left in place. IpAddress accepts a leading-zero octet as decimal and rejects an IPv4-mapped IPv6 literal. --- docs/manuals/07-upgrade-manual.md | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 8bcbc8832..4cf556cfa 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -297,6 +297,40 @@ policies are all well formed is unaffected. policy at write and drops the statement at evaluation; ExtendDB matches this, so a trust policy is not rejected for a malformed CIDR. No catalog migration is required. +### IAM: condition key names and tag keys, and more CIDR forms + +**Condition key names and tag keys fold under Unicode on the request path.** A +stored identity policy whose condition key name or tag key contains a non-ASCII +letter that folds to a resolvable key (for example the long s folding to `s`, the +st ligature to `st`, or the sharp s to `ss`) now resolves that key on a DynamoDB +request, matching the service. For such a stored policy a `Deny` gated on the +Unicode-spelled key now applies where it previously did not, and a negated `Allow` +gated on it no longer grants. A policy using only ASCII key names is unaffected. +The trust (`AssumeRole`) path matches key names and tag keys ASCII-case only and +does not fold, as the service does; a trust policy whose condition key name or tag +key contains a non-ASCII letter has that key read as absent on the trust path. + +**Tag keys that fold to the same string resolve deterministically.** If a +principal carries two tag keys that fold to the same string (for example `mass` +and `ma\u{00df}`), `aws:PrincipalTag/` now resolves to the byte-exact stored +key, then to the smallest fold-matching key by code point, every time, rather +than at random. A policy naming one of the keys exactly is unaffected. + +**Tag write rules match the service.** `TagUser`, `TagRole` and `AssumeRole` +session tags now reject a tag key or value outside IAM's grammar (a combining +mark, `#`, `!`, and other punctuation), a key over 128 or value over 256 +characters, the reserved `aws:` key prefix, and two keys that differ only in +ASCII case. A tag already stored that the new rule would reject stays in place and +keeps evaluating as before; only new writes are checked. Re-tagging a principal +that carries such a tag requires correcting the tag first. + +**More CIDR forms.** `IpAddress`/`NotIpAddress` now accept a leading-zero octet +and read it as decimal (`010.0.0.0/8` is `10.0.0.0/8`, not octal), matching the +service, and reject an IPv4-mapped IPv6 literal (`::ffff:a.b.c.d`) at the identity +and boundary write (the service rejects it; accepting it would store an +`IpAddress` `Deny` that silently never matched). No catalog migration is required +for any of these changes. + --- ## License From 71461296b738771a9111e47abac07692debaaf31 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 08:53:21 +0000 Subject: [PATCH 50/58] chore(auth): format the generated case-fold table through rustfmt The generator now pipes its output through rustfmt (edition 2024) so the generated crates/auth/src/policy/casefold.rs is rustfmt-clean and regeneration is idempotent with cargo fmt --check. --- crates/auth/src/policy/casefold.rs | 32 ++++++++++++++++++++---------- devtools/generate-casefold-table | 14 ++++++++++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/auth/src/policy/casefold.rs b/crates/auth/src/policy/casefold.rs index 37ddfd955..e9d7502a7 100644 --- a/crates/auth/src/policy/casefold.rs +++ b/crates/auth/src/policy/casefold.rs @@ -1584,7 +1584,10 @@ mod tests { #[test] fn table_sorted_and_unique() { for w in CASE_FOLD.windows(2) { - assert!(w[0].0 < w[1].0, "table must be sorted and unique by code point"); + assert!( + w[0].0 < w[1].0, + "table must be sorted and unique by code point" + ); } } @@ -1613,15 +1616,24 @@ mod tests { fn ascii_fast_path_matches_table() { // The ASCII fast path must agree with a per-code-point table fold // on every ASCII string. - for s in ["", "AWS:SecureTransport", "dynamodb:LeadingKeys", - "aws:PrincipalTag/Team", "MiXeD123_.-/", "abc"] { - let table: String = s.chars().map(|c| { - let cp = c as u32; - match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) { - Ok(i) => CASE_FOLD[i].1.to_owned(), - Err(_) => c.to_string(), - } - }).collect(); + for s in [ + "", + "AWS:SecureTransport", + "dynamodb:LeadingKeys", + "aws:PrincipalTag/Team", + "MiXeD123_.-/", + "abc", + ] { + let table: String = s + .chars() + .map(|c| { + let cp = c as u32; + match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) { + Ok(i) => CASE_FOLD[i].1.to_owned(), + Err(_) => c.to_string(), + } + }) + .collect(); assert_eq!(case_fold(s), table, "fast path diverged on {s:?}"); assert_eq!(case_fold(s), s.to_ascii_lowercase()); } diff --git a/devtools/generate-casefold-table b/devtools/generate-casefold-table index d1d4e7d02..90eeb610c 100755 --- a/devtools/generate-casefold-table +++ b/devtools/generate-casefold-table @@ -18,6 +18,7 @@ recorded in the generated file header. Run from the repository root: No Unicode data file is vendored; the data comes from the Python standard library. """ +import subprocess import sys import unicodedata @@ -143,7 +144,18 @@ def main(): w(" }") w("}") w("") - sys.stdout.write("\n".join(out)) + source = "\n".join(out) + # Format through rustfmt so the generated file is rustfmt-clean and + # regeneration is idempotent with `cargo fmt --check`. + try: + r = subprocess.run( + ["rustfmt", "--edition", "2024", "--emit", "stdout"], + input=source, capture_output=True, text=True, check=True, + ) + source = r.stdout + except (FileNotFoundError, subprocess.CalledProcessError): + pass + sys.stdout.write(source) if __name__ == "__main__": From d7b575851737766bf6eb34ac27a45caa88ae750a Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 09:35:31 +0000 Subject: [PATCH 51/58] fix(auth): validate tag charset by Unicode general category IAM's tag grammar is [\p{L}\p{Z}\p{N}_.:/=+\-@]: any Unicode Letter, Number or Separator, plus the listed symbols. The previous is_iam_tag_char approximated these with char::is_alphabetic, char::is_numeric and an ASCII-space literal, which diverges from the service in both directions. is_alphabetic is the Alphabetic property, a superset of \p{L} that admits Other_Alphabetic combining marks such as U+0345 (category Mn) that IAM rejects, so ExtendDB stored a tag key the service refuses. An ASCII-space-only separator check rejected the \p{Z} separators U+00A0, U+2002, U+2003, U+3000, U+2028, U+2029 and U+202F that IAM accepts, so ExtendDB refused keys the service stores and reversed its own prior behaviour. Match the exact general categories through unicode-properties' general_category_group (Letter = Lu Ll Lt Lm Lo, Number = Nd Nl No, Separator = Zs Zl Zp), already in the lock file. Applied at tag keys and values for tag_user, tag_role and the AssumeRole session tags. Unit tests for the seven separators, Nl and No numbers, the two Mn marks and an emoji symbol. --- Cargo.lock | 1 + crates/server/Cargo.toml | 3 ++ crates/server/src/management/mod.rs | 53 ++++++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b35d2c098..aa6f2061c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1378,6 +1378,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "unicode-properties", "uuid", ] diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 85bbefb3e..da5da4f74 100755 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -32,6 +32,9 @@ uuid = { workspace = true } metrics = { workspace = true } anyhow = { workspace = true } bcrypt = { workspace = true } +# IAM tag charset validation needs the exact Unicode general categories +# (\p{L}, \p{N}, \p{Z}); char::is_alphabetic / is_numeric are not those sets. +unicode-properties = "0.1.4" base64 = { workspace = true } time = { workspace = true } aes-gcm = { workspace = true } diff --git a/crates/server/src/management/mod.rs b/crates/server/src/management/mod.rs index daa1c3eca..431867942 100755 --- a/crates/server/src/management/mod.rs +++ b/crates/server/src/management/mod.rs @@ -279,11 +279,25 @@ pub(crate) fn validate_iam_tags(tags: &[(String, String)]) -> Result<(), String> /// One character of IAM's tag grammar: a Unicode letter or number, an ASCII /// space, or one of `_ . : / = + - @`. Combining marks and other punctuation /// are excluded, matching the service's `[\p{L}\p{Z}\p{N}_.:/=+\-@]`. +/// Whether `c` is admissible in an IAM tag key or value. +/// +/// IAM's tag grammar is `[\p{L}\p{Z}\p{N}_.:/=+\-@]`: any Unicode Letter +/// (Lu, Ll, Lt, Lm, Lo), Number (Nd, Nl, No) or Separator (Zs, Zl, Zp), plus the +/// listed symbols. These are Unicode general categories, matched here through +/// `general_category_group`, not `char::is_alphabetic` / `char::is_numeric`: +/// `is_alphabetic` is the Alphabetic property, a superset of `\p{L}` that admits +/// Other_Alphabetic combining marks such as U+0345 (category Mn) that IAM +/// rejects, and an ASCII-space-only separator check rejects the `\p{Z}` +/// separators (U+00A0, U+2002, U+3000 and the rest) that IAM accepts. fn is_iam_tag_char(c: char) -> bool { - c.is_alphabetic() - || c.is_numeric() - || c == ' ' - || matches!(c, '_' | '.' | ':' | '/' | '=' | '+' | '-' | '@') + use unicode_properties::{GeneralCategoryGroup, UnicodeGeneralCategory}; + matches!(c, '_' | '.' | ':' | '/' | '=' | '+' | '-' | '@') + || matches!( + c.general_category_group(), + GeneralCategoryGroup::Letter + | GeneralCategoryGroup::Number + | GeneralCategoryGroup::Separator + ) } /// Validate admin name: 1-64 chars, alphanumeric, hyphens, or underscores. @@ -325,6 +339,37 @@ mod tag_validation_tests { assert!(validate_iam_tags(&t("team", "v\u{0301}")).is_err()); // combining mark in value } + #[test] + fn tag_charset_matches_unicode_general_categories() { + // Separators (Zs, Zl, Zp) are accepted, matching live IAM. + for sep in [ + '\u{00a0}', // no-break space (Zs) + '\u{2002}', // en space (Zs) + '\u{2003}', // em space (Zs) + '\u{3000}', // ideographic space (Zs) + '\u{2028}', // line separator (Zl) + '\u{2029}', // paragraph separator (Zp) + '\u{202f}', // narrow no-break space (Zs) + ] { + let k = format!("a{sep}b"); + assert!(validate_iam_tags(&t(&k, "v")).is_ok(), "sep {sep:?} key"); + assert!(validate_iam_tags(&t("k", &k)).is_ok(), "sep {sep:?} value"); + } + // Numbers beyond Nd: Nl and No are accepted. + assert!(validate_iam_tags(&t("a\u{2160}b", "v")).is_ok()); // roman numeral one (Nl) + assert!(validate_iam_tags(&t("a\u{00b2}b", "v")).is_ok()); // superscript two (No) + assert!(validate_iam_tags(&t("a\u{00bd}b", "v")).is_ok()); // vulgar fraction half (No) + // Marks (Mn) are rejected even when Alphabetic (Other_Alphabetic), + // matching live IAM. + assert!(validate_iam_tags(&t("a\u{0345}b", "v")).is_err()); // ypogegrammeni (Mn, Other_Alphabetic) + assert!(validate_iam_tags(&t("a\u{0301}b", "v")).is_err()); // combining acute (Mn) + assert!(validate_iam_tags(&t("k", "v\u{0345}")).is_err()); // in a value too + // Symbols are rejected: an emoji (So). + assert!(validate_iam_tags(&t("a\u{1f600}b", "v")).is_err()); + // Control: ASCII space (Zs) still accepted. + assert!(validate_iam_tags(&t("a b", "v")).is_ok()); + } + #[test] fn duplicate_ascii_case_key_detected() { let dup = duplicate_tag_key_ci(&[ From 76961a0184df405ad62ae5c44ee050b16ff26e76 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 09:39:34 +0000 Subject: [PATCH 52/58] fix(auth): reject a policy Sid outside the service charset Live IAM accepts a Sid only when it is empty or entirely ASCII letters and digits, regardless of length, and rejects a hyphen, underscore, space, dot or any non-ASCII character (including Latin-1 such as an accented letter) with MalformedPolicyDocument, on both CreateRole (trust) and PutUserPolicy (identity). ExtendDB stored any Sid. Apply the measured rule: from_json rejects such a Sid on the identity-policy write and read path, and validate_trust_policy rejects it on the trust write. Unit tests on both paths for the accepted (alphanumeric, empty, long) and rejected (hyphen, underscore, space, dot, accented) values. --- crates/auth/src/policy/document.rs | 49 +++++++++++++++++++++ crates/server/src/management/assume_role.rs | 37 ++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index cfa2ecf4c..ce6ee6e6a 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -175,6 +175,7 @@ impl PolicyDocument { let version = raw["Version"].as_str().unwrap_or("2012-10-17").to_owned(); let statements = parse_statements(&raw["Statement"])?; validate_statement_cidrs(&statements)?; + validate_statement_sids(&statements)?; Ok(Self { version, statements, @@ -225,6 +226,28 @@ fn validate_statement_cidrs(statements: &[Statement]) -> Result<(), PolicyParseE Ok(()) } +/// Reject a `Sid` outside the service's charset. +/// +/// Measured on live IAM (`PutUserPolicy` and `CreateRole`): a `Sid` is accepted +/// only when it is empty or entirely ASCII letters and digits (`[A-Za-z0-9]`), +/// regardless of length; a hyphen, underscore, space, dot or any non-ASCII +/// character (including Latin-1 such as an accented letter) is rejected with +/// `MalformedPolicyDocument`. `from_json` is the identity-policy write and read +/// path, so this rejects such a `Sid` at write and fails a stored identity +/// policy closed at read. +fn validate_statement_sids(statements: &[Statement]) -> Result<(), PolicyParseError> { + for stmt in statements { + if let Some(sid) = &stmt.sid + && !sid.chars().all(|c| c.is_ascii_alphanumeric()) + { + return Err(PolicyParseError(format!( + "invalid Sid (must be ASCII letters and digits only): {sid}" + ))); + } + } + Ok(()) +} + /// Parse the Statement field — may be a single object or an array. fn parse_statements(value: &Value) -> Result, PolicyParseError> { match value { @@ -634,6 +657,32 @@ mod tests { assert!(PolicyDocument::from_json(json).is_ok()); } + #[test] + fn reject_non_alphanumeric_sid() { + for sid in ["Sid-X", "Sid_X", "Sid X", "Sid.X", "Sid\u{00e9}"] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Sid":"{sid}","Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*"}}]}}"# + ); + assert!( + PolicyDocument::from_json(&json).is_err(), + "should reject Sid {sid:?}" + ); + } + } + + #[test] + fn accept_alphanumeric_or_empty_sid() { + for sid in ["Abc123", "", "aaaaaaaaaaaaaaaaaaaa"] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Sid":"{sid}","Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*"}}]}}"# + ); + assert!( + PolicyDocument::from_json(&json).is_ok(), + "should accept Sid {sid:?}" + ); + } + } + #[test] fn reject_miscased_operator_names() { // The live service treats operator names, set qualifiers and the diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index 579d423b3..0be4b5295 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -736,6 +736,17 @@ pub(crate) fn validate_trust_policy(trust_policy: &Value) -> Result<(), String> if json_has_char_above_latin1(trust_policy) { return Err("Trust policy must not contain a character above U+00FF".to_owned()); } + // Measured on live IAM CreateRole: a Sid is accepted only when it is empty + // or entirely ASCII letters and digits; a hyphen, underscore, space, dot or + // non-ASCII character is rejected with MalformedPolicyDocument. Identity + // PutUserPolicy applies the same rule (see validate_statement_sids). + for stmt in &statements { + if let Some(sid) = stmt.get("Sid").and_then(serde_json::Value::as_str) + && !sid.chars().all(|c| c.is_ascii_alphanumeric()) + { + return Err("Trust policy Sid must be ASCII letters and digits only".to_owned()); + } + } for stmt in &statements { validate_trust_statement(stmt)?; } @@ -1640,6 +1651,32 @@ mod tests { ok(json!({"Version": "2012-10-17", "Statement": allow_alice()})); } + #[test] + fn validate_rejects_non_alphanumeric_sid() { + for sid in ["Sid-X", "Sid_X", "Sid X", "Sid.X", "Sid\u{00e9}"] { + let doc = json!({"Version": "2012-10-17", + "Statement": [{"Sid": sid, "Effect": "Allow", + "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}); + assert!( + validate_trust_policy(&doc).is_err(), + "should reject Sid {sid:?}" + ); + } + } + + #[test] + fn validate_accepts_alphanumeric_or_empty_sid() { + for sid in ["Abc123", "", "aaaaaaaaaa"] { + let doc = json!({"Version": "2012-10-17", + "Statement": [{"Sid": sid, "Effect": "Allow", + "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}); + assert!( + validate_trust_policy(&doc).is_ok(), + "should accept Sid {sid:?}" + ); + } + } + #[test] fn validate_accepts_lowercase_effect() { ok( From 8b780f06a4b8ff8bfa8863b811f8d69b749a6067 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 09:49:04 +0000 Subject: [PATCH 53/58] test(auth): make the fold-collision wire test a fresh user per check The collision guard tagged one user and issued 20 GetItem calls. A principal's tag map is cached per user, so all 20 calls saw a single HashMap order and the test passed on the unfixed code about a third of the time, missing the regression. Resolve a fresh user for each of 20 checks so each gets its own map order, and cover both the byte-exact key (aws:PrincipalTag/mass) and the no-exact folding key (aws:PrincipalTag/MASS, which folds to mass and must pick the smallest fold-matching key by code point). Against the unfixed binary both checks fail on every one of five runs; against the fixed head both pass. --- tests/test_abac.py | 62 ++++++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/tests/test_abac.py b/tests/test_abac.py index 92d25fce3..62f4d98ef 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -864,35 +864,53 @@ class TestTagCollisionDeterministic(_TaggedTableBase): SHARP = "\u00df" - def test_fold_colliding_tag_resolves_exact_key(self, table): + CHECKS = 20 + + def _resolve_once(self, table, policy_key): + """Create a FRESH user tagged {mass:A, ma\u00df:B}, allow GetItem when + aws:PrincipalTag/ == A, and return "ALLOW" or "DENY". + + A fresh user per check is the point: the principal's tag map is cached + per user, so one user sees a single HashMap order and hides the + nondeterminism. Each fresh user gets its own order, so resolution that + is not pinned to the exact (then smallest-by-code-point) key shows up as + a DENY on some fraction of users. + """ user = f"gk-coll-{uuid.uuid4().hex[:8]}" ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) try: - r = self.mgmt.tag_user(self.account_id, user, {"mass": "A", f"ma{self.SHARP}": "B"}) - assert r.status_code in (200, 201, 204), r.text + assert self.mgmt.tag_user( + self.account_id, user, {"mass": "A", f"ma{self.SHARP}": "B"} + ).status_code in (200, 201, 204) + assert self.mgmt.put_user_policy(self.account_id, user, "p", { + "Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringEquals": { + f"aws:PrincipalTag/{policy_key}": "A"}}}]}).status_code == 204 client = _make_client(self.endpoint, ak, sk, self.region) - - def outcomes_for(value): - assert self.mgmt.put_user_policy(self.account_id, user, "p", { - "Version": "2012-10-17", "Statement": [{ - "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", - "Condition": {"StringEquals": {"aws:PrincipalTag/mass": value}}}]}).status_code == 204 - seen = set() - for _ in range(20): - try: - client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) - seen.add("ALLOW") - except ClientError as exc: - assert exc.response["Error"]["Code"] == "AccessDeniedException" - seen.add("DENY") - return seen - - # Exact key mass=A resolves A every time; =B denies every time. - assert outcomes_for("A") == {"ALLOW"} - assert outcomes_for("B") == {"DENY"} + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException" + return "DENY" finally: self.mgmt.delete_user(self.account_id, user) + def test_fold_colliding_exact_key_resolves_deterministically(self, table): + # aws:PrincipalTag/mass must resolve the byte-exact stored key (mass=A), + # so every one of CHECKS fresh users allows. + results = [self._resolve_once(table, "mass") for _ in range(self.CHECKS)] + assert set(results) == {"ALLOW"}, results + + def test_fold_colliding_no_exact_key_resolves_deterministically(self, table): + # aws:PrincipalTag/MASS has no byte-exact key; it folds to "mass", + # colliding with mass and ma\u00df. The rule picks the smallest + # fold-matching key by code point (mass < ma\u00df), so A resolves and + # every one of CHECKS fresh users allows. + results = [self._resolve_once(table, "MASS") for _ in range(self.CHECKS)] + assert set(results) == {"ALLOW"}, results + def test_rejects_invalid_and_dup_tag_keys_at_write(self): # A combining-mark tag key and the reserved aws: prefix are rejected # at write (ValidationError on the service); two keys differing only From ec979888ae4818dd2623ddfdb6bfbcdfb384d2c0 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 09:50:26 +0000 Subject: [PATCH 54/58] test(auth): trust-path folding guard uses keys that fold to real keys The trust-path folding guard gated on aws:SecureTran[sharp-s]port, which case-folds to aws:securetranssport and never equals the target key, so it passed on a folding build too and did not exercise the behaviour change. Replace it with two keys that fold to real keys: sts:RoleSe[sharp-s]ionName folds to sts:rolesessionname and aws:PrincipalTag/cla[sharp-s] folds to aws:PrincipalTag/class for a caller tagged class=prod. Both would match and grant under Unicode folding; on the ASCII-only trust path the key reads absent and the assume is denied. Verified: both deny on the fixed head and both grant (assertion fails) on the unfixed binary. --- tests/test_auth_integration.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 3bd36430a..b34fe0891 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -1276,15 +1276,34 @@ def test_keyname_case_insensitive_externalid_lowercase(self): # -- Resolved request-wide keys on the trust path (T8) ------------------ - def test_trust_unicode_key_does_not_grant(self): - # The trust path matches ASCII-case only (no Unicode folding). A - # Latin-1 key (sharp s) is stored but reads absent, so an Allow gated on - # aws:SecureTran[sharp-s]port does not grant, matching live STS (which - # also leaves it absent). + def test_trust_folding_rolesessionname_key_does_not_grant(self): + # The trust path matches ASCII-case only (no Unicode folding). The + # key "sts:RoleSe\u00dfionName" (sharp s) case-folds to + # "sts:rolesessionname", the real key, so under Unicode folding an Allow + # gated on it would match the session name "bb-session" and grant. On + # the ASCII-only path the key reads absent, the Allow does not fire, and + # the assume is denied, matching live STS. This folds to a real key, so + # it fails on a folding build and passes here (unlike the old sharp-s + # SecureTransport key, which folds to "...transsport" and never matched). user, arn = self._caller() try: trust = _trust(_stmt("Allow", {"AWS": arn}, - condition={"Bool": {"aws:SecureTran\u00dfport": "true"}})) + condition={"StringEquals": { + "sts:RoleSe\u00dfionName": "bb-session"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_trust_folding_principaltag_key_does_not_grant(self): + # Companion row: "aws:PrincipalTag/cla\u00df" folds to + # "aws:PrincipalTag/class". The caller is tagged class=prod, so under + # Unicode folding an Allow gated on it would match and grant; on the + # ASCII-only path the key reads absent and the assume is denied. + user, arn = self._caller(tags={"class": "prod"}) + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": { + "aws:PrincipalTag/cla\u00df": "prod"}})) assert self._assume(trust, arn) == DENY finally: self.mgmt.delete_user(self.account_id, user) From 8343c9ca8cbd8a7eb76b91927fe623866707ae96 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 09:51:08 +0000 Subject: [PATCH 55/58] docs: correct tag re-tagging note and add tag charset and Sid notes Replace the upgrade-manual sentence saying re-tagging a principal requires correcting a stored tag the new rule rejects: only the tags named in a request are validated, so a stored tag does not block setting other tags and is rejected only when a later request includes it, which is what both ExtendDB and IAM do. State the tag grammar as the exact Unicode general categories (Letter, Number, Separator) rather than an ASCII approximation. Add a note that a policy Sid is accepted only when empty or ASCII alphanumeric on both the trust and identity-policy writes, and a stored policy with a non-conforming Sid keeps evaluating since the Sid is a label. --- docs/manuals/07-upgrade-manual.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index 4cf556cfa..bd98e8b7a 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -317,12 +317,25 @@ key, then to the smallest fold-matching key by code point, every time, rather than at random. A policy naming one of the keys exactly is unaffected. **Tag write rules match the service.** `TagUser`, `TagRole` and `AssumeRole` -session tags now reject a tag key or value outside IAM's grammar (a combining -mark, `#`, `!`, and other punctuation), a key over 128 or value over 256 -characters, the reserved `aws:` key prefix, and two keys that differ only in -ASCII case. A tag already stored that the new rule would reject stays in place and -keeps evaluating as before; only new writes are checked. Re-tagging a principal -that carries such a tag requires correcting the tag first. +session tags now reject a tag key or value outside IAM's grammar +`[\p{L}\p{Z}\p{N}_.:/=+\-@]`: any Unicode Letter (Lu, Ll, Lt, Lm, Lo), Number +(Nd, Nl, No) or Separator (Zs, Zl, Zp) by general category, plus the listed +symbols. A combining mark (such as U+0345, category Mn), `#`, `!` and other +punctuation, a key over 128 or value over 256 characters, the reserved `aws:` +key prefix, and two keys that differ only in ASCII case are all rejected. Only +the tags named in a given request are validated, so a tag already stored that +the new rule would reject stays in place and keeps evaluating, and does not +block setting other tags on the same principal; it is rejected only when a later +request includes it again. Live IAM behaves the same way: a tag request +validates only its own tags. + +**Policy Sid charset.** A policy `Sid` is now accepted only when it is empty or +entirely ASCII letters and digits, on both the trust write (`CreateRole`) and +the identity-policy write (`PutUserPolicy`), matching the service, which rejects +any other `Sid` (a hyphen, underscore, space, dot or non-ASCII character) with +`MalformedPolicyDocument`. The `Sid` is a statement label and does not affect +evaluation, so a policy already stored with a non-conforming `Sid` keeps +evaluating unchanged; only a new write is checked. **More CIDR forms.** `IpAddress`/`NotIpAddress` now accept a leading-zero octet and read it as decimal (`010.0.0.0/8` is `10.0.0.0/8`, not octal), matching the From 516f4ef9b46c22804f6b942fafae315d1e1a773d Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 10:19:48 +0000 Subject: [PATCH 56/58] fix(auth): check the policy Sid charset at write only The Sid charset check ran inside PolicyDocument::from_json, which is also the request path for stored identity policies. A policy stored before the check existed with a Sid such as Allow-Read then failed to parse on every request, and every request by that principal was denied, including those a different valid policy allowed. The upgrade manual says such a policy keeps evaluating. Move the check to PolicyDocument::from_json_for_write, used by the inline and boundary policy writes. from_json keeps the checks that apply when a stored policy is read. Unit tests assert both: a non-conforming Sid is rejected at write and still parses on the request path. --- crates/auth/src/policy/document.rs | 37 +++++++++++++++---- crates/server/src/management/iam_policy.rs | 4 +- .../src/management/permissions_boundary.rs | 4 +- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index ce6ee6e6a..dedcd5e2a 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -175,12 +175,30 @@ impl PolicyDocument { let version = raw["Version"].as_str().unwrap_or("2012-10-17").to_owned(); let statements = parse_statements(&raw["Statement"])?; validate_statement_cidrs(&statements)?; - validate_statement_sids(&statements)?; Ok(Self { version, statements, }) } + + /// Parse a policy document for a write (`PutUserPolicy`, `PutRolePolicy`, + /// a managed or boundary policy) and apply the checks the service applies + /// only when a policy is written. + /// + /// Everything `from_json` checks, plus the `Sid` charset. The `Sid` check + /// runs at write only: a `Sid` is a statement label that does not affect + /// evaluation, so a policy stored before this check existed must keep + /// parsing on the request path rather than fail the principal closed. + /// + /// # Errors + /// + /// Returns `PolicyParseError` if `from_json` rejects the document or a + /// statement `Sid` is outside the service's charset. + pub fn from_json_for_write(json: &str) -> Result { + let doc = Self::from_json(json)?; + validate_statement_sids(&doc.statements)?; + Ok(doc) + } } /// Unwrap a condition operator to its base, dropping any `ForAllValues:` / @@ -232,9 +250,8 @@ fn validate_statement_cidrs(statements: &[Statement]) -> Result<(), PolicyParseE /// only when it is empty or entirely ASCII letters and digits (`[A-Za-z0-9]`), /// regardless of length; a hyphen, underscore, space, dot or any non-ASCII /// character (including Latin-1 such as an accented letter) is rejected with -/// `MalformedPolicyDocument`. `from_json` is the identity-policy write and read -/// path, so this rejects such a `Sid` at write and fails a stored identity -/// policy closed at read. +/// `MalformedPolicyDocument`. Applied by `from_json_for_write` only, never on +/// the request path, so a stored policy with such a `Sid` keeps evaluating. fn validate_statement_sids(statements: &[Statement]) -> Result<(), PolicyParseError> { for stmt in statements { if let Some(sid) = &stmt.sid @@ -664,8 +681,14 @@ mod tests { r#"{{"Version":"2012-10-17","Statement":[{{"Sid":"{sid}","Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*"}}]}}"# ); assert!( - PolicyDocument::from_json(&json).is_err(), - "should reject Sid {sid:?}" + PolicyDocument::from_json_for_write(&json).is_err(), + "should reject Sid {sid:?} at write" + ); + // A policy stored before the check existed still parses on the + // request path, so its principal is not locked out. + assert!( + PolicyDocument::from_json(&json).is_ok(), + "stored policy with Sid {sid:?} must still parse" ); } } @@ -677,7 +700,7 @@ mod tests { r#"{{"Version":"2012-10-17","Statement":[{{"Sid":"{sid}","Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*"}}]}}"# ); assert!( - PolicyDocument::from_json(&json).is_ok(), + PolicyDocument::from_json_for_write(&json).is_ok(), "should accept Sid {sid:?}" ); } diff --git a/crates/server/src/management/iam_policy.rs b/crates/server/src/management/iam_policy.rs index 4df605f2a..7328902f5 100755 --- a/crates/server/src/management/iam_policy.rs +++ b/crates/server/src/management/iam_policy.rs @@ -183,7 +183,9 @@ async fn put_policy( // Strict parse-on-write: reject documents the policy evaluator cannot parse. { let json_str = document.to_string(); - if let Err(e) = extenddb_auth::policy::document::PolicyDocument::from_json(&json_str) { + if let Err(e) = + extenddb_auth::policy::document::PolicyDocument::from_json_for_write(&json_str) + { return ( StatusCode::BAD_REQUEST, format!("Invalid policy document: {e}"), diff --git a/crates/server/src/management/permissions_boundary.rs b/crates/server/src/management/permissions_boundary.rs index 10ea49442..f1e10e9ca 100755 --- a/crates/server/src/management/permissions_boundary.rs +++ b/crates/server/src/management/permissions_boundary.rs @@ -101,7 +101,9 @@ async fn set_boundary( // a malformed CIDR with MalformedPolicyDocument. { let json_str = document.to_string(); - if let Err(e) = extenddb_auth::policy::document::PolicyDocument::from_json(&json_str) { + if let Err(e) = + extenddb_auth::policy::document::PolicyDocument::from_json_for_write(&json_str) + { return ( StatusCode::BAD_REQUEST, format!("Invalid permissions boundary document: {e}"), From 36327482c1e884e79324495369549c9a27b99c77 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 10:39:11 +0000 Subject: [PATCH 57/58] docs(auth): remove the stale tag charset comment and record Unicode versions is_iam_tag_char kept the doc comment from before the general-category fix, which described an ASCII-space check that no longer exists. Remove it. Also record that the tag charset uses Unicode 15.1 general categories and the case-fold table is Unicode 15.0.0, and why the fold table holds under 15.1. --- crates/server/src/management/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/server/src/management/mod.rs b/crates/server/src/management/mod.rs index 431867942..f6e215b61 100755 --- a/crates/server/src/management/mod.rs +++ b/crates/server/src/management/mod.rs @@ -276,9 +276,6 @@ pub(crate) fn validate_iam_tags(tags: &[(String, String)]) -> Result<(), String> Ok(()) } -/// One character of IAM's tag grammar: a Unicode letter or number, an ASCII -/// space, or one of `_ . : / = + - @`. Combining marks and other punctuation -/// are excluded, matching the service's `[\p{L}\p{Z}\p{N}_.:/=+\-@]`. /// Whether `c` is admissible in an IAM tag key or value. /// /// IAM's tag grammar is `[\p{L}\p{Z}\p{N}_.:/=+\-@]`: any Unicode Letter @@ -289,6 +286,12 @@ pub(crate) fn validate_iam_tags(tags: &[(String, String)]) -> Result<(), String> /// Other_Alphabetic combining marks such as U+0345 (category Mn) that IAM /// rejects, and an ASCII-space-only separator check rejects the `\p{Z}` /// separators (U+00A0, U+2002, U+3000 and the rest) that IAM accepts. +/// +/// `unicode-properties` carries Unicode 15.1 general categories; the case-fold +/// table in `extenddb_auth` is Unicode 15.0.0. Unicode 15.1 added only uncased +/// characters (CJK Unified Ideographs Extension I and five ideographic +/// description characters), so its case folding is identical to 15.0.0 and +/// the fold table holds under either version. fn is_iam_tag_char(c: char) -> bool { use unicode_properties::{GeneralCategoryGroup, UnicodeGeneralCategory}; matches!(c, '_' | '.' | ':' | '/' | '=' | '+' | '-' | '@') From 5a0bd6317c06dbb5a8629cb0eba3c2c4e33355cc Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 21 Oct 2026 10:44:00 +0000 Subject: [PATCH 58/58] fix(console): validate trust and identity policies the same way as the API The console create-role and add-policy forms checked only that the document parsed as JSON, then stored it. The management API runs the trust-policy validation and the identity-policy write checks first, so a document it rejects could still be stored through the console. A role created there with Principal "*", Action "*" or a Resource field was then assumable, by a caller in another account in the first case. The service rejects all three at CreateRole. Both console forms now run the same checks as the management API before storing, and show the error on the form. Console tests cover the three trust-policy shapes, a malformed CIDR and a non-alphanumeric Sid; they fail on the previous build and pass on this one. --- .../server/src/console/pages/policy_pages.rs | 22 +++++++++ crates/server/src/console/pages/role_pages.rs | 22 +++++++++ crates/server/src/management/mod.rs | 3 ++ tests/test_console_integration.py | 49 +++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/crates/server/src/console/pages/policy_pages.rs b/crates/server/src/console/pages/policy_pages.rs index 46b82d40b..54c3e23d8 100755 --- a/crates/server/src/console/pages/policy_pages.rs +++ b/crates/server/src/console/pages/policy_pages.rs @@ -285,6 +285,28 @@ async fn put_policy( .into_response(); }; + // Apply the same identity-policy validation as the management API, so + // the console cannot store a document the service, and the management + // API, refuse. + let invalid = if doc.get("Version").is_none() || doc.get("Statement").is_none() { + Some("Policy document must contain Version and Statement".to_owned()) + } else { + extenddb_auth::policy::document::PolicyDocument::from_json_for_write(&doc.to_string()) + .err() + .map(|e| format!("Invalid policy document: {e}")) + }; + if let Some(msg) = invalid { + let nav = html::nav_bar(&identity_label(&session.identity)); + let content = format!("

Add Policy

{}", html::alert_error(&msg)); + return Html(html::layout_csrf( + "New Policy", + &nav, + &content, + &session.csrf_token, + )) + .into_response(); + } + match state .catalog_store .put_policy( diff --git a/crates/server/src/console/pages/role_pages.rs b/crates/server/src/console/pages/role_pages.rs index cd1963c21..f2ffe4697 100755 --- a/crates/server/src/console/pages/role_pages.rs +++ b/crates/server/src/console/pages/role_pages.rs @@ -115,6 +115,28 @@ pub async fn create_role( .into_response(); }; + // Apply the same trust-policy validation as the management API, so the + // console cannot store a trust policy that the service, and the + // management API, refuse. + let invalid = if tp.get("Statement").is_none() { + Some("Trust policy must contain a Statement".to_owned()) + } else { + crate::management::validate_trust_policy(&tp) + .err() + .map(|msg| format!("Invalid trust policy: {msg}")) + }; + if let Some(msg) = invalid { + let nav = html::nav_bar(&identity_label(&session.identity)); + let content = format!("

Create Role

{}", html::alert_error(&msg)); + return Html(html::layout_csrf( + "New Role", + &nav, + &content, + &session.csrf_token, + )) + .into_response(); + } + match state .catalog_store .create_role(&account_id, &form.role_name, &tp) diff --git a/crates/server/src/management/mod.rs b/crates/server/src/management/mod.rs index f6e215b61..cbe651ded 100755 --- a/crates/server/src/management/mod.rs +++ b/crates/server/src/management/mod.rs @@ -12,6 +12,9 @@ mod account; pub(crate) use account::generate_account_id; mod admin; mod assume_role; +/// Shared with the web console so both write surfaces validate a trust +/// policy the same way. +pub(crate) use assume_role::validate_trust_policy; pub(crate) mod auth; pub mod cache_invalidate; mod cache_metrics; diff --git a/tests/test_console_integration.py b/tests/test_console_integration.py index f07fa6aef..402d9b2d4 100755 --- a/tests/test_console_integration.py +++ b/tests/test_console_integration.py @@ -431,6 +431,28 @@ def test_delete_role(self): resp = self.console.get(f"/accounts/{self.account_id}") assert role not in resp.text + + @pytest.mark.parametrize("statement", [ + # The service refuses each of these trust policies at CreateRole + # (MalformedPolicyDocument); the management API refuses them too. + {"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}, + {"Effect": "Allow", "Principal": {"AWS": "ACCOUNT_ROOT"}, "Action": "*"}, + {"Effect": "Allow", "Principal": {"AWS": "ACCOUNT_ROOT"}, + "Action": "sts:AssumeRole", "Resource": "*"}, + ], ids=["bare-star-principal", "star-action", "resource-field"]) + def test_create_role_rejects_trust_policy_the_service_refuses(self, statement): + role = f"bad-{uuid.uuid4().hex[:8]}" + stmt = json.loads(json.dumps(statement).replace( + "ACCOUNT_ROOT", f"arn:aws:iam::{self.account_id}:root")) + resp = self.console.post(f"/accounts/{self.account_id}/roles/new", { + "role_name": role, + "trust_policy": json.dumps({"Version": "2012-10-17", "Statement": [stmt]}), + }) + # The form is re-rendered with an error instead of redirecting. + assert resp.status_code == 200 + assert "Invalid trust policy" in resp.text + resp = self.console.get(f"/accounts/{self.account_id}") + assert role not in resp.text # --------------------------------------------------------------------------- # Policy CRUD # --------------------------------------------------------------------------- @@ -459,6 +481,33 @@ def _full_access_doc(self) -> str: }], }) + def test_user_policy_rejects_document_the_service_refuses(self): + # A malformed CIDR and a non-alphanumeric Sid are each refused by the + # service at PutUserPolicy and by the management API. + user = f"pv-{uuid.uuid4().hex[:8]}" + self.console.post(f"/accounts/{self.account_id}/users/new", { + "user_name": user, + "password": "TestPass123!", + }) + bad_docs = [ + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"IpAddress": {"aws:SourceIp": "10.0.0.0/33"}}}]}, + {"Version": "2012-10-17", "Statement": [{ + "Sid": "Allow-Read", "Effect": "Allow", + "Action": "dynamodb:GetItem", "Resource": "*"}]}, + ] + for i, doc in enumerate(bad_docs): + policy = f"bad{i}-{uuid.uuid4().hex[:6]}" + resp = self.console.post( + f"/accounts/{self.account_id}/users/{user}/policies/new", + {"policy_name": policy, "policy_document": json.dumps(doc)}, + ) + assert resp.status_code == 200 + assert "Invalid policy document" in resp.text + resp = self.console.get(f"/accounts/{self.account_id}/users/{user}") + assert policy not in resp.text + def test_user_policy_crud(self): user = f"pu-{uuid.uuid4().hex[:8]}" policy = f"pol-{uuid.uuid4().hex[:8]}"