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
29 changes: 26 additions & 3 deletions crates/oab-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -131,6 +132,14 @@ fn tools() -> Vec<Tool> {
"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": {}
})),
),
]
}

Expand Down Expand Up @@ -298,6 +307,18 @@ impl OabMcp {
)
}

async fn t_runtime_context(&self, _args: &Map<String, Value>) -> Result<Value> {
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<String, Value>) -> Result<Value> {
let cluster = self.cluster(args);
let resource = args
Expand Down Expand Up @@ -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:?}"),
Expand Down Expand Up @@ -390,15 +412,15 @@ 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<String> = catalog
.as_array()
.expect("tool list is an array")
.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",
Expand All @@ -407,6 +429,7 @@ mod tests {
"deploy_apply",
"deploy_scale",
"deploy_delete",
"runtime_context",
] {
assert!(names.contains(&expected.to_string()), "missing {expected}");
}
Expand Down
1 change: 1 addition & 0 deletions crates/studio-cp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
96 changes: 96 additions & 0 deletions crates/studio-cp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeContext> {
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
Expand Down Expand Up @@ -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");
}
}
Loading