From 58431a470d0aa6ccaedc45bdacceb50a72e1f0c3 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 12:55:05 -0300 Subject: [PATCH 1/5] fix(ecs): vend task-role sessions from the ECS credentials endpoint GET /_fakecloud/ecs/creds/{task_id} returned static placeholder credentials. It now returns a session for the task's taskRoleArn, named after the task ID (assumed-role//), minted and registered like an AssumeRole session so it verifies under --verify-sigv4 and is evaluated as the role under --iam. Sets are cached per task until near expiry, then rotated, and revoked once the task stops. Stopped, unknown and role-less tasks get the ECS agent's HTTP 400 InvalidIdInRequest. RunTask overrides.taskRoleArn/executionRoleArn now replace the task definition's roles for the task. RegisterTaskDefinition, RunTask role overrides and CloudFormation AWS::ECS::TaskDefinition share validate_task_role: the trust policy must name ecs-tasks.amazonaws.com, and under --iam strict another account's role is refused, both with ECS's ClientException message. The --imds-link-local listener on 169.254.170.2 also serves task credentials at the agent's /v2/credentials/. Closes #2601 --- AGENTS.md | 3 +- .../src/resource_provisioner/ecs.rs | 18 + .../src/resource_provisioner/mod.rs | 45 ++ .../tests/ecs_task_role_credentials.rs | 466 ++++++++++++++++++ .../tests/ecs_task_role_secrets.rs | 38 +- crates/fakecloud-e2e/tests/iam_pass_role.rs | 12 +- crates/fakecloud-ecs/src/lib.rs | 4 +- crates/fakecloud-ecs/src/service.rs | 82 ++- crates/fakecloud-ecs/src/service_tasks.rs | 60 ++- crates/fakecloud-ecs/src/service_tests.rs | 98 ++++ .../src/sts_service/container_creds.rs | 242 +++++++-- crates/fakecloud-sdk/src/client.rs | 6 +- crates/fakecloud-server/src/cli.rs | 3 +- crates/fakecloud-server/src/ecs_creds.rs | 359 ++++++++++++++ crates/fakecloud-server/src/link_local.rs | 85 +++- crates/fakecloud-server/src/main.rs | 94 ++-- sdks/go/ecs.go | 7 +- .../docs/guides/instance-credentials.md | 2 +- .../content/docs/reference/configuration.md | 2 +- .../content/docs/reference/introspection.md | 2 +- website/content/docs/services/ecs.md | 10 +- 21 files changed, 1475 insertions(+), 163 deletions(-) create mode 100644 crates/fakecloud-e2e/tests/ecs_task_role_credentials.rs create mode 100644 crates/fakecloud-server/src/ecs_creds.rs 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..e43819e86 100644 --- a/crates/fakecloud-ecs/src/service.rs +++ b/crates/fakecloud-ecs/src/service.rs @@ -102,6 +102,64 @@ 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 caller's account. 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 { + validator + .validate(caller_account, role_arn, "ecs-tasks.amazonaws.com") + .map_err(|_| helpers::client_exception(unable_to_assume_role_message(role_arn)))?; + } + Ok(()) } impl EcsService { @@ -112,6 +170,7 @@ impl EcsService { snapshot_lock: Arc::new(AsyncMutex::new(())), runtime: None, role_trust_validator: None, + iam_mode: fakecloud_core::auth::IamMode::Off, } } @@ -133,18 +192,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..689dd1f0c 100644 --- a/crates/fakecloud-ecs/src/service_tests.rs +++ b/crates/fakecloud-ecs/src/service_tests.rs @@ -779,3 +779,101 @@ 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)); + } +} diff --git a/crates/fakecloud-iam/src/sts_service/container_creds.rs b/crates/fakecloud-iam/src/sts_service/container_creds.rs index 17b925125..1e048f2c9 100644 --- a/crates/fakecloud-iam/src/sts_service/container_creds.rs +++ b/crates/fakecloud-iam/src/sts_service/container_creds.rs @@ -359,45 +359,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 +779,71 @@ 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()); + } } 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..67aa4184f --- /dev/null +++ b/crates/fakecloud-server/src/ecs_creds.rs @@ -0,0 +1,359 @@ +//! 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::{ + 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, +} + +/// A task that may hold credentials: its account, and its role while it runs. +struct RunningTask { + account_id: String, + role_arn: Option, +} + +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 { + Arc::new(Self { + ecs, + iam, + default_account_id: default_account_id.into(), + cache: WorkloadCredentialCache::new(), + }) + } + + /// The not-yet-stopped task `task_id`, in whichever account runs it. + fn running_task(&self, task_id: &str) -> Option { + let accounts = self.ecs.read(); + let found = accounts.iter().find_map(|(account_id, state)| { + let task = state.tasks.get(task_id)?; + (task.last_status != "STOPPED").then(|| RunningTask { + account_id: account_id.to_string(), + role_arn: task.task_role_arn.clone(), + }) + }); + found + } + + /// Credentials for the task role of the running task `task_id`. + pub fn credentials(&self, task_id: &str) -> Result { + if task_id.is_empty() { + return Err(CredentialsError::NoId); + } + let task = self + .running_task(task_id) + .ok_or(CredentialsError::NotFound)?; + let role_arn = task.role_arn.ok_or(CredentialsError::NotFound)?; + Ok(self.cache.get_or_mint( + &self.iam, + &self.default_account_id, + &cache_key(&task.account_id, task_id), + &role_arn, + task_id, + DEFAULT_CONTAINER_CREDENTIALS_DURATION, + )) + } + + /// Revoke the credentials of every task that has stopped (or is gone). + pub fn revoke_stopped(&self) { + let running: HashSet = { + let accounts = self.ecs.read(); + 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)); + } + + /// 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)); + } +} 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/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/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 From 8007819efb9828e1b814fabbd0ecf18f43f04e6a Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 13:00:38 -0300 Subject: [PATCH 2/5] fix(ecs): address review findings on task-role credentials - Hold the ECS read lock through the revocation sweep so a task started mid-sweep is not revoked against a stale snapshot. - Check another account's task role against its own trust policy. - Document the endpoint's new behavior in every SDK. --- crates/fakecloud-ecs/src/service.rs | 6 ++- crates/fakecloud-ecs/src/service_tests.rs | 46 +++++++++++++++++++ crates/fakecloud-server/src/ecs_creds.rs | 31 +++++++------ sdks/dotnet/src/FakeCloud/FakeCloudClient.cs | 9 ++-- sdks/go/types.go | 2 +- .../main/java/dev/fakecloud/FakeCloud.java | 9 ++-- sdks/php/src/FakeCloud.php | 11 +++-- sdks/python/src/fakecloud/client.py | 12 ++++- sdks/typescript/src/client.ts | 8 ++-- 9 files changed, 103 insertions(+), 31 deletions(-) diff --git a/crates/fakecloud-ecs/src/service.rs b/crates/fakecloud-ecs/src/service.rs index e43819e86..d00f31106 100644 --- a/crates/fakecloud-ecs/src/service.rs +++ b/crates/fakecloud-ecs/src/service.rs @@ -128,7 +128,8 @@ pub fn unable_to_assume_role_message(role_arn: &str) -> String { /// 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 caller's account. Always applied. +/// 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( @@ -155,8 +156,9 @@ pub fn validate_task_role( } } if let Some(validator) = validator { + let role_account = fakecloud_aws::arn::account_of(role_arn).unwrap_or(caller_account); validator - .validate(caller_account, role_arn, "ecs-tasks.amazonaws.com") + .validate(role_account, role_arn, "ecs-tasks.amazonaws.com") .map_err(|_| helpers::client_exception(unable_to_assume_role_message(role_arn)))?; } Ok(()) diff --git a/crates/fakecloud-ecs/src/service_tests.rs b/crates/fakecloud-ecs/src/service_tests.rs index 689dd1f0c..312d4271d 100644 --- a/crates/fakecloud-ecs/src/service_tests.rs +++ b/crates/fakecloud-ecs/src/service_tests.rs @@ -877,3 +877,49 @@ fn register_task_definition_checks_both_roles() { 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-server/src/ecs_creds.rs b/crates/fakecloud-server/src/ecs_creds.rs index 67aa4184f..918db84da 100644 --- a/crates/fakecloud-server/src/ecs_creds.rs +++ b/crates/fakecloud-server/src/ecs_creds.rs @@ -125,22 +125,27 @@ impl EcsTaskCredentials { } /// 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: ECS state, then the + /// cache, then IAM; `credentials` releases the ECS lock before touching + /// the cache, so the two never wait on each other in reverse. pub fn revoke_stopped(&self) { - let running: HashSet = { - let accounts = self.ecs.read(); - 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() - }; + 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`. 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/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( From ec55f7bf493d26ee653341aa938531906e1b9292 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 13:06:28 -0300 Subject: [PATCH 3/5] fix(ecs): revoke task sessions a restart left registered With persistence on, IAM state keeps the sessions handed to tasks while the per-task cache that revokes them starts empty, and the restart stops every restored task. Revoke the sessions of stopped tasks on startup. --- .../src/sts_service/container_creds.rs | 82 +++++++++++++++++-- crates/fakecloud-server/src/ecs_creds.rs | 46 ++++++++++- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/crates/fakecloud-iam/src/sts_service/container_creds.rs b/crates/fakecloud-iam/src/sts_service/container_creds.rs index 1e048f2c9..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. @@ -846,4 +890,32 @@ mod tests { 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-server/src/ecs_creds.rs b/crates/fakecloud-server/src/ecs_creds.rs index 918db84da..2a52bfc1c 100644 --- a/crates/fakecloud-server/src/ecs_creds.rs +++ b/crates/fakecloud-server/src/ecs_creds.rs @@ -22,7 +22,8 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use fakecloud_ecs::SharedEcsState; use fakecloud_iam::sts_service::container_creds::{ - ContainerCredentials, WorkloadCredentialCache, DEFAULT_CONTAINER_CREDENTIALS_DURATION, + revoke_sessions_named, ContainerCredentials, WorkloadCredentialCache, + DEFAULT_CONTAINER_CREDENTIALS_DURATION, }; use fakecloud_iam::SharedIamState; @@ -84,12 +85,33 @@ impl EcsTaskCredentials { iam: SharedIamState, default_account_id: impl Into, ) -> Arc { - Arc::new(Self { + 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(|(_, state)| state.tasks.iter()) + .filter(|(_, t)| t.last_status == "STOPPED") + .filter_map(|(id, t)| Some((t.task_role_arn.clone()?, id.clone()))) + .collect() + }; + for (role_arn, task_id) in stopped { + revoke_sessions_named(&self.iam, &self.default_account_id, &role_arn, &task_id); + } } /// The not-yet-stopped task `task_id`, in whichever account runs it. @@ -361,4 +383,22 @@ mod tests { ); 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)); + } } From e1a72a10ebd7e1d8edb19f83474c10a8e1718110 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 13:13:11 -0300 Subject: [PATCH 4/5] fix(ecs): hold the task lookup through the credential mint A task stopping (and being swept) between the running check and the mint could still be handed a fresh session until the next sweep. --- crates/fakecloud-server/src/ecs_creds.rs | 49 ++++++++++-------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/crates/fakecloud-server/src/ecs_creds.rs b/crates/fakecloud-server/src/ecs_creds.rs index 2a52bfc1c..43f1659ce 100644 --- a/crates/fakecloud-server/src/ecs_creds.rs +++ b/crates/fakecloud-server/src/ecs_creds.rs @@ -69,12 +69,6 @@ pub struct EcsTaskCredentials { cache: WorkloadCredentialCache, } -/// A task that may hold credentials: its account, and its role while it runs. -struct RunningTask { - account_id: String, - role_arn: Option, -} - fn cache_key(account_id: &str, task_id: &str) -> String { format!("{account_id}/{task_id}") } @@ -114,45 +108,42 @@ impl EcsTaskCredentials { } } - /// The not-yet-stopped task `task_id`, in whichever account runs it. - fn running_task(&self, task_id: &str) -> Option { - let accounts = self.ecs.read(); - let found = accounts.iter().find_map(|(account_id, state)| { - let task = state.tasks.get(task_id)?; - (task.last_status != "STOPPED").then(|| RunningTask { - account_id: account_id.to_string(), - role_arn: task.task_role_arn.clone(), - }) - }); - found - } - /// 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 task = self - .running_task(task_id) + 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.task_role_arn.as_deref()?))) .ok_or(CredentialsError::NotFound)?; - let role_arn = task.role_arn.ok_or(CredentialsError::NotFound)?; - Ok(self.cache.get_or_mint( + let creds = self.cache.get_or_mint( &self.iam, &self.default_account_id, - &cache_key(&task.account_id, task_id), - &role_arn, + &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: ECS state, then the - /// cache, then IAM; `credentials` releases the ECS lock before touching - /// the cache, so the two never wait on each other in reverse. + /// 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 From 749baf3cc41fc723fa86400b5f871a84d8f59715 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 13:17:10 -0300 Subject: [PATCH 5/5] fix(ecs): resolve a short task role name in the task's account ECS accepts a bare role name for taskRoleArn; its session was minted (and looked for after a restart) in the server's default account rather than the task's. Resolve it to the full role ARN in the task's account and partition, which is also what the endpoint now reports as RoleArn. --- crates/fakecloud-server/src/ecs_creds.rs | 50 +++++++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/crates/fakecloud-server/src/ecs_creds.rs b/crates/fakecloud-server/src/ecs_creds.rs index 43f1659ce..e9e2eb941 100644 --- a/crates/fakecloud-server/src/ecs_creds.rs +++ b/crates/fakecloud-server/src/ecs_creds.rs @@ -69,6 +69,20 @@ pub struct EcsTaskCredentials { 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}") } @@ -98,9 +112,15 @@ impl EcsTaskCredentials { let accounts = self.ecs.read(); accounts .iter() - .flat_map(|(_, state)| state.tasks.iter()) - .filter(|(_, t)| t.last_status == "STOPPED") - .filter_map(|(id, t)| Some((t.task_role_arn.clone()?, id.clone()))) + .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 { @@ -124,13 +144,13 @@ impl EcsTaskCredentials { 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.task_role_arn.as_deref()?))) + .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, - &self.default_account_id, + account_id, &cache_key(account_id, task_id), - role_arn, + &role_arn, task_id, DEFAULT_CONTAINER_CREDENTIALS_DURATION, ); @@ -392,4 +412,22 @@ mod tests { ); 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)); + } }