diff --git a/crates/oab-mcp/src/main.rs b/crates/oab-mcp/src/main.rs index 63c3b2c..eba6a98 100644 --- a/crates/oab-mcp/src/main.rs +++ b/crates/oab-mcp/src/main.rs @@ -5,7 +5,8 @@ //! tools over **stdio**, so an agent operates the OAB control plane as a //! first-class client: //! -//! - read: `deploy_list`, `deploy_get`, `get_agent_states`, `deploy_events` +//! - read: `deploy_list`, `deploy_get`, `get_agent_states`, `deploy_events`, +//! `runtime_context` //! - write: `deploy_apply`, `deploy_scale`, `deploy_delete` //! //! Every tool is a thin dispatch into `studio-cp`; this crate owns only the @@ -131,6 +132,14 @@ fn tools() -> Vec { "required": ["resource", "name"] })), ), + Tool::new( + "runtime_context", + "Show the effective runtime identity/context this control plane resolved: the acting principal (STS caller ARN), its kind (role vs static user), account (scope), region (location), and a best-effort credential-source hint. Read-only; answers \"who am I acting as, against what account?\" and surfaces silent credential fallback.", + as_map(json!({ + "type": "object", + "properties": {} + })), + ), ] } @@ -298,6 +307,18 @@ impl OabMcp { ) } + async fn t_runtime_context(&self, _args: &Map) -> Result { + let ctx = scp::observe_identity(&self.aws).await?; + Ok(json!({ + "principal": ctx.principal, + "principal_kind": ctx.principal_kind, + "scope": ctx.scope, + "location": ctx.location, + "source": ctx.source, + "caller_id": ctx.caller_id, + })) + } + async fn t_delete(&self, args: &Map) -> Result { let cluster = self.cluster(args); let resource = args @@ -356,6 +377,7 @@ impl ServerHandler for OabMcp { "deploy_apply" => self.t_apply(args).await, "deploy_scale" => self.t_scale(args).await, "deploy_delete" => self.t_delete(args).await, + "runtime_context" => self.t_runtime_context(args).await, other => { return Err(McpError::invalid_params( format!("unknown tool {other:?}"), @@ -390,7 +412,7 @@ mod tests { use super::*; #[test] - fn catalog_advertises_the_seven_named_tools() { + fn catalog_advertises_the_named_tools() { let catalog = serde_json::to_value(tools()).expect("tools serialize"); let names: Vec = catalog .as_array() @@ -398,7 +420,7 @@ mod tests { .iter() .map(|t| t["name"].as_str().expect("tool has a name").to_string()) .collect(); - assert_eq!(names.len(), 7); + assert_eq!(names.len(), 8); for expected in [ "deploy_list", "deploy_get", @@ -407,6 +429,7 @@ mod tests { "deploy_apply", "deploy_scale", "deploy_delete", + "runtime_context", ] { assert!(names.contains(&expected.to_string()), "missing {expected}"); } diff --git a/crates/studio-cp/Cargo.toml b/crates/studio-cp/Cargo.toml index 1308d16..a9d3a5c 100644 --- a/crates/studio-cp/Cargo.toml +++ b/crates/studio-cp/Cargo.toml @@ -9,5 +9,6 @@ license = "MIT" oabctl = { path = "../oabctl" } agent-lifecycle = { path = "../agent-lifecycle" } aws-config = "1.5" +aws-sdk-sts = "1" anyhow = "1.0" tokio = { version = "1.40", features = ["rt-multi-thread", "macros"] } diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index 06f84c9..7ba1608 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -152,6 +152,89 @@ pub async fn observe_events( oabctl::fetch_ecs_events(aws_config, log_group, Some(cluster), service, since_ms, limit).await } +// ---- Effective runtime identity/context (ADR: Per-Fleet managing identity) -- +// +// Read-only observation of *who this control plane is actually acting as*. The +// silent-fallback incident (a static `[default]` profile shadowing the intended +// task role, an IAM user in the wrong account) had no signal surfacing the +// resolved principal; this makes it observable — the value a Fleet↔account +// binding is later reconciled against. + +/// The **effective** runtime identity/context a driver resolved. Generic and +/// read-only; vendor specifics (STS ARN / account / region) are produced here +/// and surfaced upward unchanged. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeContext { + /// Effective acting principal — the STS caller ARN. + pub principal: String, + /// Coarse kind of the principal: `"role"` (assumed-role / role) vs `"user"` + /// (static IAM user) vs `"unknown"`. A `user/...` where a task role was + /// expected is the exact shape of the credential-fallback incident. + pub principal_kind: String, + /// Account / project boundary — the AWS account id. + pub scope: String, + /// Region / zone the config resolved to (empty if unset). + pub location: String, + /// Best-effort hint of where the credential came from, inferred from the + /// process environment. Not authoritative — richer provenance is later work. + pub source: String, + /// Opaque STS caller `UserId` (support/correlation). + pub caller_id: String, +} + +/// Classify a caller ARN as a role vs a static user vs unknown. +fn principal_kind(arn: &str) -> &'static str { + if arn.contains(":assumed-role/") || arn.contains(":role/") { + "role" + } else if arn.contains(":user/") { + "user" + } else { + "unknown" + } +} + +/// Best-effort classification of the credential source from the environment. An +/// honest hint only: the AWS SDK does not expose which chain provider won, so we +/// infer from the same signals that decide it. +fn credential_source_hint() -> String { + if std::env::var_os("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some() + || std::env::var_os("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some() + { + "container-credentials (task/pod role)".to_string() + } else if std::env::var_os("AWS_ACCESS_KEY_ID").is_some() { + "static keys (env)".to_string() + } else if let Ok(profile) = std::env::var("AWS_PROFILE") { + format!("named profile: {profile}") + } else { + "default credential chain".to_string() + } +} + +/// Observe the **effective** runtime identity/context the given AWS config +/// resolves to: a live STS `GetCallerIdentity`, the config's region, and an +/// environment-derived source hint. Read-only — makes the resolved principal +/// visible (the signal the silent-fallback incident lacked). +pub async fn observe_identity( + aws_config: &aws_config::SdkConfig, +) -> anyhow::Result { + let ident = aws_sdk_sts::Client::new(aws_config) + .get_caller_identity() + .send() + .await?; + let principal = ident.arn().unwrap_or_default().to_string(); + Ok(RuntimeContext { + principal_kind: principal_kind(&principal).to_string(), + principal, + scope: ident.account().unwrap_or_default().to_string(), + location: aws_config + .region() + .map(|r| r.as_ref().to_string()) + .unwrap_or_default(), + source: credential_source_hint(), + caller_id: ident.user_id().unwrap_or_default().to_string(), + }) +} + // ---- Write side (ADR-2 write model) ------------------------------------ // // The read side above observes; these mutate. Each is a thin passthrough to @@ -277,4 +360,17 @@ mod tests { assert_eq!(d.instances[0].phase, AgentState::Running); assert_eq!(d.instances[1].phase, AgentState::Starting); } + + #[test] + fn principal_kind_distinguishes_role_from_static_user() { + assert_eq!( + principal_kind("arn:aws:sts::504190915686:assumed-role/openab-orca-task-role/sid"), + "role" + ); + assert_eq!( + principal_kind("arn:aws:iam::916371022086:user/brett.chien"), + "user" + ); + assert_eq!(principal_kind("arn:aws:iam::1:root"), "unknown"); + } }