From 78e76c31f1e566cbde9e679060fcd53e11f2383f Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Thu, 24 Sep 2026 17:19:29 -0300 Subject: [PATCH] fix(iam): build user and policy ARNs from the request's account CreateUser and CreatePolicy took the account for their ARN from a pre-multi-account helper that only knew STS session keys and otherwise fell back to the server's default account. Credentials issued by /_fakecloud/iam/create-admin are not session keys, so users and customer-managed policies created in a non-default account carried the default account id and the policy only resolved by that wrong ARN. Use the partitioned state's account, as CreateRole and CreateGroup already do, and drop the now-unused helper. Fixes #2552 --- crates/fakecloud-e2e/tests/multi_account.rs | 133 ++++++++++++++++++ .../fakecloud-iam/src/iam_service/helpers.rs | 7 - .../fakecloud-iam/src/iam_service/policies.rs | 3 +- crates/fakecloud-iam/src/iam_service/users.rs | 19 +-- 4 files changed, 136 insertions(+), 26 deletions(-) diff --git a/crates/fakecloud-e2e/tests/multi_account.rs b/crates/fakecloud-e2e/tests/multi_account.rs index 0797eef81..c83a23239 100644 --- a/crates/fakecloud-e2e/tests/multi_account.rs +++ b/crates/fakecloud-e2e/tests/multi_account.rs @@ -102,6 +102,139 @@ async fn sts_assume_role_routes_to_target_account() { .contains(ACCOUNT_B)); } +// ====================================================================== +// IAM: every entity ARN carries the account it was created in +// ====================================================================== + +const LIST_BUCKET_POLICY: &str = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}"#; + +/// Creates a user, a customer-managed policy, a role, and a group through +/// `iam` and asserts each ARN names `account`, and that the policy and user +/// resolve by those ARNs. +async fn assert_iam_entities_in_account(iam: &aws_sdk_iam::Client, account: &str) { + let user = iam.create_user().user_name("alice").send().await.unwrap(); + let user_arn = user.user().unwrap().arn(); + assert_eq!(user_arn, format!("arn:aws:iam::{account}:user/alice")); + + let policy = iam + .create_policy() + .policy_name("list") + .policy_document(LIST_BUCKET_POLICY) + .send() + .await + .unwrap(); + let policy_arn = policy.policy().unwrap().arn().unwrap().to_string(); + assert_eq!(policy_arn, format!("arn:aws:iam::{account}:policy/list")); + + let role = iam + .create_role() + .role_name("svc") + .assume_role_policy_document(r#"{"Version":"2012-10-17","Statement":[]}"#) + .send() + .await + .unwrap(); + assert_eq!( + role.role().unwrap().arn(), + format!("arn:aws:iam::{account}:role/svc") + ); + + let group = iam.create_group().group_name("ops").send().await.unwrap(); + assert_eq!( + group.group().unwrap().arn(), + format!("arn:aws:iam::{account}:group/ops") + ); + + // The policy resolves by the ARN CreatePolicy returned, and attaching it + // by that ARN reaches the user. + let fetched = iam + .get_policy() + .policy_arn(&policy_arn) + .send() + .await + .unwrap(); + assert_eq!(fetched.policy().unwrap().arn(), Some(policy_arn.as_str())); + iam.attach_user_policy() + .user_name("alice") + .policy_arn(&policy_arn) + .send() + .await + .unwrap(); + let attached = iam + .list_attached_user_policies() + .user_name("alice") + .send() + .await + .unwrap(); + assert_eq!( + attached.attached_policies()[0].policy_arn(), + Some(policy_arn.as_str()) + ); + + let fetched_user = iam.get_user().user_name("alice").send().await.unwrap(); + assert_eq!(fetched_user.user().unwrap().arn(), user_arn); +} + +#[tokio::test] +async fn iam_entity_arns_use_non_default_account() { + let server = start().await; + let (b_akid, b_secret) = server.create_admin(ACCOUNT_B, "admin-b").await; + let b_cfg = config_with(&server, &b_akid, &b_secret).await; + let iam_b = aws_sdk_iam::Client::new(&b_cfg); + + assert_iam_entities_in_account(&iam_b, ACCOUNT_B).await; +} + +#[tokio::test] +async fn iam_entity_arns_use_assumed_role_account() { + let server = start().await; + let (a_akid, a_secret) = server.create_admin(ACCOUNT_A, "admin-a").await; + let (b_akid, b_secret) = server.create_admin(ACCOUNT_B, "admin-b").await; + + // An admin role in account B that account A may assume. + let b_cfg = config_with(&server, &b_akid, &b_secret).await; + let iam_b = aws_sdk_iam::Client::new(&b_cfg); + iam_b + .create_role() + .role_name("admin-role") + .assume_role_policy_document( + r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"sts:AssumeRole"}]}"#, + ) + .send() + .await + .unwrap(); + iam_b + .attach_role_policy() + .role_name("admin-role") + .policy_arn("arn:aws:iam::aws:policy/AdministratorAccess") + .send() + .await + .unwrap(); + + let a_cfg = config_with(&server, &a_akid, &a_secret).await; + let assumed = StsClient::new(&a_cfg) + .assume_role() + .role_arn(format!("arn:aws:iam::{ACCOUNT_B}:role/admin-role")) + .role_session_name("cross") + .send() + .await + .unwrap(); + let creds = assumed.credentials().unwrap(); + let session_cfg = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .endpoint_url(server.endpoint()) + .region(aws_config::Region::new("us-east-1")) + .credentials_provider(Credentials::new( + creds.access_key_id(), + creds.secret_access_key(), + Some(creds.session_token().to_string()), + None, + "multi-acct-session", + )) + .load() + .await; + + assert_iam_entities_in_account(&aws_sdk_iam::Client::new(&session_cfg), ACCOUNT_B).await; +} + // ====================================================================== // SQS: queues isolated per account // ====================================================================== diff --git a/crates/fakecloud-iam/src/iam_service/helpers.rs b/crates/fakecloud-iam/src/iam_service/helpers.rs index 2e13ded94..50062afe7 100644 --- a/crates/fakecloud-iam/src/iam_service/helpers.rs +++ b/crates/fakecloud-iam/src/iam_service/helpers.rs @@ -481,13 +481,6 @@ pub(crate) fn iam_action_resource( } } -/// Extract the caller's access key from the request's Authorization header. -pub(crate) fn extract_access_key(req: &AwsRequest) -> Option { - let auth = req.headers.get("authorization")?.to_str().ok()?; - let info = fakecloud_aws::sigv4::parse_sigv4(auth)?; - Some(info.access_key) -} - /// Convert a hyphenated service name to title case, handling known abbreviations. pub(crate) fn title_case_service(s: &str) -> String { s.split('-') diff --git a/crates/fakecloud-iam/src/iam_service/policies.rs b/crates/fakecloud-iam/src/iam_service/policies.rs index 222013200..e5a5050d4 100644 --- a/crates/fakecloud-iam/src/iam_service/policies.rs +++ b/crates/fakecloud-iam/src/iam_service/policies.rs @@ -42,14 +42,13 @@ impl IamService { let input = CreatePolicyInput::from_query(&req.query_params)?; let partition = partition_for_region(&req.region); - let effective_account = self.effective_account_id(req); let mut accounts = self.state.write(); let state = accounts.get_or_create(&req.account_id); let arn = format!( "arn:{}:iam::{}:policy{}{}", - partition, effective_account, input.path, input.policy_name + partition, state.account_id, input.path, input.policy_name ); if state.policies.contains_key(&arn) { diff --git a/crates/fakecloud-iam/src/iam_service/users.rs b/crates/fakecloud-iam/src/iam_service/users.rs index a402ffd36..1910298d8 100644 --- a/crates/fakecloud-iam/src/iam_service/users.rs +++ b/crates/fakecloud-iam/src/iam_service/users.rs @@ -9,7 +9,7 @@ use crate::state::{IamAccessKey, IamState, IamUser, SigningCertificate, SshPubli use crate::xml_responses; use super::{ - empty_response, extract_access_key, generate_id, generate_long_id, parse_tag_keys, parse_tags, + empty_response, generate_id, generate_long_id, parse_tag_keys, parse_tags, partition_for_region, required_param_with_code, resolve_calling_user, tags_xml, url_encode, validate_optional_string_length_with_code, validate_string_length_with_code, validate_tags, validate_untag_keys, IamService, @@ -185,20 +185,6 @@ fn rename_user_references(state: &mut IamState, old_name: &str, new_name: &str) // ========= User operations ========= impl IamService { - /// Determine the effective account ID for this request. - /// If the caller has assumed a role into a different account, use that account ID. - /// MUST be called before acquiring a write lock on self.state. - pub(super) fn effective_account_id(&self, req: &AwsRequest) -> String { - if let Some(access_key) = extract_access_key(req) { - let mut accounts = self.state.write(); - let state = accounts.get_or_create(&req.account_id); - if let Some(identity) = state.credential_identities.get(&access_key) { - return identity.account_id.clone(); - } - } - self.state.read().default_account_id().to_string() - } - pub(super) fn create_user(&self, req: &AwsRequest) -> Result { let user_name = required_param(&req.query_params, "UserName")?; validate_string_length("userName", &user_name, 1, 64)?; @@ -212,7 +198,6 @@ impl IamService { let permissions_boundary = req.query_params.get("PermissionsBoundary").cloned(); let partition = partition_for_region(&req.region); - let effective_account = self.effective_account_id(req); let mut accounts = self.state.write(); let state = accounts.get_or_create(&req.account_id); @@ -229,7 +214,7 @@ impl IamService { arn: format!( "arn:{}:iam::{}:user{}{}", partition, - effective_account, + state.account_id, if path == "/" { "/" } else { &path }, user_name ),