Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions crates/oab-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ fn tools() -> Vec<Tool> {
),
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": {
Expand Down Expand Up @@ -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,
Expand All @@ -376,6 +383,8 @@ impl OabMcp {
"source": ctx.source,
"caller_id": ctx.caller_id,
"binding": binding,
"expected_principal": expected,
"identity_matches": identity_matches,
}))
}

Expand Down
62 changes: 62 additions & 0 deletions crates/studio-cp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
}
Loading