From fc2fd62a178f58d9ceb8f70fa5d51cfdd9fb67e6 Mon Sep 17 00:00:00 2001 From: "Orca (ecs-claude)" Date: Thu, 13 Aug 2026 13:19:39 +0800 Subject: [PATCH] feat(cp): IdentityMismatch reconcile check (ADR #19 slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the declare→switch→observe→reconcile loop: compare the resolved caller principal against the binding's expected_principal. - studio-cp: `principal_matches(expected, actual)` — a read-only warning signal (never an authz gate). Handles STS assumed-role vs IAM role shape and a trailing `*` wildcard; a static `user/...` never satisfies a role expectation (exactly the fallback we want flagged). Unit-tested incl. the incident case. - oab-mcp: `runtime_context` now reports `expected_principal` and `identity_matches` (null when no expectation is declared). Stacks on the slice-2 FleetBinding switch. Assume-role credential form is the next slice (its provider API is version-sensitive; landing it isolated). Co-Authored-By: Claude Opus 4.8 --- crates/oab-mcp/src/main.rs | 13 ++++++-- crates/studio-cp/src/lib.rs | 62 +++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/oab-mcp/src/main.rs b/crates/oab-mcp/src/main.rs index ead08e9..1d02881 100644 --- a/crates/oab-mcp/src/main.rs +++ b/crates/oab-mcp/src/main.rs @@ -142,7 +142,7 @@ fn tools() -> Vec { ), Tool::new( "runtime_context", - "Show the effective runtime identity/context this control plane resolved for a cluster/fleet: the acting principal (STS caller ARN), its kind (role vs static user), account (scope), region (location), a best-effort credential-source hint, and the fleet binding in effect (if any). Read-only; answers \"who am I acting as, against what account?\" and surfaces silent credential fallback.", + "Show the effective runtime identity/context this control plane resolved for a cluster/fleet: the acting principal (STS caller ARN), its kind (role vs static user), account (scope), region (location), a best-effort credential-source hint, the fleet binding in effect (if any), and — when the binding declares an expected_principal — whether the resolved identity matches it (identity_matches; a non-blocking IdentityMismatch when false). Read-only; answers \"who am I acting as, against what account?\" and surfaces silent credential fallback.", as_map(json!({ "type": "object", "properties": { @@ -359,7 +359,14 @@ impl OabMcp { let cluster = self.cluster(args); let aws = self.aws_for(&cluster).await; let ctx = scp::observe_identity(&aws).await?; - let binding = self.bindings.for_cluster(&cluster).map(|b| { + let binding = self.bindings.for_cluster(&cluster); + let expected = binding.and_then(|b| b.expected_principal.clone()); + // Reconcile: expected (declared) vs actual (resolved). null when no + // expectation is declared. Non-blocking — a warning signal, not a gate. + let identity_matches = expected + .as_ref() + .map(|e| scp::principal_matches(e, &ctx.principal)); + let binding = binding.map(|b| { json!({ "name": b.name, "profile": b.profile, @@ -376,6 +383,8 @@ impl OabMcp { "source": ctx.source, "caller_id": ctx.caller_id, "binding": binding, + "expected_principal": expected, + "identity_matches": identity_matches, })) } diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index ba880b8..8d69160 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -312,6 +312,48 @@ pub async fn resolve_binding_config(binding: &FleetBinding) -> aws_config::SdkCo loader.load().await } +/// Does the resolved caller `actual` principal satisfy the `expected` principal +/// a binding declares? The read-only **reconcile** check (IdentityMismatch) — +/// a warning signal, never an authz gate (ADR-2 authz stays deferred). +/// +/// Handles the STS assumed-role vs IAM role shape and a trailing `*` wildcard: +/// an expected `arn:aws:iam::A:role/R` (or `…:assumed-role/R/*`) matches an +/// actual `arn:aws:sts::A:assumed-role/R/SESSION`. +pub fn principal_matches(expected: &str, actual: &str) -> bool { + if expected == actual { + return true; + } + if let Some(prefix) = expected.strip_suffix('*') { + if actual.starts_with(prefix) { + return true; + } + } + match (role_identity(expected), role_identity(actual)) { + (Some(e), Some(a)) => e == a, + _ => false, + } +} + +/// Extract `(account, role_name)` from an IAM role or STS assumed-role ARN; +/// `None` for anything else (e.g. a static `user/…` ARN — which therefore never +/// matches a role expectation, exactly the fallback we want flagged). +fn role_identity(arn: &str) -> Option<(String, String)> { + let parts: Vec<&str> = arn.split(':').collect(); + if parts.len() < 6 { + return None; + } + let account = parts[4].to_string(); + let resource = parts[5..].join(":"); + let name = if let Some(r) = resource.strip_prefix("assumed-role/") { + r.split('/').next()?.to_string() + } else if let Some(r) = resource.strip_prefix("role/") { + r.to_string() + } else { + return None; + }; + Some((account, name)) +} + // ---- Write side (ADR-2 write model) ------------------------------------ // // The read side above observes; these mutate. Each is a thin passthrough to @@ -472,4 +514,24 @@ profile = "appier-sg" assert_eq!(prod.region.as_deref(), Some("ap-east-2")); assert!(b.for_cluster("nope").is_none()); } + + #[test] + fn identity_mismatch_flags_static_user_and_wrong_account() { + let expected_role = "arn:aws:iam::504190915686:role/openab-orca-task-role"; + let actual_assumed = + "arn:aws:sts::504190915686:assumed-role/openab-orca-task-role/sess-abc"; + // role ARN expected, assumed-role ARN actual, same account+role → match + assert!(principal_matches(expected_role, actual_assumed)); + // trailing-wildcard expectation + let expected_wild = "arn:aws:sts::504190915686:assumed-role/openab-orca-task-role/*"; + assert!(principal_matches(expected_wild, actual_assumed)); + // the incident: expected a role, got a static IAM user → mismatch + let brett = "arn:aws:iam::916371022086:user/brett.chien"; + assert!(!principal_matches(expected_role, brett)); + // right role name, wrong account → mismatch + let other_acct = "arn:aws:sts::916371022086:assumed-role/openab-orca-task-role/x"; + assert!(!principal_matches(expected_role, other_acct)); + // exact match + assert!(principal_matches(brett, brett)); + } }