diff --git a/AGENTS.md b/AGENTS.md index c790a38a0..0d7a06d6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,7 @@ Startup time, idle memory, and binary size are tracked as constants in `scripts/ - Step Functions is fully shipped with complete ASL interpreter (all state types), error handling (Retry/Catch), and cross-service task integrations (Lambda, SQS, SNS, EventBridge, DynamoDB). - API Gateway v2 (HTTP APIs) is fully shipped with Lambda proxy integration v2.0, HTTP proxy, Mock integrations, route matching with path parameters and wildcards, CORS, and JWT/Lambda authorizers. - `GET /_fakecloud/credentials` vends AWS container-credentials-format JSON (minted + registered like AssumeRole, so it verifies under `--verify-sigv4`); point an app's `AWS_CONTAINER_CREDENTIALS_FULL_URI` at it to resolve the SDK default credential chain locally with no code change. Role ARN configurable via `--credentials-role-arn`. -- EC2 IMDS is served on `/latest/*` (IMDSv1 + IMDSv2 token, `meta-data/iam/security-credentials/*`, `iam/info`, `instance-id`, `placement/*`, `dynamic/instance-identity/document`); consumed via `AWS_EC2_METADATA_SERVICE_ENDPOINT`. Credentials share the same registered cache as `/_fakecloud/credentials`. Instance ID via `--imds-instance-id`. `--imds-link-local` additionally binds `169.254.169.254:80` (IMDS) + `169.254.170.2:80/creds` (ECS creds) for apps that hardcode those IPs; needs root plus the addresses pre-aliased on loopback (fakecloud binds them but never creates/deletes the alias), with graceful fallback if the bind fails. +- EC2 IMDS is served on `/latest/*` (IMDSv1 + IMDSv2 token, `meta-data/iam/security-credentials/*`, `iam/info`, `instance-id`, `placement/*`, `dynamic/instance-identity/document`); consumed via `AWS_EC2_METADATA_SERVICE_ENDPOINT`. Credentials share the same registered cache as `/_fakecloud/credentials`. Instance ID via `--imds-instance-id`. `--imds-link-local` additionally binds `169.254.169.254:80` (IMDS) + `169.254.170.2:80/creds` (ECS creds; `/v2/credentials/` there serves an ECS task's role creds) for apps that hardcode those IPs; needs root plus the addresses pre-aliased on loopback (fakecloud binds them but never creates/deletes the alias), with graceful fallback if the bind fails. +- ECS task-role credentials: `GET /_fakecloud/ecs/creds/{task_id}` (injected as `AWS_CONTAINER_CREDENTIALS_FULL_URI` for tasks with a `taskRoleArn`) vends a session for the task role named after the task ID (`assumed-role//`), minted + registered like AssumeRole and cached per task until near expiry (`fakecloud-server` `ecs_creds`, `WorkloadCredentialCache`); revoked when the task stops (1s sweep). Stopped/unknown/role-less tasks get the ECS agent's HTTP 400 `{"code":"InvalidIdInRequest","message":"CredentialsV2Request: Credentials not found","HTTPErrorCode":400}`. RegisterTaskDefinition/RunTask overrides/CFN apply `fakecloud_ecs::validate_task_role` (trust policy must name `ecs-tasks.amazonaws.com`; cross-account refused under `--iam strict`) with ECS's `ClientException` "ECS was unable to assume the role ...". - `--dns` runs a real DNS resolver (UDP+TCP, `--dns-addr` default `0.0.0.0:53`, port 53 needs root) answering the wire-encodable types (A/AAAA/CNAME/MX/TXT/NS/PTR/SPF/CAA/SRV/SOA) from the Route 53 records created in fakecloud. Longest-suffix zone match across all accounts; CNAME chase into local zones with the requested type appended, and an external CNAME target forward-resolved upstream so a stub client still gets an address. Names in no local zone forward to `--dns-upstream` (default first non-loopback `/etc/resolv.conf` nameserver, else `8.8.8.8:53`); forward socket is connect()ed + txid-checked. UDP honors EDNS0 size and sets TC to force TCP fallback past it. Pure resolution logic in `fakecloud_route53::resolver`, hand-rolled wire codec in the server `dns` module reusing `dnssec::encode_rdata`/`type_code`. Best-effort detached listener; a bind failure is logged and the main server is unaffected. The same resolution is exposed for tests at `GET /_fakecloud/dns/resolve?name=&type=` (defaults `A`, unsupported type -> 400) returning `{name,type,status(ANSWERED|NODATA|NXDOMAIN|NOT_AUTHORITATIVE),authoritative,records:[{name,type,ttl,value}],external_cname}` (`external_cname` names the target when an A/AAAA CNAME chain exits all local zones, since this path does no upstream I/O), wrapped as `dns_resolve`/`dnsResolve` in all seven SDKs -- no socket needed. - When adding new service behavior, prefer complete, realistic implementations over placeholder API coverage. diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs index 21aa97722..4385ee215 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs @@ -69,6 +69,21 @@ impl ResourceProvisioner { Ok(()) } + /// The same role checks `RegisterTaskDefinition` runs: a role whose trust + /// policy lets ECS tasks assume it, in the stack's account under IAM + /// enforcement. + fn validate_ecs_task_role(&self, role_arn: &str) -> Result<(), String> { + let validator = + fakecloud_iam::pass_role::IamRoleTrustValidator::new(self.iam_state.clone()); + fakecloud_ecs::validate_task_role( + &self.account_id, + role_arn, + Some(&validator), + self.iam_mode, + ) + .map_err(|e| e.to_string()) + } + pub(super) fn create_ecs_task_definition( &self, resource: &ResourceDefinition, @@ -98,6 +113,9 @@ impl ResourceProvisioner { .get("ExecutionRoleArn") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + for role_arn in [&task_role_arn, &execution_role_arn].into_iter().flatten() { + self.validate_ecs_task_role(role_arn)?; + } let network_mode = props .get("NetworkMode") .and_then(|v| v.as_str()) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs index 1967316f9..fc398cb22 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs @@ -10635,6 +10635,51 @@ mod tests { assert_eq!(prov.existing_name(&legacy).as_deref(), Some("Subnets")); } + #[test] + fn task_definition_roles_run_the_register_task_definition_checks() { + let prov = make_provisioner(); + let untrusted = prov + .create_resource(&make_resource( + "AWS::IAM::Role", + "Untrusted", + serde_json::json!({ + "RoleName": "untrusted-task-role", + "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []} + }), + )) + .unwrap(); + let def = |props: serde_json::Value| { + let mut p = serde_json::json!({ + "ContainerDefinitions": [{"Name": "app", "Image": "nginx", "Memory": 256}] + }); + p.as_object_mut() + .unwrap() + .extend(props.as_object().unwrap().clone()); + make_resource("AWS::ECS::TaskDefinition", "Task", p) + }; + for key in ["TaskRoleArn", "ExecutionRoleArn"] { + let err = prov + .create_resource(&def(serde_json::json!({ key: untrusted.physical_id }))) + .unwrap_err(); + assert!( + err.contains("ClientException") + && err.contains("ECS was unable to assume the role"), + "{key}: {err}" + ); + } + + // Another account's role: accepted with IAM enforcement off (the + // default), refused like AWS with it on. + let foreign = + serde_json::json!({"TaskRoleArn": "arn:aws:iam::999999999999:role/elsewhere"}); + prov.create_resource(&def(foreign.clone())) + .expect("default mode accepts another account's role"); + let mut enforcing = make_provisioner(); + enforcing.iam_mode = fakecloud_core::auth::IamMode::Strict; + let err = enforcing.create_resource(&def(foreign)).unwrap_err(); + assert!(err.contains("ClientException"), "{err}"); + } + #[test] fn an_unnamed_task_definition_update_registers_the_next_revision() { let prov = make_provisioner(); diff --git a/crates/fakecloud-e2e/tests/ecs_task_role_credentials.rs b/crates/fakecloud-e2e/tests/ecs_task_role_credentials.rs new file mode 100644 index 000000000..bf0c0aac3 --- /dev/null +++ b/crates/fakecloud-e2e/tests/ecs_task_role_credentials.rs @@ -0,0 +1,466 @@ +//! ECS task-role credentials: `GET /_fakecloud/ecs/creds/{task_id}` (the URL +//! injected as `AWS_CONTAINER_CREDENTIALS_FULL_URI`) vends the task role's +//! session, named after the task, that verifies under `--verify-sigv4` and +//! acts as the role under `--iam strict`; revoked once the task stops. +//! RegisterTaskDefinition refuses roles ECS tasks cannot assume. + +mod helpers; + +use std::time::Duration; + +use aws_sdk_ecs::types::ContainerDefinition; +use helpers::TestServer; + +const ECS_TASKS_TRUST: &str = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#; +const EC2_TRUST: &str = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#; + +fn docker_available() -> bool { + std::process::Command::new("docker") + .arg("info") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn require_docker_or_skip(test: &str) -> bool { + if docker_available() { + return true; + } + if std::env::var("CI").is_ok() { + panic!("docker is required for {test} in CI"); + } + eprintln!("skipping {test}: docker is not available"); + false +} + +async fn wait_status(ecs: &aws_sdk_ecs::Client, cluster: &str, arn: &str, want: &str) { + for _ in 0..240 { + let desc = ecs + .describe_tasks() + .cluster(cluster) + .tasks(arn) + .send() + .await + .unwrap(); + let status = desc.tasks()[0] + .last_status() + .unwrap_or_default() + .to_string(); + if status == want { + return; + } + assert!( + want != "RUNNING" || status != "STOPPED", + "task {arn} stopped before running: {:?}", + desc.tasks()[0].stopped_reason() + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + panic!("task {arn} never reached {want}"); +} + +async fn fetch_creds(server: &TestServer, task_id: &str) -> (u16, serde_json::Value) { + let resp = reqwest::Client::new() + .get(format!( + "{}/_fakecloud/ecs/creds/{task_id}", + server.endpoint() + )) + .send() + .await + .unwrap(); + let status = resp.status().as_u16(); + (status, resp.json().await.unwrap()) +} + +fn sdk_config(server: &TestServer, creds: &serde_json::Value) -> aws_config::SdkConfig { + let credentials = aws_credential_types::Credentials::new( + creds["AccessKeyId"].as_str().unwrap(), + creds["SecretAccessKey"].as_str().unwrap(), + Some(creds["Token"].as_str().unwrap().to_string()), + None, + "ecs-task", + ); + aws_config::SdkConfig::builder() + .behavior_version(aws_config::BehaviorVersion::latest()) + .endpoint_url(server.endpoint()) + .region(aws_config::Region::new("us-east-1")) + .credentials_provider( + aws_credential_types::provider::SharedCredentialsProvider::new(credentials), + ) + .build() +} + +/// SDK config signed with the reserved root-bypass credentials, which skip +/// SigV4 verification and IAM enforcement. +async fn root_config(server: &TestServer) -> aws_config::SdkConfig { + aws_config::defaults(aws_config::BehaviorVersion::latest()) + .endpoint_url(server.endpoint()) + .region(aws_config::Region::new("us-east-1")) + .credentials_provider(aws_credential_types::Credentials::new( + "test", "test", None, None, "test", + )) + .load() + .await +} + +fn assert_not_found(status: u16, body: &serde_json::Value) { + assert_eq!(status, 400, "{body}"); + assert_eq!( + body, + &serde_json::json!({ + "code": "InvalidIdInRequest", + "message": "CredentialsV2Request: Credentials not found", + "HTTPErrorCode": 400, + }) + ); +} + +/// A running task's credentials are its role's session named after the task: +/// they verify under --verify-sigv4, are evaluated as the role under +/// --iam strict, are reachable from inside the container, and stop working +/// once the task stops. +#[tokio::test] +async fn running_task_gets_its_role_session_until_it_stops() { + if !require_docker_or_skip("running_task_gets_its_role_session_until_it_stops") { + return; + } + let server = TestServer::start_with_env(&[ + ("FAKECLOUD_VERIFY_SIGV4", "true"), + ("FAKECLOUD_IAM", "strict"), + ]) + .await; + let root = root_config(&server).await; + let iam = aws_sdk_iam::Client::new(&root); + let ecs = aws_sdk_ecs::Client::new(&root); + + let role = iam + .create_role() + .role_name("app-task-role") + .assume_role_policy_document(ECS_TASKS_TRUST) + .send() + .await + .unwrap(); + let role_arn = role.role().unwrap().arn().to_string(); + iam.put_role_policy() + .role_name("app-task-role") + .policy_name("list-queues") + .policy_document( + r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["sqs:ListQueues","sts:GetCallerIdentity"],"Resource":"*"}]}"#, + ) + .send() + .await + .unwrap(); + + ecs.create_cluster() + .cluster_name("creds-cluster") + .send() + .await + .unwrap(); + ecs.register_task_definition() + .family("creds-family") + .task_role_arn(&role_arn) + .container_definitions( + ContainerDefinition::builder() + .name("app") + .image("public.ecr.aws/docker/library/alpine:3.20") + .essential(true) + .command("sh") + .command("-c") + // Fetch the credentials the way an SDK in the task would, then + // keep running so the host can use them while the task lives. + .command( + "wget -qO- \"$AWS_CONTAINER_CREDENTIALS_FULL_URI\" | grep -o '\"RoleArn\":\"[^\"]*\"'; sleep 300", + ) + .build(), + ) + .send() + .await + .unwrap(); + + let run = ecs + .run_task() + .cluster("creds-cluster") + .task_definition("creds-family") + .send() + .await + .unwrap(); + let task_arn = run.tasks()[0].task_arn().unwrap().to_string(); + let task_id = task_arn.rsplit('/').next().unwrap().to_string(); + wait_status(&ecs, "creds-cluster", &task_arn, "RUNNING").await; + + let (status, creds) = fetch_creds(&server, &task_id).await; + assert_eq!(status, 200, "{creds}"); + assert_eq!(creds["RoleArn"].as_str(), Some(role_arn.as_str())); + for field in ["AccessKeyId", "SecretAccessKey", "Token", "Expiration"] { + assert!(creds[field].is_string(), "missing {field}: {creds}"); + } + // Refetching within the validity window hands back the same session. + let (_, again) = fetch_creds(&server, &task_id).await; + assert_eq!(again["AccessKeyId"], creds["AccessKeyId"]); + + // The container reached the endpoint through the injected URI. + let expected = format!("\"RoleArn\":\"{role_arn}\""); + let mut in_container = false; + for _ in 0..60 { + let logs: serde_json::Value = reqwest::Client::new() + .get(format!( + "{}/_fakecloud/ecs/tasks/{task_id}/logs", + server.endpoint() + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + if logs["logs"] + .as_str() + .unwrap_or_default() + .contains(&expected) + { + in_container = true; + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + // Captured logs are only guaranteed once the container exits, so the + // in-container fetch is checked again after the stop below. + + let task_config = sdk_config(&server, &creds); + let identity = aws_sdk_sts::Client::new(&task_config) + .get_caller_identity() + .send() + .await + .expect("task credentials verify under --verify-sigv4"); + assert_eq!( + identity.arn(), + Some(format!("arn:aws:sts::123456789012:assumed-role/app-task-role/{task_id}").as_str()) + ); + + // Under --iam strict the session acts as the role: its policy allows + // ListQueues (and GetCallerIdentity) and nothing else. + let sqs = aws_sdk_sqs::Client::new(&task_config); + sqs.list_queues() + .send() + .await + .expect("the task role allows sqs:ListQueues"); + let denied = sqs + .create_queue() + .queue_name("not-allowed") + .send() + .await + .expect_err("the task role does not allow sqs:CreateQueue"); + let code = denied + .into_service_error() + .meta() + .code() + .map(str::to_string); + assert!( + matches!( + code.as_deref(), + Some("AccessDenied") | Some("AccessDeniedException") + ), + "{code:?}" + ); + + ecs.stop_task() + .cluster("creds-cluster") + .task(&task_arn) + .send() + .await + .unwrap(); + wait_status(&ecs, "creds-cluster", &task_arn, "STOPPED").await; + + if !in_container { + let logs: serde_json::Value = reqwest::Client::new() + .get(format!( + "{}/_fakecloud/ecs/tasks/{task_id}/logs", + server.endpoint() + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!( + logs["logs"] + .as_str() + .unwrap_or_default() + .contains(&expected), + "container did not fetch its credentials: {logs}" + ); + } + + // The endpoint refuses the stopped task, and the session it handed out + // no longer authenticates. + let (status, body) = fetch_creds(&server, &task_id).await; + assert_not_found(status, &body); + let mut revoked = false; + for _ in 0..20 { + if aws_sdk_sts::Client::new(&task_config) + .get_caller_identity() + .send() + .await + .is_err() + { + revoked = true; + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + assert!(revoked, "stopped task's credentials still authenticate"); +} + +/// A task without a task role has no credentials, and neither does an ID no +/// task has: both answered like the ECS agent. +#[tokio::test] +async fn task_without_role_and_unknown_id_get_the_agent_error() { + let server = TestServer::start().await; + let (status, body) = fetch_creds(&server, "0123456789abcdef0123456789abcdef").await; + assert_not_found(status, &body); + + if !require_docker_or_skip("task_without_role_and_unknown_id_get_the_agent_error") { + return; + } + let ecs = server.ecs_client().await; + ecs.create_cluster() + .cluster_name("norole-cluster") + .send() + .await + .unwrap(); + ecs.register_task_definition() + .family("norole-family") + .container_definitions( + ContainerDefinition::builder() + .name("app") + .image("public.ecr.aws/docker/library/alpine:3.20") + .essential(true) + .command("sh") + .command("-c") + .command("echo FULL_URI=[$AWS_CONTAINER_CREDENTIALS_FULL_URI]; sleep 300") + .build(), + ) + .send() + .await + .unwrap(); + let run = ecs + .run_task() + .cluster("norole-cluster") + .task_definition("norole-family") + .send() + .await + .unwrap(); + let task_arn = run.tasks()[0].task_arn().unwrap().to_string(); + let task_id = task_arn.rsplit('/').next().unwrap().to_string(); + wait_status(&ecs, "norole-cluster", &task_arn, "RUNNING").await; + + let (status, body) = fetch_creds(&server, &task_id).await; + assert_not_found(status, &body); + + ecs.stop_task() + .cluster("norole-cluster") + .task(&task_arn) + .send() + .await + .unwrap(); + wait_status(&ecs, "norole-cluster", &task_arn, "STOPPED").await; + let logs: serde_json::Value = reqwest::Client::new() + .get(format!( + "{}/_fakecloud/ecs/tasks/{task_id}/logs", + server.endpoint() + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + // As on AWS, a task with no role gets no credentials URI at all. + assert!( + logs["logs"] + .as_str() + .unwrap_or_default() + .contains("FULL_URI=[]"), + "{logs}" + ); +} + +/// RegisterTaskDefinition refuses a task or execution role ECS tasks cannot +/// assume (trust policy without `ecs-tasks.amazonaws.com`) and, under IAM +/// enforcement, another account's role, with ECS's ClientException. +#[tokio::test] +async fn register_task_definition_refuses_roles_ecs_cannot_assume() { + let server = TestServer::start_with_env(&[("FAKECLOUD_IAM", "strict")]).await; + let iam = server.iam_client().await; + let ecs = server.ecs_client().await; + + let untrusted = iam + .create_role() + .role_name("ec2-only") + .assume_role_policy_document(EC2_TRUST) + .send() + .await + .unwrap() + .role() + .unwrap() + .arn() + .to_string(); + let trusted = iam + .create_role() + .role_name("ecs-ok") + .assume_role_policy_document(ECS_TASKS_TRUST) + .send() + .await + .unwrap() + .role() + .unwrap() + .arn() + .to_string(); + + let register = |task_role: String, exec_role: String| { + ecs.register_task_definition() + .family("role-checked") + .task_role_arn(task_role) + .execution_role_arn(exec_role) + .container_definitions( + ContainerDefinition::builder() + .name("app") + .image("public.ecr.aws/docker/library/alpine:3.20") + .build(), + ) + .send() + }; + + let foreign = "arn:aws:iam::999999999999:role/elsewhere".to_string(); + for (task_role, exec_role, refused) in [ + (untrusted.clone(), trusted.clone(), &untrusted), + (trusted.clone(), untrusted.clone(), &untrusted), + (foreign.clone(), trusted.clone(), &foreign), + ] { + let err = register(task_role, exec_role) + .await + .expect_err("role ECS cannot assume must be refused"); + let status = err.raw_response().map(|r| r.status().as_u16()); + let err = err.into_service_error(); + assert!(err.is_client_exception(), "{err:?}"); + assert_eq!( + err.meta().message(), + Some( + format!( + "ECS was unable to assume the role '{refused}' that was provided for this task. \ + Please verify that the role being passed has the proper trust relationship and \ + permissions and that your IAM user has permissions to pass this role." + ) + .as_str() + ) + ); + assert_eq!(status, Some(400)); + } + + register(trusted.clone(), trusted) + .await + .expect("roles that trust ecs-tasks.amazonaws.com register"); +} diff --git a/crates/fakecloud-e2e/tests/ecs_task_role_secrets.rs b/crates/fakecloud-e2e/tests/ecs_task_role_secrets.rs index 8ae6d50cf..6272acd11 100644 --- a/crates/fakecloud-e2e/tests/ecs_task_role_secrets.rs +++ b/crates/fakecloud-e2e/tests/ecs_task_role_secrets.rs @@ -1,8 +1,8 @@ //! ECS task-role credentials + secrets injection. //! //! - Tasks with a `taskRoleArn` get `AWS_CONTAINER_CREDENTIALS_FULL_URI` -//! injected and a dedicated HTTP endpoint on the main fakecloud -//! server vends IMDS-format credentials. +//! injected, pointing at the task-credentials endpoint on the main +//! fakecloud server. //! - Tasks with `containerDefinitions[].secrets[]` entries get the //! referenced SecretsManager secrets / SSM parameters resolved //! synchronously and injected as env vars. @@ -70,8 +70,8 @@ async fn task_logs(endpoint: &str, task_id: &str) -> String { } /// Creating a task with a `taskRoleArn` causes the runtime to inject -/// `AWS_CONTAINER_CREDENTIALS_FULL_URI`. Fetching that URL from inside -/// the container returns IMDS-format credentials. +/// `AWS_CONTAINER_CREDENTIALS_FULL_URI`; once the task stops the endpoint +/// no longer has credentials for it. #[tokio::test] async fn ecs_task_role_credentials_are_served() { if !require_docker_or_skip("ecs_task_role_credentials_are_served") { @@ -125,36 +125,20 @@ async fn ecs_task_role_credentials_are_served() { "env var not injected; logs: {logs}" ); - // Direct probe of the credential endpoint — same JSON the AWS SDK's - // ContainerCredentialsProvider would fetch from inside the container. - let creds: serde_json::Value = reqwest::Client::new() + // The task has stopped, so -- as with the ECS agent -- its credentials + // are gone. The running-task path is covered in + // `ecs_task_role_credentials.rs`. + let resp = reqwest::Client::new() .get(format!( "{}/_fakecloud/ecs/creds/{task_id}", server.endpoint() )) .send() .await - .unwrap() - .json() - .await .unwrap(); - assert!( - creds["AccessKeyId"] - .as_str() - .unwrap_or("") - .starts_with("ASIA"), - "AccessKeyId shape unexpected: {creds}" - ); - assert_eq!( - creds["RoleArn"].as_str(), - Some("arn:aws:iam::123456789012:role/app-task-role"), - "RoleArn mismatch: {creds}" - ); - assert!(creds["Token"].as_str().is_some(), "missing Token: {creds}"); - assert!( - creds["Expiration"].as_str().is_some(), - "missing Expiration: {creds}" - ); + assert_eq!(resp.status().as_u16(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["code"], "InvalidIdInRequest", "{body}"); } /// Container `secrets[]` entries resolve against SecretsManager. diff --git a/crates/fakecloud-e2e/tests/iam_pass_role.rs b/crates/fakecloud-e2e/tests/iam_pass_role.rs index cc48711f6..38c4ca854 100644 --- a/crates/fakecloud-e2e/tests/iam_pass_role.rs +++ b/crates/fakecloud-e2e/tests/iam_pass_role.rs @@ -138,12 +138,14 @@ async fn ecs_register_task_definition_rejects_role_without_ecs_tasks_trust() { .await .expect_err("RegisterTaskDefinition should fail when taskRoleArn doesn't trust ecs-tasks.amazonaws.com"); - let msg = format!("{err:?}"); + // ECS answers a role it cannot assume with ClientException naming it. + let err = err.into_service_error(); + assert!(err.is_client_exception(), "{err:?}"); assert!( - msg.contains("trust policy") - || msg.contains("InvalidParameterException") - || msg.contains("ecs-tasks.amazonaws.com"), - "expected PassRole trust-policy rejection, got: {msg}" + err.meta().message().is_some_and( + |m| m.starts_with(&format!("ECS was unable to assume the role '{bad_role}'")) + ), + "{err:?}" ); } diff --git a/crates/fakecloud-ecs/src/lib.rs b/crates/fakecloud-ecs/src/lib.rs index a786e949c..902d7d1b1 100644 --- a/crates/fakecloud-ecs/src/lib.rs +++ b/crates/fakecloud-ecs/src/lib.rs @@ -4,7 +4,9 @@ pub mod runtime; pub(crate) mod service; pub(crate) mod state; -pub use service::{run_scheduler_ticker, EcsService}; +pub use service::{ + run_scheduler_ticker, unable_to_assume_role_message, validate_task_role, EcsService, +}; pub use state::{ ecs_arn, CapacityProvider, Cluster, EcsSnapshot, EcsState, LifecycleEvent, Service, SharedEcsState, TagEntry, Task, TaskDefinition, ECS_SNAPSHOT_SCHEMA_VERSION, diff --git a/crates/fakecloud-ecs/src/service.rs b/crates/fakecloud-ecs/src/service.rs index 0dfb8e1e0..d00f31106 100644 --- a/crates/fakecloud-ecs/src/service.rs +++ b/crates/fakecloud-ecs/src/service.rs @@ -102,6 +102,66 @@ pub struct EcsService { snapshot_lock: Arc>, runtime: Option>, role_trust_validator: Option>, + /// IAM enforcement mode; a role owned by another account is refused only + /// while it is on (see [`validate_task_role`]). + iam_mode: fakecloud_core::auth::IamMode, +} + +/// The message ECS answers a role it cannot pass to a task with, whether the +/// role's trust policy leaves out `ecs-tasks.amazonaws.com` or the caller may +/// not pass it (another account's role). +pub fn unable_to_assume_role_message(role_arn: &str) -> String { + format!( + "ECS was unable to assume the role '{role_arn}' that was provided for this task. \ + Please verify that the role being passed has the proper trust relationship and \ + permissions and that your IAM user has permissions to pass this role." + ) +} + +/// The checks `RegisterTaskDefinition` (and `RunTask` role overrides) run on a +/// `taskRoleArn` / `executionRoleArn`, shared with CloudFormation's +/// `AWS::ECS::TaskDefinition`: +/// +/// - `iam:PassRole` is same-account only on AWS. Under `--iam strict` a role +/// owned by another account is refused whatever its trust policy says; +/// `--iam soft` logs the would-be denial to the IAM audit target and allows +/// it; with IAM off (the default) it is accepted, so templates carrying +/// another emulator's default account keep working. +/// - The role's trust policy must let `ecs-tasks.amazonaws.com` assume it, +/// looked up in the account that owns the role (where the task's session +/// is minted), or the caller's when the ARN names none. Always applied. +/// +/// Both fail as ECS does: `ClientException` naming the role. +pub fn validate_task_role( + caller_account: &str, + role_arn: &str, + validator: Option<&dyn fakecloud_core::auth::RoleTrustValidator>, + iam_mode: fakecloud_core::auth::IamMode, +) -> Result<(), AwsServiceError> { + let cross_account = + fakecloud_aws::arn::account_of(role_arn).is_some_and(|a| a != caller_account); + if cross_account && iam_mode.is_enabled() { + tracing::warn!( + target: "fakecloud::iam::audit", + action = "iam:PassRole", + resource = %role_arn, + account = %caller_account, + mode = %iam_mode, + "cross-account pass role denied" + ); + if iam_mode.is_strict() { + return Err(helpers::client_exception(unable_to_assume_role_message( + role_arn, + ))); + } + } + if let Some(validator) = validator { + let role_account = fakecloud_aws::arn::account_of(role_arn).unwrap_or(caller_account); + validator + .validate(role_account, role_arn, "ecs-tasks.amazonaws.com") + .map_err(|_| helpers::client_exception(unable_to_assume_role_message(role_arn)))?; + } + Ok(()) } impl EcsService { @@ -112,6 +172,7 @@ impl EcsService { snapshot_lock: Arc::new(AsyncMutex::new(())), runtime: None, role_trust_validator: None, + iam_mode: fakecloud_core::auth::IamMode::Off, } } @@ -133,18 +194,19 @@ impl EcsService { self } + /// Apply the server's IAM enforcement mode to the task-role checks. + pub fn with_iam_mode(mut self, mode: fakecloud_core::auth::IamMode) -> Self { + self.iam_mode = mode; + self + } + fn check_pass_role(&self, account_id: &str, role_arn: &str) -> Result<(), AwsServiceError> { - let Some(ref validator) = self.role_trust_validator else { - return Ok(()); - }; - if let Err(err) = validator.validate(account_id, role_arn, "ecs-tasks.amazonaws.com") { - return Err(AwsServiceError::aws_error( - StatusCode::BAD_REQUEST, - "InvalidParameterException", - err.to_string(), - )); - } - Ok(()) + validate_task_role( + account_id, + role_arn, + self.role_trust_validator.as_deref(), + self.iam_mode, + ) } pub fn state_handle(&self) -> &SharedEcsState { diff --git a/crates/fakecloud-ecs/src/service_tasks.rs b/crates/fakecloud-ecs/src/service_tasks.rs index 63db023ef..19538bf13 100644 --- a/crates/fakecloud-ecs/src/service_tasks.rs +++ b/crates/fakecloud-ecs/src/service_tasks.rs @@ -205,8 +205,17 @@ impl EcsService { let td_revision = td.revision; let td_cpu = td.cpu.clone(); let td_memory = td.memory.clone(); - let td_task_role = td.task_role_arn.clone(); - let td_exec_role = td.execution_role_arn.clone(); + // `overrides.taskRoleArn` / `overrides.executionRoleArn` replace the + // task definition's roles for this task (checked above), so the task + // runs -- and its credentials endpoint vends -- the overriding role. + let role_override = |key: &str| { + body.get("overrides") + .and_then(|o| opt_str(o, key)) + .map(String::from) + }; + let td_task_role = role_override("taskRoleArn").or_else(|| td.task_role_arn.clone()); + let td_exec_role = + role_override("executionRoleArn").or_else(|| td.execution_role_arn.clone()); let td_containers = td.container_definitions.clone(); // RunTask supports propagateTags=TASK_DEFINITION to copy the // TaskDefinition's tags onto each spawned task, in addition to @@ -663,7 +672,7 @@ impl EcsService { } #[cfg(test)] -mod multi_container_tests { +pub(super) mod multi_container_tests { use super::*; use crate::EcsService; use bytes::Bytes; @@ -688,7 +697,7 @@ mod multi_container_tests { svc } - pub(super) fn make_request(action: &str, body: Value) -> AwsRequest { + pub(crate) fn make_request(action: &str, body: Value) -> AwsRequest { let body_bytes = Bytes::from(serde_json::to_vec(&body).unwrap()); AwsRequest { service: "ecs".into(), @@ -896,6 +905,49 @@ mod multi_container_tests { assert_eq!(d2body["failures"][0]["reason"], "MISSING"); } + #[test] + fn run_task_role_overrides_replace_the_task_definition_roles() { + let svc = fresh_service(); + svc.register_task_definition(&make_request( + "RegisterTaskDefinition", + json!({ + "family": "roles", + "taskRoleArn": "arn:aws:iam::000000000000:role/td-task", + "executionRoleArn": "arn:aws:iam::000000000000:role/td-exec", + "containerDefinitions": [{"name": "app", "image": "alpine"}] + }), + )) + .unwrap(); + let run = |overrides: Value| { + let resp = svc + .run_task(&make_request( + "RunTask", + json!({"cluster": "default", "taskDefinition": "roles", "overrides": overrides}), + )) + .unwrap(); + let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + let arn = body["tasks"][0]["taskArn"].as_str().unwrap().to_string(); + let id = arn.rsplit('/').next().unwrap().to_string(); + let accounts = svc.state.read(); + let task = &accounts.get("000000000000").unwrap().tasks[&id]; + (task.task_role_arn.clone(), task.execution_role_arn.clone()) + }; + assert_eq!( + run(json!({})), + ( + Some("arn:aws:iam::000000000000:role/td-task".into()), + Some("arn:aws:iam::000000000000:role/td-exec".into()) + ) + ); + assert_eq!( + run(json!({"taskRoleArn": "arn:aws:iam::000000000000:role/override"})), + ( + Some("arn:aws:iam::000000000000:role/override".into()), + Some("arn:aws:iam::000000000000:role/td-exec".into()) + ) + ); + } + #[test] fn register_task_def_with_two_containers_then_run_task_starts_both() { let svc = fresh_service(); diff --git a/crates/fakecloud-ecs/src/service_tests.rs b/crates/fakecloud-ecs/src/service_tests.rs index e17f30486..312d4271d 100644 --- a/crates/fakecloud-ecs/src/service_tests.rs +++ b/crates/fakecloud-ecs/src/service_tests.rs @@ -779,3 +779,147 @@ mod scheduler_reconcile { assert_eq!(v["settings"], json!([])); } } + +/// Trusts exactly one role, like a role whose trust policy names +/// `ecs-tasks.amazonaws.com`; rejects every other. +struct TrustsOnly(&'static str); + +impl fakecloud_core::auth::RoleTrustValidator for TrustsOnly { + fn validate( + &self, + _account_id: &str, + role_arn: &str, + service_principal: &str, + ) -> Result<(), fakecloud_core::auth::PassRoleError> { + assert_eq!(service_principal, "ecs-tasks.amazonaws.com"); + if role_arn == self.0 { + Ok(()) + } else { + Err(fakecloud_core::auth::PassRoleError::TrustPolicyDenies { + role_arn: role_arn.to_string(), + service_principal: service_principal.to_string(), + }) + } + } +} + +#[test] +fn validate_task_role_matches_ecs_errors() { + use fakecloud_core::auth::IamMode; + let trusted = "arn:aws:iam::123456789012:role/trusted"; + let untrusted = "arn:aws:iam::123456789012:role/untrusted"; + let validator = TrustsOnly(trusted); + + validate_task_role("123456789012", trusted, Some(&validator), IamMode::Strict).unwrap(); + + let err = + validate_task_role("123456789012", untrusted, Some(&validator), IamMode::Off).unwrap_err(); + assert_eq!(err.code(), "ClientException"); + assert_eq!(err.message(), unable_to_assume_role_message(untrusted)); + assert!(err.message().starts_with( + "ECS was unable to assume the role 'arn:aws:iam::123456789012:role/untrusted' that was provided for this task." + )); + + // Another account's role: accepted with IAM off or soft, refused under + // strict whatever its trust policy says. + let foreign = "arn:aws:iam::999999999999:role/elsewhere"; + let trusts_foreign = TrustsOnly(foreign); + validate_task_role("123456789012", foreign, Some(&trusts_foreign), IamMode::Off).unwrap(); + validate_task_role( + "123456789012", + foreign, + Some(&trusts_foreign), + IamMode::Soft, + ) + .unwrap(); + let err = validate_task_role( + "123456789012", + foreign, + Some(&trusts_foreign), + IamMode::Strict, + ) + .unwrap_err(); + assert_eq!(err.code(), "ClientException"); + assert_eq!(err.message(), unable_to_assume_role_message(foreign)); +} + +#[test] +fn register_task_definition_checks_both_roles() { + let svc = EcsService::new(std::sync::Arc::new(parking_lot::RwLock::new( + fakecloud_core::multi_account::MultiAccountState::new( + "000000000000", + "us-east-1", + "http://localhost:4566", + ), + ))) + .with_role_trust_validator(std::sync::Arc::new(TrustsOnly( + "arn:aws:iam::000000000000:role/ok", + ))); + let register = |task_role: &str, exec_role: &str| { + svc.register_task_definition(&service_tasks::multi_container_tests::make_request( + "RegisterTaskDefinition", + json!({ + "family": "f", + "taskRoleArn": task_role, + "executionRoleArn": exec_role, + "containerDefinitions": [{"name": "app", "image": "alpine"}] + }), + )) + }; + let ok = "arn:aws:iam::000000000000:role/ok"; + let bad = "arn:aws:iam::000000000000:role/bad"; + register(ok, ok).expect("trusted roles register"); + for (task_role, exec_role) in [(bad, ok), (ok, bad)] { + let Err(err) = register(task_role, exec_role) else { + panic!("untrusted role registered"); + }; + assert_eq!(err.code(), "ClientException"); + assert_eq!(err.message(), unable_to_assume_role_message(bad)); + } +} + +/// Trust decided per account: the role named `app` trusts ECS tasks only in +/// account 999999999999; the caller's own `app` role does not. +struct TrustsOnlyIn(&'static str); + +impl fakecloud_core::auth::RoleTrustValidator for TrustsOnlyIn { + fn validate( + &self, + account_id: &str, + role_arn: &str, + service_principal: &str, + ) -> Result<(), fakecloud_core::auth::PassRoleError> { + if account_id == self.0 { + Ok(()) + } else { + Err(fakecloud_core::auth::PassRoleError::TrustPolicyDenies { + role_arn: role_arn.to_string(), + service_principal: service_principal.to_string(), + }) + } + } +} + +#[test] +fn another_accounts_role_is_checked_against_its_own_trust_policy() { + use fakecloud_core::auth::IamMode; + let validator = TrustsOnlyIn("999999999999"); + // The role's own account trusts ECS tasks; a same-named role in the + // caller's account that does not must not decide the outcome. + validate_task_role( + "123456789012", + "arn:aws:iam::999999999999:role/app", + Some(&validator), + IamMode::Off, + ) + .expect("checked in the role's account"); + let Err(err) = validate_task_role( + "123456789012", + "arn:aws:iam::123456789012:role/app", + Some(&validator), + IamMode::Off, + ) else { + panic!("caller's own untrusted role accepted"); + }; + assert_eq!(err.code(), "ClientException"); +} diff --git a/crates/fakecloud-iam/src/sts_service/container_creds.rs b/crates/fakecloud-iam/src/sts_service/container_creds.rs index 17b925125..0cbff3a60 100644 --- a/crates/fakecloud-iam/src/sts_service/container_creds.rs +++ b/crates/fakecloud-iam/src/sts_service/container_creds.rs @@ -185,11 +185,8 @@ pub fn mint_session_credentials( let creds = StsCredentials::generate(); let issued_at = Utc::now(); let expiration = issued_at + duration; - let partition = partition_of(role_arn); - let account_id = - extract_account_from_arn(role_arn).unwrap_or_else(|| default_account_id.to_string()); - let role_name = assumed_role_name(role_arn); - let assumed_role_arn = format_assumed_role_arn(partition, &account_id, role_name, session_name); + let (account_id, assumed_role_arn) = + session_principal(default_account_id, role_arn, session_name); let user_id = format!("{}:{}", deterministic_role_id(role_arn), session_name); { @@ -233,6 +230,53 @@ pub fn mint_session_credentials( } } +/// The account a session for `role_arn` is registered under and its +/// assumed-role principal ARN. +fn session_principal( + default_account_id: &str, + role_arn: &str, + session_name: &str, +) -> (String, String) { + let account_id = + extract_account_from_arn(role_arn).unwrap_or_else(|| default_account_id.to_string()); + let assumed_role_arn = format_assumed_role_arn( + partition_of(role_arn), + &account_id, + assumed_role_name(role_arn), + session_name, + ); + (account_id, assumed_role_arn) +} + +/// Unregister every registered session of `role_arn` named `session_name`, +/// whoever holds it. For sessions minted by an earlier server run whose +/// holder is gone (persisted IAM state outlives the in-memory caches), e.g. +/// the sessions of ECS tasks a restart stopped. Returns how many were removed. +pub fn revoke_sessions_named( + iam: &SharedIamState, + default_account_id: &str, + role_arn: &str, + session_name: &str, +) -> usize { + let (account_id, assumed_role_arn) = + session_principal(default_account_id, role_arn, session_name); + let mut accounts = iam.write(); + let Some(state) = accounts.get_mut(&account_id) else { + return 0; + }; + let keys: Vec = state + .sts_temp_credentials + .iter() + .filter(|(_, c)| c.principal_arn == assumed_role_arn) + .map(|(k, _)| k.clone()) + .collect(); + for key in &keys { + state.sts_temp_credentials.remove(key); + state.credential_identities.remove(key); + } + keys.len() +} + /// True if `creds` is still registered in IAM state (i.e. not cleared by a /// `/_reset`). Used so the cache never hands back credentials that have been /// purged out from under it. @@ -359,45 +403,162 @@ impl ContainerCredentialCache { let mut cache = self.by_role.lock(); if let Some(rc) = cache.get_mut(role_arn) { - // Drop any superseded creds that have now actually expired. - rc.retired.retain(|c| { - if c.expiration <= now { - evict_container_credentials(iam, c); - false - } else { - true - } + return rc.reuse_or_rotate(iam, now, || { + mint_container_credentials(iam, default_account_id, role_arn, duration) }); - // Reuse the current credential while it is comfortably valid AND - // still registered (a /_reset clears IAM state under the cache). - if rc.current.expiration - now > CONTAINER_CREDENTIALS_REFRESH_WINDOW - && credential_registered(iam, &rc.current) - { - return rc.current.clone(); - } - // Rotate. Keep the superseded credential valid until its real - // expiration (overlapping validity) unless it is already expired or - // already gone from state. - let fresh = mint_container_credentials(iam, default_account_id, role_arn, duration); - let old = std::mem::replace(&mut rc.current, fresh.clone()); - if old.expiration > now && credential_registered(iam, &old) { - rc.retired.push(old); + } + + let fresh = mint_container_credentials(iam, default_account_id, role_arn, duration); + cache.insert(role_arn.to_string(), RoleCreds::new(fresh.clone())); + fresh + } +} + +impl RoleCreds { + fn new(current: ContainerCredentials) -> Self { + Self { + current, + retired: Vec::new(), + } + } + + /// Hand back the current credential while it is comfortably valid and + /// still registered; otherwise rotate to a fresh one from `mint`, keeping + /// the superseded set valid until its real expiration. + fn reuse_or_rotate( + &mut self, + iam: &SharedIamState, + now: DateTime, + mint: impl FnOnce() -> ContainerCredentials, + ) -> ContainerCredentials { + // Drop any superseded creds that have now actually expired. + self.retired.retain(|c| { + if c.expiration <= now { + evict_container_credentials(iam, c); + false } else { - evict_container_credentials(iam, &old); + true } - return fresh; + }); + // Reuse the current credential while it is comfortably valid AND + // still registered (a /_reset clears IAM state under the cache). + if self.current.expiration - now > CONTAINER_CREDENTIALS_REFRESH_WINDOW + && credential_registered(iam, &self.current) + { + return self.current.clone(); + } + // Rotate. Keep the superseded credential valid until its real + // expiration (overlapping validity) unless it is already expired or + // already gone from state. + let fresh = mint(); + let old = std::mem::replace(&mut self.current, fresh.clone()); + if old.expiration > now && credential_registered(iam, &old) { + self.retired.push(old); + } else { + evict_container_credentials(iam, &old); } + fresh + } - let fresh = mint_container_credentials(iam, default_account_id, role_arn, duration); + /// Unregister every credential this entry handed out. + fn evict_all(&self, iam: &SharedIamState) { + evict_container_credentials(iam, &self.current); + for c in &self.retired { + evict_container_credentials(iam, c); + } + } +} + +/// The session a workload runs under: its role and the session name AWS +/// gives it (an ECS task's role session is named after the task ID). +struct WorkloadCreds { + role_arn: String, + session_name: String, + creds: RoleCreds, +} + +/// Caches the credentials vended to running workloads (ECS tasks), one live +/// set per workload, each for that workload's own role and session name. +/// +/// Refetches within the validity window return the same set; near expiry a +/// fresh set is minted and the superseded one stays valid until it expires, +/// as the ECS agent does. [`WorkloadCredentialCache::revoke`] unregisters +/// every set a workload was handed once it stops. +/// +/// Lock order matches [`ContainerCredentialCache`]: this `Mutex` before the +/// IAM `RwLock`, never the reverse. +#[derive(Default)] +pub struct WorkloadCredentialCache { + by_workload: Mutex>, +} + +impl WorkloadCredentialCache { + pub fn new() -> Self { + Self::default() + } + + /// Credentials for the workload `key` running as `role_arn` with role + /// session `session_name`: the cached set while it is comfortably valid + /// and still registered, a freshly minted (and registered) one otherwise. + /// A workload whose role changed has its old sessions revoked first. + pub fn get_or_mint( + &self, + iam: &SharedIamState, + default_account_id: &str, + key: &str, + role_arn: &str, + session_name: &str, + duration: Duration, + ) -> ContainerCredentials { + let now = Utc::now(); + let mint = + || mint_session_credentials(iam, default_account_id, role_arn, session_name, duration); + let mut cache = self.by_workload.lock(); + if let Some(entry) = cache.get_mut(key) { + if entry.role_arn == role_arn && entry.session_name == session_name { + return entry.creds.reuse_or_rotate(iam, now, mint); + } + entry.creds.evict_all(iam); + cache.remove(key); + } + let fresh = mint(); cache.insert( - role_arn.to_string(), - RoleCreds { - current: fresh.clone(), - retired: Vec::new(), + key.to_string(), + WorkloadCreds { + role_arn: role_arn.to_string(), + session_name: session_name.to_string(), + creds: RoleCreds::new(fresh.clone()), }, ); fresh } + + /// Unregister every credential set the workload `key` was handed, so + /// requests signed with them no longer authenticate. Idempotent; returns + /// whether the workload had any. + pub fn revoke(&self, iam: &SharedIamState, key: &str) -> bool { + let removed = self.by_workload.lock().remove(key); + match removed { + Some(entry) => { + entry.creds.evict_all(iam); + true + } + None => false, + } + } + + /// Revoke every workload for which `still_running` returns false. + pub fn revoke_unless(&self, iam: &SharedIamState, still_running: impl Fn(&str) -> bool) { + let mut cache = self.by_workload.lock(); + cache.retain(|key, entry| { + if still_running(key) { + true + } else { + entry.creds.evict_all(iam); + false + } + }); + } } #[cfg(test)] @@ -662,4 +823,99 @@ mod tests { // Revoking twice is a no-op. issuer.revoke(&creds); } + + #[test] + fn workload_cache_names_the_session_and_revokes_every_rotation() { + let iam = shared(); + let cache = WorkloadCredentialCache::new(); + let role = "arn:aws:iam::123456789012:role/app"; + let resolver = IamCredentialResolver::new(iam.clone()); + + let long = cache.get_or_mint(&iam, "123456789012", "a/t1", role, "t1", Duration::hours(1)); + let again = cache.get_or_mint(&iam, "123456789012", "a/t1", role, "t1", Duration::hours(1)); + assert_eq!( + long.access_key_id, again.access_key_id, + "reuse within window" + ); + assert_eq!( + long.assumed_role_arn, + "arn:aws:sts::123456789012:assumed-role/app/t1" + ); + + // Near expiry a fresh set is minted; the superseded one stays valid. + let short = Duration::minutes(2); + let a = cache.get_or_mint(&iam, "123456789012", "a/t2", role, "t2", short); + let b = cache.get_or_mint(&iam, "123456789012", "a/t2", role, "t2", short); + assert_ne!(a.access_key_id, b.access_key_id, "rotates near expiry"); + assert!(resolver.resolve(&a.access_key_id).is_some()); + + // Revoking the workload unregisters the current and superseded sets, + // and leaves other workloads alone. + assert!(cache.revoke(&iam, "a/t2")); + assert!(resolver.resolve(&a.access_key_id).is_none()); + assert!(resolver.resolve(&b.access_key_id).is_none()); + assert!(resolver.resolve(&long.access_key_id).is_some()); + assert!(!cache.revoke(&iam, "a/t2"), "idempotent"); + + cache.revoke_unless(&iam, |key| key != "a/t1"); + assert!(resolver.resolve(&long.access_key_id).is_none()); + assert_eq!(temp_cred_count(&iam, "123456789012"), 0); + } + + #[test] + fn workload_cache_role_change_revokes_the_old_session() { + let iam = shared(); + let cache = WorkloadCredentialCache::new(); + let resolver = IamCredentialResolver::new(iam.clone()); + let first = cache.get_or_mint( + &iam, + "123456789012", + "k", + "arn:aws:iam::123456789012:role/one", + "t", + Duration::hours(1), + ); + let second = cache.get_or_mint( + &iam, + "123456789012", + "k", + "arn:aws:iam::123456789012:role/two", + "t", + Duration::hours(1), + ); + assert_eq!( + second.assumed_role_arn, + "arn:aws:sts::123456789012:assumed-role/two/t" + ); + assert!(resolver.resolve(&first.access_key_id).is_none()); + assert!(resolver.resolve(&second.access_key_id).is_some()); + } + + #[test] + fn sessions_named_after_a_workload_are_revoked_without_the_cache() { + let iam = shared(); + let role = "arn:aws:iam::123456789012:role/app"; + // Minted by a cache that no longer exists (an earlier server run). + let a = WorkloadCredentialCache::new().get_or_mint( + &iam, + "123456789012", + "k", + role, + "task-1", + Duration::hours(1), + ); + let other = + mint_session_credentials(&iam, "123456789012", role, "task-2", Duration::hours(1)); + assert_eq!( + revoke_sessions_named(&iam, "123456789012", role, "task-1"), + 1 + ); + let resolver = IamCredentialResolver::new(iam.clone()); + assert!(resolver.resolve(&a.access_key_id).is_none()); + assert!(resolver.resolve(&other.access_key_id).is_some()); + assert_eq!( + revoke_sessions_named(&iam, "123456789012", role, "task-1"), + 0 + ); + } } diff --git a/crates/fakecloud-sdk/src/client.rs b/crates/fakecloud-sdk/src/client.rs index e69540040..0b893260b 100644 --- a/crates/fakecloud-sdk/src/client.rs +++ b/crates/fakecloud-sdk/src/client.rs @@ -1737,8 +1737,10 @@ impl EcsClient<'_> { FakeCloud::parse(resp).await } - /// Fetch the IAM task-role credentials that fakecloud hands out via - /// `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` to the container. Fields + /// Fetch the task-role credentials fakecloud hands a running task's + /// containers via `AWS_CONTAINER_CREDENTIALS_FULL_URI` (a session for + /// the task role named after the task ID). Errors once the task has + /// stopped, or for a task without a task role. Fields /// use AWS's native PascalCase (`AccessKeyId`, `SecretAccessKey`, /// `Token`, `Expiration`, `RoleArn`). pub async fn task_credentials( diff --git a/crates/fakecloud-server/src/cli.rs b/crates/fakecloud-server/src/cli.rs index 8c97d5b00..ea05f5aa6 100644 --- a/crates/fakecloud-server/src/cli.rs +++ b/crates/fakecloud-server/src/cli.rs @@ -154,7 +154,8 @@ pub(crate) struct Cli { /// them (rather than honoring `AWS_EC2_METADATA_SERVICE_ENDPOINT` / /// `AWS_CONTAINER_CREDENTIALS_*`) resolve credentials unmodified: /// IMDS at `169.254.169.254:80` and ECS container credentials at - /// `169.254.170.2:80/creds`. Off by default. Requires running fakecloud as + /// `169.254.170.2:80/creds` (plus ECS task-role credentials at the agent's + /// `169.254.170.2:80/v2/credentials/`). Off by default. Requires running fakecloud as /// root (to bind port 80) with those addresses already assigned to the /// loopback interface: fakecloud binds them but never creates or deletes the /// alias, and logs the exact manual command if binding fails (the main diff --git a/crates/fakecloud-server/src/ecs_creds.rs b/crates/fakecloud-server/src/ecs_creds.rs new file mode 100644 index 000000000..e9e2eb941 --- /dev/null +++ b/crates/fakecloud-server/src/ecs_creds.rs @@ -0,0 +1,433 @@ +//! The ECS task-role credentials endpoint (`GET /_fakecloud/ecs/creds/{task_id}`). +//! +//! A task whose task definition names a `taskRoleArn` gets +//! `AWS_CONTAINER_CREDENTIALS_FULL_URI` pointed here. Like the ECS agent, the +//! endpoint hands out credentials for that role's session named after the +//! task ID (`assumed-role//`), minted and registered like an +//! `AssumeRole` session so they verify under `--verify-sigv4` and are +//! evaluated as the role under `--iam`. Refetches within the validity window +//! return the same set; near expiry a fresh one is minted. +//! +//! Once the task stops its credentials are revoked (the sweep in +//! [`run_revocation_sweep`]), and the endpoint answers a stopped, unknown, or +//! role-less task the way the agent answers an ID it holds no credentials +//! for: HTTP 400 `{"code":"InvalidIdInRequest","message":"CredentialsV2Request: +//! Credentials not found","HTTPErrorCode":400}`. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use fakecloud_ecs::SharedEcsState; +use fakecloud_iam::sts_service::container_creds::{ + revoke_sessions_named, ContainerCredentials, WorkloadCredentialCache, + DEFAULT_CONTAINER_CREDENTIALS_DURATION, +}; +use fakecloud_iam::SharedIamState; + +/// Error prefix the ECS agent's v2 credentials handler puts on its messages. +const ERR_PREFIX: &str = "CredentialsV2Request: "; + +/// How often stopped tasks' credentials are revoked. +const SWEEP_INTERVAL: Duration = Duration::from_secs(1); + +/// Why the endpoint has no credentials for a request, as the agent reports it. +#[derive(Debug, PartialEq, Eq)] +pub enum CredentialsError { + /// The request carried no task ID. + NoId, + /// No running task with that ID has a task role. + NotFound, +} + +impl IntoResponse for CredentialsError { + fn into_response(self) -> Response { + let (code, message) = match self { + Self::NoId => ("NoIdInRequest", "No Credential ID in the request"), + Self::NotFound => ("InvalidIdInRequest", "Credentials not found"), + }; + ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "code": code, + "message": format!("{ERR_PREFIX}{message}"), + "HTTPErrorCode": StatusCode::BAD_REQUEST.as_u16(), + })), + ) + .into_response() + } +} + +/// Serves task-role credentials for ECS tasks and revokes them once the task +/// stops. +pub struct EcsTaskCredentials { + ecs: SharedEcsState, + iam: SharedIamState, + default_account_id: String, + cache: WorkloadCredentialCache, +} + +/// The full ARN of a task's role. ECS accepts a bare role name for +/// `taskRoleArn`; it names a role in the task's own account and partition. +fn task_role_arn(task: &fakecloud_ecs::Task, task_account: &str) -> Option { + let role = task.task_role_arn.as_deref()?; + if role.starts_with("arn:") { + return Some(role.to_string()); + } + Some( + fakecloud_aws::arn::Arn::global("iam", task_account, &format!("role/{role}")) + .with_partition(fakecloud_aws::arn::partition_of(&task.task_arn)) + .to_string(), + ) +} + +fn cache_key(account_id: &str, task_id: &str) -> String { + format!("{account_id}/{task_id}") +} + +impl EcsTaskCredentials { + pub fn new( + ecs: SharedEcsState, + iam: SharedIamState, + default_account_id: impl Into, + ) -> Arc { + let this = Arc::new(Self { + ecs, + iam, + default_account_id: default_account_id.into(), + cache: WorkloadCredentialCache::new(), + }); + this.revoke_persisted_sessions(); + this + } + + /// Revoke the sessions of stopped tasks still registered from an earlier + /// run. With persistence on, IAM state (and the sessions in it) survives + /// a restart while the cache that would revoke them does not, and the + /// restart stops every task it restores. + fn revoke_persisted_sessions(&self) { + let stopped: Vec<(String, String)> = { + let accounts = self.ecs.read(); + accounts + .iter() + .flat_map(|(account_id, state)| { + state + .tasks + .iter() + .filter(|(_, t)| t.last_status == "STOPPED") + .filter_map(move |(id, t)| { + Some((task_role_arn(t, account_id)?, id.clone())) + }) + }) + .collect() + }; + for (role_arn, task_id) in stopped { + revoke_sessions_named(&self.iam, &self.default_account_id, &role_arn, &task_id); + } + } + + /// Credentials for the task role of the running task `task_id`. + /// + /// The ECS read lock is held until the credentials are minted, so a task + /// cannot stop (and be swept) between the running check and the mint and + /// still walk away with a fresh session. + pub fn credentials(&self, task_id: &str) -> Result { + if task_id.is_empty() { + return Err(CredentialsError::NoId); + } + let accounts = self.ecs.read(); + let (account_id, role_arn) = accounts + .iter() + .find_map(|(account_id, state)| { + let task = state.tasks.get(task_id)?; + (task.last_status != "STOPPED").then_some((account_id, task)) + }) + .and_then(|(account_id, task)| Some((account_id, task_role_arn(task, account_id)?))) + .ok_or(CredentialsError::NotFound)?; + let creds = self.cache.get_or_mint( + &self.iam, + account_id, + &cache_key(account_id, task_id), + &role_arn, + task_id, + DEFAULT_CONTAINER_CREDENTIALS_DURATION, + ); + drop(accounts); + Ok(creds) + } + + /// Revoke the credentials of every task that has stopped (or is gone). + /// + /// The ECS read lock is held until the revocation is done, so a task + /// started (and handed credentials) while the sweep runs is never judged + /// against a snapshot that predates it. Lock order, here and in + /// `credentials`: ECS state, then the cache, then IAM. + pub fn revoke_stopped(&self) { + let accounts = self.ecs.read(); + let running: HashSet = accounts + .iter() + .flat_map(|(account_id, state)| { + state + .tasks + .iter() + .filter(|(_, t)| t.last_status != "STOPPED") + .map(move |(task_id, _)| cache_key(account_id, task_id)) + }) + .collect(); + self.cache + .revoke_unless(&self.iam, |key| running.contains(key)); + drop(accounts); + } + + /// The endpoint response for `task_id`. + pub fn respond(&self, task_id: &str) -> Response { + match self.credentials(task_id) { + Ok(creds) => (StatusCode::OK, axum::Json(creds.to_container_json())).into_response(), + Err(e) => e.into_response(), + } + } +} + +/// Revoke stopped tasks' credentials for as long as the server runs. +pub async fn run_revocation_sweep(creds: Arc) { + let mut interval = tokio::time::interval(SWEEP_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + creds.revoke_stopped(); + } +} + +/// Test fixtures shared with the link-local listener's tests. +#[cfg(test)] +pub(crate) mod test_support { + use fakecloud_ecs::SharedEcsState; + + /// Insert a RUNNING task `task_id` into `account` with the given role. + pub(crate) fn add_task(ecs: &SharedEcsState, account: &str, task_id: &str, role: Option<&str>) { + let task: fakecloud_ecs::Task = serde_json::from_value(serde_json::json!({ + "task_arn": format!("arn:aws:ecs:us-east-1:{account}:task/c/{task_id}"), + "task_id": task_id, + "cluster_arn": format!("arn:aws:ecs:us-east-1:{account}:cluster/c"), + "cluster_name": "c", + "task_definition_arn": format!("arn:aws:ecs:us-east-1:{account}:task-definition/f:1"), + "family": "f", + "revision": 1, + "last_status": "RUNNING", + "desired_status": "RUNNING", + "launch_type": "FARGATE", + "containers": [], + "overrides": {}, + "connectivity": "CONNECTED", + "created_at": chrono::Utc::now(), + "task_role_arn": role, + "tags": [], + })) + .expect("task deserializes"); + ecs.write() + .get_or_create(account) + .tasks + .insert(task_id.to_string(), task); + } +} + +#[cfg(test)] +mod tests { + use super::test_support::add_task; + use super::*; + use fakecloud_core::auth::CredentialResolver; + use fakecloud_core::multi_account::MultiAccountState; + use fakecloud_iam::credential_resolver::IamCredentialResolver; + use parking_lot::RwLock; + + const ACCOUNT: &str = "123456789012"; + const ROLE: &str = "arn:aws:iam::123456789012:role/app-task-role"; + + fn setup() -> (SharedEcsState, SharedIamState, Arc) { + let ecs: SharedEcsState = Arc::new(RwLock::new(MultiAccountState::new( + ACCOUNT, + "us-east-1", + "http://localhost:4566", + ))); + let iam: SharedIamState = Arc::new(RwLock::new(MultiAccountState::new( + ACCOUNT, + "us-east-1", + "http://localhost:4566", + ))); + let creds = EcsTaskCredentials::new(ecs.clone(), iam.clone(), ACCOUNT); + (ecs, iam, creds) + } + + fn stop_task(ecs: &SharedEcsState, account: &str, task_id: &str) { + ecs.write() + .get_or_create(account) + .tasks + .get_mut(task_id) + .unwrap() + .last_status = "STOPPED".into(); + } + + fn resolves(iam: &SharedIamState, creds: &ContainerCredentials) -> bool { + IamCredentialResolver::new(iam.clone()) + .resolve(&creds.access_key_id) + .is_some() + } + + #[test] + fn running_task_gets_its_role_session_named_after_the_task() { + let (ecs, iam, endpoint) = setup(); + add_task(&ecs, ACCOUNT, "abc123", Some(ROLE)); + let creds = endpoint.credentials("abc123").expect("credentials"); + assert_eq!(creds.role_arn, ROLE); + assert_eq!( + creds.assumed_role_arn, + "arn:aws:sts::123456789012:assumed-role/app-task-role/abc123" + ); + assert!(creds.access_key_id.starts_with("FSIA"), "{creds:?}"); + let resolved = IamCredentialResolver::new(iam.clone()) + .resolve(&creds.access_key_id) + .expect("registered"); + assert_eq!(resolved.secret_access_key, creds.secret_access_key); + assert_eq!(resolved.principal.arn, creds.assumed_role_arn); + + // Refetches within the validity window reuse the same set. + let again = endpoint.credentials("abc123").unwrap(); + assert_eq!(again.access_key_id, creds.access_key_id); + let json = again.to_container_json(); + for field in [ + "AccessKeyId", + "SecretAccessKey", + "Token", + "Expiration", + "RoleArn", + ] { + assert!(json.get(field).is_some(), "missing {field}: {json}"); + } + } + + #[test] + fn stopped_task_is_revoked_and_refused() { + let (ecs, iam, endpoint) = setup(); + add_task(&ecs, ACCOUNT, "t1", Some(ROLE)); + add_task(&ecs, ACCOUNT, "t2", Some(ROLE)); + let c1 = endpoint.credentials("t1").unwrap(); + let c2 = endpoint.credentials("t2").unwrap(); + assert_ne!(c1.access_key_id, c2.access_key_id); + + stop_task(&ecs, ACCOUNT, "t1"); + endpoint.revoke_stopped(); + assert!( + !resolves(&iam, &c1), + "stopped task's creds still registered" + ); + assert!(resolves(&iam, &c2), "running task's creds were revoked"); + assert_eq!( + endpoint.credentials("t1").unwrap_err(), + CredentialsError::NotFound + ); + + // A task removed from state (e.g. an ECS reset) is revoked too. + ecs.write().get_or_create(ACCOUNT).tasks.clear(); + endpoint.revoke_stopped(); + assert!(!resolves(&iam, &c2)); + } + + #[test] + fn roleless_unknown_and_empty_ids_are_refused_like_the_agent() { + let (ecs, _iam, endpoint) = setup(); + add_task(&ecs, ACCOUNT, "norole", None); + assert_eq!( + endpoint.credentials("norole").unwrap_err(), + CredentialsError::NotFound + ); + assert_eq!( + endpoint.credentials("missing").unwrap_err(), + CredentialsError::NotFound + ); + assert_eq!( + endpoint.credentials("").unwrap_err(), + CredentialsError::NoId + ); + } + + #[tokio::test] + async fn error_body_matches_the_agent() { + let (_ecs, _iam, endpoint) = setup(); + let resp = endpoint.respond("missing"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "code": "InvalidIdInRequest", + "message": "CredentialsV2Request: Credentials not found", + "HTTPErrorCode": 400, + }) + ); + } + + #[test] + fn iam_reset_under_the_cache_mints_fresh_registered_creds() { + let (ecs, iam, endpoint) = setup(); + add_task(&ecs, ACCOUNT, "t", Some(ROLE)); + let before = endpoint.credentials("t").unwrap(); + iam.write().reset(); + let after = endpoint.credentials("t").unwrap(); + assert_ne!(before.access_key_id, after.access_key_id); + assert!(resolves(&iam, &after)); + } + + #[test] + fn task_in_another_account_mints_there() { + let (ecs, iam, endpoint) = setup(); + let role = "arn:aws:iam::222222222222:role/other"; + add_task(&ecs, "222222222222", "x", Some(role)); + let creds = endpoint.credentials("x").unwrap(); + assert_eq!( + creds.assumed_role_arn, + "arn:aws:sts::222222222222:assumed-role/other/x" + ); + assert!(resolves(&iam, &creds)); + } + + #[test] + fn sessions_of_tasks_stopped_by_a_restart_are_revoked_on_startup() { + let (ecs, iam, before_restart) = setup(); + add_task(&ecs, ACCOUNT, "t", Some(ROLE)); + add_task(&ecs, ACCOUNT, "still", Some(ROLE)); + let stale = before_restart.credentials("t").unwrap(); + let live = before_restart.credentials("still").unwrap(); + drop(before_restart); + // The restart stops the restored task; IAM state kept its session. + stop_task(&ecs, ACCOUNT, "t"); + let _after_restart = EcsTaskCredentials::new(ecs.clone(), iam.clone(), ACCOUNT); + assert!( + !resolves(&iam, &stale), + "persisted session survived restart" + ); + assert!(resolves(&iam, &live)); + } + + #[test] + fn short_role_name_resolves_in_the_tasks_account() { + let (ecs, iam, endpoint) = setup(); + add_task(&ecs, "222222222222", "x", Some("app-role")); + let creds = endpoint.credentials("x").unwrap(); + assert_eq!(creds.role_arn, "arn:aws:iam::222222222222:role/app-role"); + assert_eq!( + creds.assumed_role_arn, + "arn:aws:sts::222222222222:assumed-role/app-role/x" + ); + assert!(resolves(&iam, &creds)); + + // And its persisted session is found there after a restart. + stop_task(&ecs, "222222222222", "x"); + let _after_restart = EcsTaskCredentials::new(ecs.clone(), iam.clone(), ACCOUNT); + assert!(!resolves(&iam, &creds)); + } +} diff --git a/crates/fakecloud-server/src/link_local.rs b/crates/fakecloud-server/src/link_local.rs index 9cff6bb4f..f58c099e3 100644 --- a/crates/fakecloud-server/src/link_local.rs +++ b/crates/fakecloud-server/src/link_local.rs @@ -9,8 +9,11 @@ //! 80) so such apps resolve credentials unmodified: //! - `169.254.169.254:80` serves **only** the IMDS `/latest/*` surface (not the //! rest of the app), returning 404 for anything else. -//! - `169.254.170.2:80` serves container credentials at a single fixed path -//! (`/creds`); set `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/creds`. +//! - `169.254.170.2:80` serves the server's container credentials at a fixed +//! path (`/creds`; set `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/creds`), and +//! an ECS task's role credentials at the ECS agent's own relative URI, +//! `/v2/credentials/` (the same credentials as +//! `/_fakecloud/ecs/creds/`). //! //! This needs privileged host setup that fakecloud deliberately does NOT perform //! itself: the operator assigns the addresses to the loopback interface up front @@ -29,6 +32,7 @@ use std::sync::Arc; use axum::{extract::State, routing::get, Json, Router}; +use crate::ecs_creds::EcsTaskCredentials; use crate::imds::ImdsContext; /// IMDS link-local address. @@ -38,6 +42,8 @@ const ECS_CREDS_IP: &str = "169.254.170.2"; /// The fixed path the ECS-credentials listener serves (set /// `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` to this). const ECS_CREDS_PATH: &str = "/creds"; +/// The ECS agent's task-credentials relative URI, keyed by task ID here. +const ECS_TASK_CREDS_PATH: &str = "/v2/credentials/{task_id}"; /// Both services listen on port 80, as on real AWS. const PORT: u16 = 80; @@ -93,16 +99,34 @@ async fn ecs_credentials(State(ctx): State>) -> Json`. +fn ecs_creds_router(ctx: Arc, tasks: Arc) -> Router { + // Both routes hold a refcounted clone of the one shared context rather + // than a second copy of its string fields. + let task_creds = Router::new() + .route( + ECS_TASK_CREDS_PATH, + get( + |State(tasks): State>, + axum::extract::Path(task_id): axum::extract::Path| async move { + tasks.respond(&task_id) + }, + ), + ) + .with_state(tasks); + Router::new() + .route(ECS_CREDS_PATH, get(ecs_credentials)) + .with_state(ctx) + .merge(task_creds) +} + /// Bring up the link-local listeners. Runs to completion in a detached task /// spawned by `main`, so it never delays startup. `ctx` is the IMDS context; the /// IMDS IP serves an IMDS-only router built from it, the ECS IP the `/creds` /// surface built from the same context. -pub async fn run(ctx: Arc) { - // Both routers hold a refcounted clone of the one shared context rather than - // a second copy of its string fields. - let creds_router = Router::new() - .route(ECS_CREDS_PATH, get(ecs_credentials)) - .with_state(ctx.clone()); +pub async fn run(ctx: Arc, tasks: Arc) { + let creds_router = ecs_creds_router(ctx.clone(), tasks); // Bring both addresses up concurrently; the two binds share no state. tokio::join!( @@ -166,11 +190,22 @@ mod tests { role_arn: "arn:aws:iam::123456789012:role/fakecloud".to_string(), instance_id: "i-0123456789abcdef0".to_string(), }); - let router = Router::new() - .route(ECS_CREDS_PATH, get(ecs_credentials)) - .with_state(ctx); + let ecs: fakecloud_ecs::SharedEcsState = Arc::new(RwLock::new(MultiAccountState::new( + "123456789012", + "us-east-1", + "", + ))); + crate::ecs_creds::test_support::add_task( + &ecs, + "123456789012", + "task1", + Some("arn:aws:iam::123456789012:role/app"), + ); + let tasks = EcsTaskCredentials::new(ecs, ctx.iam.clone(), "123456789012"); + let router = ecs_creds_router(ctx, tasks); let resp = router + .clone() .oneshot( Request::builder() .uri(ECS_CREDS_PATH) @@ -196,5 +231,33 @@ mod tests { json["RoleArn"].as_str(), Some("arn:aws:iam::123456789012:role/fakecloud") ); + + // The agent's task relative URI serves that task's role session. + let get_json = |uri: &'static str| { + let router = router.clone(); + async move { + let resp = router + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + ( + status, + serde_json::from_slice::(&bytes).unwrap(), + ) + } + }; + let (status, task) = get_json("/v2/credentials/task1").await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + task["RoleArn"].as_str(), + Some("arn:aws:iam::123456789012:role/app") + ); + let (status, missing) = get_json("/v2/credentials/nope").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(missing["code"], "InvalidIdInRequest"); } } diff --git a/crates/fakecloud-server/src/main.rs b/crates/fakecloud-server/src/main.rs index f78c79364..0e672347a 100644 --- a/crates/fakecloud-server/src/main.rs +++ b/crates/fakecloud-server/src/main.rs @@ -18,6 +18,7 @@ mod appas_hooks; mod cli; mod dns; mod dynamodb_streams_lambda_poller; +mod ecs_creds; mod imds; mod introspection; mod kinesis_lambda_poller; @@ -116,14 +117,6 @@ const DNS_INTROSPECTION_TYPES: &[&str] = &[ "A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SPF", "CAA", "SRV", "SOA", ]; -/// The role the ECS task-credentials endpoint reports for a task started -/// without a `taskRoleArn`, in the partition of the task's own ARN. -fn ecs_default_task_role_arn(task_arn: &str, account_id: &str) -> String { - fakecloud_aws::arn::Arn::global("iam", account_id, "role/ecs-task-role") - .with_partition(fakecloud_aws::arn::partition_of(task_arn)) - .to_string() -} - /// Handler for `GET /_fakecloud/dns/resolve?name=&type=`. Returns what /// the DNS resolver would answer for the name+type straight from the Route 53 /// records, so a test can assert resolution without opening a socket. @@ -3516,6 +3509,7 @@ async fn main() { ecs_service = ecs_service.with_role_trust_validator( fakecloud_iam::pass_role::IamRoleTrustValidator::shared(iam_state.clone()), ); + ecs_service = ecs_service.with_iam_mode(cli.iam_mode()); if let Some(store) = ecs_snapshot_store { ecs_service = ecs_service.with_snapshot_store(store); } @@ -7359,6 +7353,16 @@ async fn main() { instance_id: cli.imds_instance_id(), }); let imds_router = imds::routes(imds_ctx.clone()); + // ECS task-role credentials: each running task's role session (named after + // the task), revoked once the task stops. + let ecs_task_credentials = ecs_creds::EcsTaskCredentials::new( + ecs_introspection_state.clone(), + iam_state.clone(), + cli.account_id.clone(), + ); + tokio::spawn(ecs_creds::run_revocation_sweep( + ecs_task_credentials.clone(), + )); let app = Router::new() .merge(imds_router) .route( @@ -10346,41 +10350,28 @@ async fn main() { }), ) .route( - // ECS task-role credential endpoint. Containers started by - // ECS RunTask with a `taskRoleArn` have - // `AWS_CONTAINER_CREDENTIALS_FULL_URI` pointing here; AWS - // SDKs following the default credential-provider chain - // fetch IMDS-format creds from this path. Returns synthetic - // short-lived credentials since fakecloud STS accepts any - // access-key/secret. + // ECS task-role credential endpoint. Containers of a task whose + // task definition has a `taskRoleArn` get + // `AWS_CONTAINER_CREDENTIALS_FULL_URI` pointing here; the AWS SDK + // default credential chain fetches the task role's session + // (named after the task) from it. See `ecs_creds`. "/_fakecloud/ecs/creds/{task_id}", axum::routing::get({ - let ec = ecs_introspection_state.clone(); + let creds = ecs_task_credentials.clone(); move |axum::extract::Path(task_id): axum::extract::Path| { - let ec = ec.clone(); - async move { - let accounts = ec.read(); - for (_, state) in accounts.iter() { - if let Some(t) = state.tasks.get(&task_id) { - let role_arn = t.task_role_arn.clone().unwrap_or_else(|| { - ecs_default_task_role_arn(&t.task_arn, &state.account_id) - }); - let expiry = chrono::Utc::now() + chrono::Duration::minutes(15); - let body = serde_json::json!({ - "AccessKeyId": format!("ASIA{}", "F".repeat(16)), - "SecretAccessKey": "fakecloud-ecs-task-role-secret", - "Token": "fakecloud-ecs-task-role-token", - "Expiration": expiry.format("%Y-%m-%dT%H:%M:%SZ").to_string(), - "RoleArn": role_arn, - }); - return (axum::http::StatusCode::OK, axum::Json(body)); - } - } - ( - axum::http::StatusCode::NOT_FOUND, - axum::Json(serde_json::json!({"error": "task not found"})), - ) - } + let creds = creds.clone(); + async move { creds.respond(&task_id) } + } + }), + ) + .route( + // No task ID: answered like the agent (`NoIdInRequest`). + "/_fakecloud/ecs/creds/", + axum::routing::get({ + let creds = ecs_task_credentials.clone(); + move || { + let creds = creds.clone(); + async move { creds.respond("") } } }), ) @@ -12144,7 +12135,7 @@ async fn main() { if cli.imds_link_local { // Detached so a hung alias/bind can never delay the main server; the // link-local IMDS IP serves an IMDS-only surface (not the whole app). - tokio::spawn(link_local::run(imds_ctx)); + tokio::spawn(link_local::run(imds_ctx, ecs_task_credentials)); } // Optional DNS resolver answering A/AAAA/CNAME/MX/TXT from the Route 53 // records created in fakecloud (names in no local zone forward upstream). @@ -12185,27 +12176,6 @@ async fn main() { } } -#[cfg(test)] -mod ecs_task_role_tests { - #[test] - fn default_task_role_follows_the_task_partition() { - assert_eq!( - super::ecs_default_task_role_arn( - "arn:aws-cn:ecs:cn-north-1:123456789012:task/c/abc", - "123456789012" - ), - "arn:aws-cn:iam::123456789012:role/ecs-task-role" - ); - assert_eq!( - super::ecs_default_task_role_arn( - "arn:aws:ecs:us-east-1:123456789012:task/c/abc", - "123456789012" - ), - "arn:aws:iam::123456789012:role/ecs-task-role" - ); - } -} - #[cfg(test)] mod dns_introspection_tests { use super::*; diff --git a/sdks/dotnet/src/FakeCloud/FakeCloudClient.cs b/sdks/dotnet/src/FakeCloud/FakeCloudClient.cs index 0f146e2cc..22d604735 100644 --- a/sdks/dotnet/src/FakeCloud/FakeCloudClient.cs +++ b/sdks/dotnet/src/FakeCloud/FakeCloudClient.cs @@ -865,9 +865,12 @@ public Task GetTaskMetadataAsync( "/_fakecloud/ecs/metadata/" + HttpTransport.EncodePath(taskArn), ct); /// - /// Return short-lived IAM credentials for a task. Matches the wire - /// shape ECS exposes via the task metadata credentials endpoint - /// (PascalCase keys). + /// Return the task-role credentials a running task's containers get + /// from AWS_CONTAINER_CREDENTIALS_FULL_URI: a session for the + /// task role named after the task ID, in the ECS credentials wire + /// shape (PascalCase keys). Throws once the task has stopped, or for + /// an unknown task or one without a task role (HTTP 400 + /// InvalidIdInRequest, as the ECS agent answers). /// public Task GetCredentialsAsync( string taskId, CancellationToken ct = default) => diff --git a/sdks/go/ecs.go b/sdks/go/ecs.go index 1e8cc4283..6b67c0457 100644 --- a/sdks/go/ecs.go +++ b/sdks/go/ecs.go @@ -103,9 +103,10 @@ func (c *ECSClient) GetTaskMetadata(ctx context.Context, taskArn string) (*EcsTa return &out, nil } -// GetTaskCredentials returns the IAM task-role credentials that the ECS -// agent's metadata server would hand out at the path advertised in -// AWS_CONTAINER_CREDENTIALS_RELATIVE_URI. +// GetTaskCredentials returns the task-role credentials fakecloud hands a +// running task's containers via AWS_CONTAINER_CREDENTIALS_FULL_URI (a +// session for the task role named after the task ID). It errors once the +// task has stopped, or for a task without a task role. func (c *ECSClient) GetTaskCredentials(ctx context.Context, taskID string) (*EcsTaskCredentials, error) { var out EcsTaskCredentials path := fmt.Sprintf("/_fakecloud/ecs/creds/%s", taskID) diff --git a/sdks/go/types.go b/sdks/go/types.go index cc6b35811..f91b558d4 100644 --- a/sdks/go/types.go +++ b/sdks/go/types.go @@ -1703,7 +1703,7 @@ type SnsSmsResponse struct { // ── ECS task IAM credentials ─────────────────────────────────────── // EcsTaskCredentials mirrors the JSON the ECS container credentials -// endpoint serves at AWS_CONTAINER_CREDENTIALS_RELATIVE_URI. +// endpoint serves at AWS_CONTAINER_CREDENTIALS_FULL_URI. type EcsTaskCredentials struct { AccessKeyID string `json:"AccessKeyId"` SecretAccessKey string `json:"SecretAccessKey"` diff --git a/sdks/java/src/main/java/dev/fakecloud/FakeCloud.java b/sdks/java/src/main/java/dev/fakecloud/FakeCloud.java index 8a3877732..20310c68d 100644 --- a/sdks/java/src/main/java/dev/fakecloud/FakeCloud.java +++ b/sdks/java/src/main/java/dev/fakecloud/FakeCloud.java @@ -1150,9 +1150,12 @@ public EcsTaskMetadataResponse getTaskMetadata(String taskArn) { } /** - * Return short-lived IAM credentials for a task. Matches the wire - * shape ECS exposes via the task metadata credentials endpoint - * (PascalCase keys). + * Return the task-role credentials a running task's containers get + * from {@code AWS_CONTAINER_CREDENTIALS_FULL_URI}: a session for the + * task role named after the task ID, in the ECS credentials wire + * shape (PascalCase keys). Throws once the task has stopped, or for + * an unknown task or one without a task role (HTTP 400 + * {@code InvalidIdInRequest}, as the ECS agent answers). */ public Types.EcsTaskCredentialsResponse getCredentials(String taskId) { return http.get( diff --git a/sdks/php/src/FakeCloud.php b/sdks/php/src/FakeCloud.php index 741e81ddb..133a9d0c6 100644 --- a/sdks/php/src/FakeCloud.php +++ b/sdks/php/src/FakeCloud.php @@ -1196,10 +1196,13 @@ public function getTaskMetadata(string $taskArn): EcsTaskMetadataResponse } /** - * Fetch the IAM credentials fakecloud minted for a task. Mirrors - * what `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` exposes inside a - * real ECS task container. Field casing is PascalCase on the wire; - * the returned object exposes camelCase properties. + * Fetch the task-role credentials a running task's containers get + * from `AWS_CONTAINER_CREDENTIALS_FULL_URI`: a session for the task + * role named after the task ID. Throws once the task has stopped, or + * for an unknown task or one without a task role (HTTP 400 + * `InvalidIdInRequest`, as the ECS agent answers). Field casing is + * PascalCase on the wire; the returned object exposes camelCase + * properties. */ public function getTaskCredentials(string $taskId): EcsTaskCredentialsResponse { diff --git a/sdks/python/src/fakecloud/client.py b/sdks/python/src/fakecloud/client.py index 2be33e429..907e928b2 100644 --- a/sdks/python/src/fakecloud/client.py +++ b/sdks/python/src/fakecloud/client.py @@ -437,8 +437,11 @@ async def get_task_metadata(self, task_arn: str) -> EcsTaskMetadataResponse: return EcsTaskMetadataResponse.from_dict(resp.json()) async def get_task_credentials(self, task_id: str) -> EcsTaskCredentials: - """Fetch the IAM credentials a running ECS task would see at - ``$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI``.""" + """Fetch the task-role credentials a running ECS task's containers + get from ``$AWS_CONTAINER_CREDENTIALS_FULL_URI``: a session for the + task role named after the task ID. Raises once the task has stopped, + or for an unknown task or one without a task role (HTTP 400 + ``InvalidIdInRequest``, as the ECS agent answers).""" resp = await self._client.get(f"{self._base}/_fakecloud/ecs/creds/{task_id}") _check(resp) return EcsTaskCredentials.from_dict(resp.json()) @@ -522,6 +525,11 @@ def get_task_metadata(self, task_arn: str) -> EcsTaskMetadataResponse: return EcsTaskMetadataResponse.from_dict(resp.json()) def get_task_credentials(self, task_id: str) -> EcsTaskCredentials: + """Fetch the task-role credentials a running ECS task's containers + get from ``$AWS_CONTAINER_CREDENTIALS_FULL_URI``: a session for the + task role named after the task ID. Raises once the task has stopped, + or for an unknown task or one without a task role (HTTP 400 + ``InvalidIdInRequest``, as the ECS agent answers).""" resp = self._client.get(f"{self._base}/_fakecloud/ecs/creds/{task_id}") _check(resp) return EcsTaskCredentials.from_dict(resp.json()) diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 382493437..4aa726273 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -1618,9 +1618,11 @@ export class EcsClient { } /** - * Return the IMDS-style temporary credentials fakecloud minted for an - * ECS task — the same payload a container reads from the credentials - * provider URL when running under Fargate. + * Return the task-role credentials a running ECS task's containers get + * from `AWS_CONTAINER_CREDENTIALS_FULL_URI`: a session for the task role + * named after the task ID. Throws once the task has stopped, or for an + * unknown task or one without a task role (HTTP 400 `InvalidIdInRequest`, + * as the ECS agent answers). */ async getTaskCredentials(taskId: string): Promise { const resp = await fetch( diff --git a/website/content/docs/guides/instance-credentials.md b/website/content/docs/guides/instance-credentials.md index f0f3d5a19..70e5cde1e 100644 --- a/website/content/docs/guides/instance-credentials.md +++ b/website/content/docs/guides/instance-credentials.md @@ -86,7 +86,7 @@ Other metadata paths served: `/latest/meta-data/instance-id`, `/latest/meta-data ### Advanced: apps that hardcode `169.254.169.254` -Some apps ignore `AWS_EC2_METADATA_SERVICE_ENDPOINT` and talk to the real IMDS IP `http://169.254.169.254` directly (and some ECS SDKs hardcode the `169.254.170.2` container-credentials base). Run fakecloud with `--imds-link-local` (or `FAKECLOUD_IMDS_LINK_LOCAL=1`) and it also binds those addresses on port 80: the full IMDS surface at `169.254.169.254`, and container credentials at `169.254.170.2/creds`. Set `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/creds` for apps that use the relative-URI base. +Some apps ignore `AWS_EC2_METADATA_SERVICE_ENDPOINT` and talk to the real IMDS IP `http://169.254.169.254` directly (and some ECS SDKs hardcode the `169.254.170.2` container-credentials base). Run fakecloud with `--imds-link-local` (or `FAKECLOUD_IMDS_LINK_LOCAL=1`) and it also binds those addresses on port 80: the full IMDS surface at `169.254.169.254`, and container credentials at `169.254.170.2/creds`. Set `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/creds` for apps that use the relative-URI base. `169.254.170.2` also serves an ECS task's role credentials at the ECS agent's relative URI, `/v2/credentials/`. This needs privileged host setup that fakecloud does **not** perform for you: binding port 80 needs **root**, and the link-local addresses must already be assigned to the loopback interface. Assign them first, then run fakecloud as root: diff --git a/website/content/docs/reference/configuration.md b/website/content/docs/reference/configuration.md index 2ebe0c5c7..c6fb21b9e 100644 --- a/website/content/docs/reference/configuration.md +++ b/website/content/docs/reference/configuration.md @@ -20,7 +20,7 @@ fakecloud is configured via CLI flags or environment variables. Flags take prece | `--dynamodb-import-dir` | `FAKECLOUD_DYNAMODB_IMPORT_DIR` | — | Bulk-load many AWS-format DynamoDB S3 exports as new tables at startup. Points at a root directory of per-table subdirectories, each self-contained with its own `describe-table.json`. Conflicts with `--dynamodb-import-path`/`--dynamodb-import-describe-table`. Same additive/`DYNAMODB_JSON`-only rules as `--dynamodb-import-path`; startup fails if any subdirectory is missing its `describe-table.json`. See [DynamoDB](/docs/services/dynamodb/#importing-an-aws-export-at-startup). | | `--credentials-role-arn` | `FAKECLOUD_CREDENTIALS_ROLE_ARN` | `arn:aws:iam:::role/fakecloud` | IAM role ARN the container/instance credential endpoint (`GET /_fakecloud/credentials`, consumed via `AWS_CONTAINER_CREDENTIALS_FULL_URI`) vends credentials for. Lets an app running under an instance/task role resolve the AWS SDK default credential chain locally with no code change. See [Run an app unmodified](/docs/guides/instance-credentials/). | | `--imds-instance-id` | `FAKECLOUD_IMDS_INSTANCE_ID` | synthetic `i-…` | Instance ID the EC2 instance metadata service (IMDS, `/latest/*`) reports (`meta-data/instance-id`, the instance identity document). IMDS is consumed by pointing an app's SDK at `AWS_EC2_METADATA_SERVICE_ENDPOINT=http://:/`. See [Run an app unmodified](/docs/guides/instance-credentials/#instance-metadata-imds). | -| `--imds-link-local` | `FAKECLOUD_IMDS_LINK_LOCAL` | `false` | Also bind the AWS link-local metadata addresses so apps that hardcode them resolve credentials unmodified: IMDS at `169.254.169.254:80` and ECS container credentials at `169.254.170.2:80/creds` (set `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/creds`). Requires running fakecloud as root (to bind port 80) with those addresses already assigned to the loopback interface; fakecloud binds them but never creates or deletes the alias, and logs the exact manual command if binding fails (the main server is unaffected). See [advanced: apps that hardcode 169.254.169.254](/docs/guides/instance-credentials/#advanced-apps-that-hardcode-169254169254). | +| `--imds-link-local` | `FAKECLOUD_IMDS_LINK_LOCAL` | `false` | Also bind the AWS link-local metadata addresses so apps that hardcode them resolve credentials unmodified: IMDS at `169.254.169.254:80` and ECS container credentials at `169.254.170.2:80/creds` (set `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/creds`), plus ECS task-role credentials at `169.254.170.2:80/v2/credentials/`. Requires running fakecloud as root (to bind port 80) with those addresses already assigned to the loopback interface; fakecloud binds them but never creates or deletes the alias, and logs the exact manual command if binding fails (the main server is unaffected). See [advanced: apps that hardcode 169.254.169.254](/docs/guides/instance-credentials/#advanced-apps-that-hardcode-169254169254). | | `--dns` | `FAKECLOUD_DNS` | `false` | Run a real DNS resolver that answers `A`/`AAAA`/`CNAME`/`MX`/`TXT`/`NS`/`PTR`/`SPF`/`CAA` from the Route 53 records created in fakecloud. Point a container's resolver at fakecloud (compose `dns:` / `/etc/resolv.conf`) and created records resolve to their local targets; names in no Route 53 zone forward upstream. See [DNS resolver](/docs/guides/dns/). | | `--dns-addr` | `FAKECLOUD_DNS_ADDR` | `0.0.0.0:53` | Address the DNS resolver binds (UDP + TCP). Binding port 53 needs root, so pass e.g. `127.0.0.1:15353` for an unprivileged run. Only used with `--dns`. | | `--dns-upstream` | `FAKECLOUD_DNS_UPSTREAM` | first non-loopback `nameserver` in `/etc/resolv.conf`, else `8.8.8.8:53` | Upstream resolver for names in no Route 53 zone, so a container can use fakecloud as its sole resolver and still reach the outside world. Only used with `--dns`. | diff --git a/website/content/docs/reference/introspection.md b/website/content/docs/reference/introspection.md index 05c88cc58..60ac96115 100644 --- a/website/content/docs/reference/introspection.md +++ b/website/content/docs/reference/introspection.md @@ -215,7 +215,7 @@ curl http://localhost:4566/_fakecloud/health | `/_fakecloud/ecs/tasks/{task_id}/force-stop` | POST | Force-stop a running task. | | `/_fakecloud/ecs/tasks/{task_id}/mark-failed` | POST | Mark a task as failed for testing failure handling. | | `/_fakecloud/ecs/events` | GET | List ECS service/task lifecycle events. | -| `/_fakecloud/ecs/creds/{task_id}` | GET | Task IAM credentials (used by the ECS Exec data plane). | +| `/_fakecloud/ecs/creds/{task_id}` | GET | Task-role credentials for a running task (the URL injected as `AWS_CONTAINER_CREDENTIALS_FULL_URI`): a session for the task's `taskRoleArn` named after the task ID, registered so it verifies under `--verify-sigv4`; revoked once the task stops. A stopped, unknown or role-less task gets the ECS agent's HTTP 400 `InvalidIdInRequest`. | | `/_fakecloud/ecs/v3/{task_id}` | GET | **NEW** -- ECS metadata v3 endpoint exposed to task containers. | | `/_fakecloud/ecs/v4/{task_id}` | GET | **NEW** -- ECS metadata v4 endpoint exposed to task containers. | | `/_fakecloud/ecs/metadata/{task_arn}` | GET | Aggregated v4 metadata dump keyed by full task ARN (URL-encoded). Same shape as `ECS_CONTAINER_METADATA_URI_V4`, addressable from tests holding a RunTask response. | diff --git a/website/content/docs/services/ecs.md b/website/content/docs/services/ecs.md index 36cd7692a..c1c19578a 100644 --- a/website/content/docs/services/ecs.md +++ b/website/content/docs/services/ecs.md @@ -117,11 +117,11 @@ Task state transitions fire `aws.ecs` / `ECS Task State Change` events on the de ### Task role credentials -Tasks registered with a `taskRoleArn` get `AWS_CONTAINER_CREDENTIALS_FULL_URI` injected into every container. The URL points at a fakecloud-local endpoint — `http://host.docker.internal:/_fakecloud/ecs/creds/` — that returns IMDS-format credentials: +Tasks whose task definition (or a `RunTask` `overrides.taskRoleArn`) names a `taskRoleArn` get `AWS_CONTAINER_CREDENTIALS_FULL_URI` injected into every container, pointing at `http://host.docker.internal:/_fakecloud/ecs/creds/` (the in-cluster fakecloud URL on the Kubernetes backend). Tasks without a task role get no credentials URI, as on AWS. The endpoint returns the container-credentials JSON the ECS agent serves: ```json { - "AccessKeyId": "ASIA...", + "AccessKeyId": "FSIA...", "SecretAccessKey": "...", "Token": "...", "Expiration": "2026-04-24T12:00:00Z", @@ -129,7 +129,11 @@ Tasks registered with a `taskRoleArn` get `AWS_CONTAINER_CREDENTIALS_FULL_URI` i } ``` -AWS SDKs pick this up via the default credential-provider chain, so `aws sts get-caller-identity` (and any other SDK call) from inside the container works out of the box. fakecloud's STS accepts any `AccessKeyId`, so no pre-registration of the role is needed. +The credentials are a real session for the task role, named after the task ID like on AWS: `GetCallerIdentity` returns `arn:aws:sts:::assumed-role//`. They are minted and registered like an `AssumeRole` session, so they verify under `--verify-sigv4` and, under `--iam`, are evaluated against the role's policies. Refetches return the same set until it nears expiry, then a fresh one. When the task stops its credentials are revoked, and the endpoint answers a stopped, unknown or role-less task the way the ECS agent does: HTTP 400 with `{"code":"InvalidIdInRequest","message":"CredentialsV2Request: Credentials not found","HTTPErrorCode":400}`. + +The AWS SDKs' container-credentials providers only accept a plain-HTTP `AWS_CONTAINER_CREDENTIALS_FULL_URI` whose host is a loopback address or an ECS link-local address (`169.254.170.2`), so an SDK inside the container refuses the `host.docker.internal` URI; code that fetches the URI directly (or a test driving the endpoint from the host) gets the credentials. With `--imds-link-local`, the same credentials are also served at the agent's own relative URI, `http://169.254.170.2/v2/credentials/`. + +`RegisterTaskDefinition` (and `RunTask` role overrides, and CloudFormation `AWS::ECS::TaskDefinition`) refuses a `taskRoleArn` or `executionRoleArn` whose trust policy does not let `ecs-tasks.amazonaws.com` assume it with ECS's `ClientException` ("ECS was unable to assume the role '...' that was provided for this task. ..."). Under `--iam strict` a role from another account is refused the same way; `--iam soft` logs it to the IAM audit target and allows it. ### Volumes + mount points