diff --git a/crates/fakecloud-iam/src/evaluator.rs b/crates/fakecloud-iam/src/evaluator.rs index 32047033e..0c2a25e27 100644 --- a/crates/fakecloud-iam/src/evaluator.rs +++ b/crates/fakecloud-iam/src/evaluator.rs @@ -384,8 +384,12 @@ fn classify_aws_principal(s: &str) -> PrincipalRef { if s == "*" { return PrincipalRef::AnyAws; } - // `arn:aws:iam:::root` → account root - if let Some(rest) = s.strip_prefix("arn:aws:iam::") { + // `arn::iam:::root` → account root + if let Some(rest) = s + .strip_prefix("arn:") + .and_then(|r| r.split_once(':')) + .and_then(|(_partition, r)| r.strip_prefix("iam::")) + { if let Some((account, tail)) = rest.split_once(':') { if tail == "root" && !account.is_empty() { return PrincipalRef::AwsAccountRoot(account.to_string()); diff --git a/crates/fakecloud-iam/src/evaluator_tests.rs b/crates/fakecloud-iam/src/evaluator_tests.rs index 9489afe73..a6bef07ec 100644 --- a/crates/fakecloud-iam/src/evaluator_tests.rs +++ b/crates/fakecloud-iam/src/evaluator_tests.rs @@ -1459,6 +1459,21 @@ fn classify_aws_principal_recognizes_root_arn() { ); } +#[test] +fn classify_aws_principal_recognizes_root_arn_in_any_partition() { + for partition in ["aws-cn", "aws-us-gov", "aws-iso"] { + assert_eq!( + classify_aws_principal(&format!("arn:{partition}:iam::111111111111:root")), + PrincipalRef::AwsAccountRoot("111111111111".to_string()) + ); + } + // A non-IAM ARN ending in `:root` is not an account root. + assert_eq!( + classify_aws_principal("arn:aws-cn:sts::111111111111:root"), + PrincipalRef::AwsArn("arn:aws-cn:sts::111111111111:root".to_string()) + ); +} + #[test] fn classify_aws_principal_keeps_user_arn_as_arn() { assert_eq!( diff --git a/crates/fakecloud-iam/src/iam_service/account.rs b/crates/fakecloud-iam/src/iam_service/account.rs index f373e57fe..1997738ec 100644 --- a/crates/fakecloud-iam/src/iam_service/account.rs +++ b/crates/fakecloud-iam/src/iam_service/account.rs @@ -932,7 +932,8 @@ impl IamService { // Root account row (after users) csv.push_str(&format!( - ",arn:aws:iam::{}:root,{},not_supported,not_supported,not_supported,not_supported,false,false,N/A,N/A,N/A,N/A,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A\n", + ",arn:{}:iam::{}:root,{},not_supported,not_supported,not_supported,not_supported,false,false,N/A,N/A,N/A,N/A,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A\n", + fakecloud_aws::arn::partition_for(&req.region), state.account_id, Utc::now().format("%Y-%m-%dT%H:%M:%S+00:00") )); diff --git a/crates/fakecloud-iam/src/iam_service/extras.rs b/crates/fakecloud-iam/src/iam_service/extras.rs index 95aaa70dc..4918aaebd 100644 --- a/crates/fakecloud-iam/src/iam_service/extras.rs +++ b/crates/fakecloud-iam/src/iam_service/extras.rs @@ -15,8 +15,8 @@ use crate::state::{ }; use super::{ - empty_response, parse_tags, required_param_with_code, resolve_calling_user, tags_xml, - validate_string_length_with_code, IamService, + empty_response, existing_arn_partition, parse_tags, required_param_with_code, + resolve_calling_user, tags_xml, validate_string_length_with_code, IamService, }; use fakecloud_core::query::required_param; @@ -1093,9 +1093,11 @@ impl IamService { .map(|(_, doc)| doc.clone()) .collect(); - let principal_arn_str = caller_arn - .clone() - .unwrap_or_else(|| Arn::global("iam", &req.account_id, "root").to_string()); + let principal_arn_str = caller_arn.clone().unwrap_or_else(|| { + Arn::global("iam", &req.account_id, "root") + .with_partition(fakecloud_aws::arn::partition_for(&req.region)) + .to_string() + }); let principal = Principal { arn: principal_arn_str.clone(), user_id: principal_arn_str.clone(), @@ -1288,7 +1290,9 @@ impl IamService { // payload (OldPassword != NewPassword) above. let user_name = req.principal.as_ref().and_then(|p| { let arn = &p.arn; - arn.strip_prefix("arn:aws:iam::") + arn.strip_prefix("arn:") + .and_then(|rest| rest.split_once(':')) + .and_then(|(_partition, rest)| rest.strip_prefix("iam::")) .and_then(|rest| rest.split_once(":user/")) .map(|(_, name)| name.to_string()) }); @@ -1506,7 +1510,8 @@ impl IamService { updated.server_certificate_name = final_name.clone(); // Rebuild ARN with the new path/name so metadata responses reflect the rename. updated.arn = format!( - "arn:aws:iam::{account}:server-certificate{path}{name}", + "arn:{partition}:iam::{account}:server-certificate{path}{name}", + partition = existing_arn_partition(&updated.arn, &req.region), account = req.account_id, path = if updated.path.starts_with('/') { updated.path.clone() diff --git a/crates/fakecloud-iam/src/iam_service/groups.rs b/crates/fakecloud-iam/src/iam_service/groups.rs index b44ba3247..402966bb2 100644 --- a/crates/fakecloud-iam/src/iam_service/groups.rs +++ b/crates/fakecloud-iam/src/iam_service/groups.rs @@ -6,7 +6,10 @@ use fakecloud_core::validation::*; use crate::state::IamGroup; -use super::{empty_response, generate_id, url_encode, validate_list_pagination, IamService}; +use super::{ + empty_response, existing_arn_partition, generate_id, url_encode, validate_list_pagination, + IamService, +}; use fakecloud_core::query::required_param; use fakecloud_aws::xml::xml_escape; @@ -37,7 +40,8 @@ impl IamService { let group = IamGroup { group_id: format!("AGPA{}", generate_id()), arn: format!( - "arn:aws:iam::{}:group{}{}", + "arn:{}:iam::{}:group{}{}", + fakecloud_aws::arn::partition_for(&req.region), state.account_id, if path == "/" { "/" } else { &path }, group_name @@ -357,7 +361,8 @@ impl IamService { let actual_new_name = new_group_name.unwrap_or_else(|| group_name.clone()); group.group_name = actual_new_name.clone(); group.arn = format!( - "arn:aws:iam::{}:group{}{}", + "arn:{}:iam::{}:group{}{}", + existing_arn_partition(&group.arn, &req.region), state.account_id, if group.path == "/" { "/" } else { &group.path }, actual_new_name diff --git a/crates/fakecloud-iam/src/iam_service/helpers.rs b/crates/fakecloud-iam/src/iam_service/helpers.rs index 50062afe7..b0573c6ec 100644 --- a/crates/fakecloud-iam/src/iam_service/helpers.rs +++ b/crates/fakecloud-iam/src/iam_service/helpers.rs @@ -7,6 +7,18 @@ pub(crate) fn partition_for_region(region: &str) -> &str { fakecloud_aws::arn::partition_for(region) } +/// Partition of an entity's existing ARN, falling back to the request +/// region's. A rename rebuilds the ARN, and IAM being global means the +/// renaming request can come from any region; taking the partition from the +/// stored ARN keeps a cn-/us-gov-/iso- entity in its partition. +pub(crate) fn existing_arn_partition(arn: &str, region: &str) -> String { + arn.split(':') + .nth(1) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| partition_for_region(region)) + .to_string() +} + /// Actions on the IAM service that mutate state. Kept in sync with the /// dispatch table in `handle`. pub(crate) fn is_mutating_action(action: &str) -> bool { diff --git a/crates/fakecloud-iam/src/iam_service/oidc.rs b/crates/fakecloud-iam/src/iam_service/oidc.rs index faa4ecb57..edb89d4a7 100644 --- a/crates/fakecloud-iam/src/iam_service/oidc.rs +++ b/crates/fakecloud-iam/src/iam_service/oidc.rs @@ -139,8 +139,9 @@ impl IamService { let mut accounts = self.state.write(); let state = accounts.get_or_create(&req.account_id); - let arn = - Arn::global("iam", &state.account_id, &format!("saml-provider/{name}")).to_string(); + let arn = Arn::global("iam", &state.account_id, &format!("saml-provider/{name}")) + .with_partition(fakecloud_aws::arn::partition_for(&req.region)) + .to_string(); let provider = SamlProvider { arn: arn.clone(), @@ -357,8 +358,10 @@ impl IamService { .next() .unwrap_or(&url_without_scheme); let arn = format!( - "arn:aws:iam::{}:oidc-provider/{}", - state.account_id, url_for_arn + "arn:{}:iam::{}:oidc-provider/{}", + fakecloud_aws::arn::partition_for(&req.region), + state.account_id, + url_for_arn ); if state.oidc_providers.contains_key(&arn) { @@ -693,7 +696,8 @@ impl IamService { let cert = ServerCertificate { server_certificate_id: format!("ASCA{}", generate_id()), arn: format!( - "arn:aws:iam::{}:server-certificate{}{}", + "arn:{}:iam::{}:server-certificate{}{}", + fakecloud_aws::arn::partition_for(&req.region), state.account_id, if path == "/" { "/" } else { &path }, name diff --git a/crates/fakecloud-iam/src/iam_service/tests.rs b/crates/fakecloud-iam/src/iam_service/tests.rs index 6833abef5..ee851a9fc 100644 --- a/crates/fakecloud-iam/src/iam_service/tests.rs +++ b/crates/fakecloud-iam/src/iam_service/tests.rs @@ -5370,3 +5370,230 @@ fn list_instance_profiles_paginates() { assert!(!body.contains("ip-a")); assert!(!body.contains("ip-b")); } + +/// Every IAM entity ARN minted in a China region carries the `aws-cn` +/// partition, the same way users, roles and policies already do. +#[test] +fn entity_arns_carry_region_partition() { + let svc = make_service(); + let cn = |action: &str, params: Vec<(&str, &str)>| { + let mut req = make_request(action, params); + req.region = "cn-north-1".to_string(); + req + }; + let arn_of = |resp: AwsResponse, tag: &str| { + let body = String::from_utf8_lossy(resp.body.expect_bytes()).to_string(); + extract_xml_tag(&body, tag).to_string() + }; + + let group = svc + .create_group(&cn("CreateGroup", vec![("GroupName", "ops")])) + .unwrap(); + assert_eq!( + arn_of(group, "Arn"), + "arn:aws-cn:iam::123456789012:group/ops" + ); + svc.update_group(&cn( + "UpdateGroup", + vec![("GroupName", "ops"), ("NewGroupName", "ops2")], + )) + .unwrap(); + let group = svc + .get_group(&cn("GetGroup", vec![("GroupName", "ops2")])) + .unwrap(); + assert_eq!( + arn_of(group, "Arn"), + "arn:aws-cn:iam::123456789012:group/ops2" + ); + + let oidc = svc + .create_oidc_provider(&cn( + "CreateOpenIDConnectProvider", + vec![ + ("Url", "https://oidc.example.com"), + ( + "ThumbprintList.member.1", + "abcdef1234567890abcdef1234567890abcdef12", + ), + ], + )) + .unwrap(); + assert_eq!( + arn_of(oidc, "OpenIDConnectProviderArn"), + "arn:aws-cn:iam::123456789012:oidc-provider/oidc.example.com" + ); + + let metadata = format!("{}", "x".repeat(1000)); + let saml = svc + .create_saml_provider(&cn( + "CreateSAMLProvider", + vec![("Name", "idp"), ("SAMLMetadataDocument", &metadata)], + )) + .unwrap(); + assert_eq!( + arn_of(saml, "SAMLProviderArn"), + "arn:aws-cn:iam::123456789012:saml-provider/idp" + ); + + let cert = svc + .upload_server_certificate(&cn( + "UploadServerCertificate", + vec![ + ("ServerCertificateName", "web"), + ( + "CertificateBody", + "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + ), + ( + "PrivateKey", + "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + ), + ], + )) + .unwrap(); + assert_eq!( + arn_of(cert, "Arn"), + "arn:aws-cn:iam::123456789012:server-certificate/web" + ); + svc.update_server_certificate(&cn( + "UpdateServerCertificate", + vec![ + ("ServerCertificateName", "web"), + ("NewServerCertificateName", "web2"), + ], + )) + .unwrap(); + let cert = svc + .get_server_certificate(&cn( + "GetServerCertificate", + vec![("ServerCertificateName", "web2")], + )) + .unwrap(); + assert_eq!( + arn_of(cert, "Arn"), + "arn:aws-cn:iam::123456789012:server-certificate/web2" + ); + + svc.generate_credential_report(&cn("GenerateCredentialReport", vec![])) + .unwrap(); + let report = svc + .get_credential_report(&cn("GetCredentialReport", vec![])) + .unwrap(); + let encoded = arn_of(report, "Content"); + let csv = String::from_utf8( + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).unwrap(), + ) + .unwrap(); + assert!( + csv.contains(",arn:aws-cn:iam::123456789012:root,"), + "root row must carry the region partition: {csv}" + ); +} + +/// ChangePassword resolves the caller from an `aws-cn` principal ARN rather +/// than silently no-oping because the ARN is not `arn:aws:`. +#[test] +fn change_password_resolves_non_aws_partition_principal() { + let svc = make_service(); + svc.create_user(&make_request("CreateUser", vec![("UserName", "u1")])) + .unwrap(); + svc.create_login_profile(&make_request( + "CreateLoginProfile", + vec![("UserName", "u1"), ("Password", "old")], + )) + .unwrap(); + let mut principal = change_password_principal("u1"); + principal.arn = "arn:aws-cn:iam::123456789012:user/u1".to_string(); + + let mut wrong = make_request( + "ChangePassword", + vec![("OldPassword", "not-old"), ("NewPassword", "fresh")], + ); + wrong.principal = Some(principal.clone()); + assert!( + svc.change_password(&wrong).is_err(), + "a wrong old password must be rejected, not accepted as an anonymous no-op" + ); + + let mut right = make_request( + "ChangePassword", + vec![("OldPassword", "old"), ("NewPassword", "fresh")], + ); + right.principal = Some(principal); + svc.change_password(&right).unwrap(); +} + +/// IAM is global, so a rename can arrive from any region. Renaming a group +/// or server certificate from a commercial region must keep the `aws-cn` +/// partition it was created with, the same way UpdateUser does. +#[test] +fn rename_from_other_region_keeps_partition() { + let svc = make_service(); + let in_region = |action: &str, params: Vec<(&str, &str)>, region: &str| { + let mut req = make_request(action, params); + req.region = region.to_string(); + req + }; + let arn_of = |resp: AwsResponse| { + let body = String::from_utf8_lossy(resp.body.expect_bytes()).to_string(); + extract_xml_tag(&body, "Arn").to_string() + }; + + svc.create_group(&in_region( + "CreateGroup", + vec![("GroupName", "ops")], + "cn-north-1", + )) + .unwrap(); + svc.update_group(&in_region( + "UpdateGroup", + vec![("GroupName", "ops"), ("NewGroupName", "ops2")], + "us-east-1", + )) + .unwrap(); + let group = svc + .get_group(&in_region( + "GetGroup", + vec![("GroupName", "ops2")], + "us-east-1", + )) + .unwrap(); + assert_eq!(arn_of(group), "arn:aws-cn:iam::123456789012:group/ops2"); + + svc.upload_server_certificate(&in_region( + "UploadServerCertificate", + vec![ + ("ServerCertificateName", "web"), + ( + "CertificateBody", + "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + ), + ( + "PrivateKey", + "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + ), + ], + "cn-north-1", + )) + .unwrap(); + svc.update_server_certificate(&in_region( + "UpdateServerCertificate", + vec![ + ("ServerCertificateName", "web"), + ("NewServerCertificateName", "web2"), + ], + "us-east-1", + )) + .unwrap(); + let cert = svc + .get_server_certificate(&in_region( + "GetServerCertificate", + vec![("ServerCertificateName", "web2")], + "us-east-1", + )) + .unwrap(); + assert_eq!( + arn_of(cert), + "arn:aws-cn:iam::123456789012:server-certificate/web2" + ); +} diff --git a/crates/fakecloud-iam/src/iam_service/users.rs b/crates/fakecloud-iam/src/iam_service/users.rs index 1910298d8..b165e5a72 100644 --- a/crates/fakecloud-iam/src/iam_service/users.rs +++ b/crates/fakecloud-iam/src/iam_service/users.rs @@ -9,10 +9,10 @@ use crate::state::{IamAccessKey, IamState, IamUser, SigningCertificate, SshPubli use crate::xml_responses; use super::{ - 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, + empty_response, existing_arn_partition, 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, }; use fakecloud_core::query::required_param; @@ -437,17 +437,7 @@ impl IamService { let actual_new_name = new_user_name.unwrap_or_else(|| user_name.clone()); user.user_name = actual_new_name.clone(); - // Preserve the existing ARN's partition (aws / aws-cn / aws-us-gov - // / aws-iso*) rather than hardcoding `arn:aws:`, which would flip - // the partition on a rename in cn-/us-gov-/iso- regions. Derive it - // from the user's current ARN, falling back to the region. - let partition = user - .arn - .split(':') - .nth(1) - .filter(|p| !p.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| partition_for_region(&req.region).to_string()); + let partition = existing_arn_partition(&user.arn, &req.region); user.arn = format!( "arn:{}:iam::{}:user{}{}", partition, diff --git a/crates/fakecloud-iam/src/pass_role.rs b/crates/fakecloud-iam/src/pass_role.rs index c01dc5372..3d2406e23 100644 --- a/crates/fakecloud-iam/src/pass_role.rs +++ b/crates/fakecloud-iam/src/pass_role.rs @@ -35,12 +35,12 @@ impl IamRoleTrustValidator { } } -/// Parse `arn:aws:iam:::role[/]/` into role name. -/// Returns `None` if the ARN is not an IAM role ARN. +/// Parse `arn::iam:::role[/]/` into role +/// name, in any partition. Returns `None` if the ARN is not an IAM role ARN. fn role_name_from_arn(role_arn: &str) -> Option<&str> { - // Format: arn:aws:iam:::role// // Extract the substring after the last "/". - let role_part = role_arn.strip_prefix("arn:aws:iam::")?; + let (_partition, rest) = role_arn.strip_prefix("arn:")?.split_once(':')?; + let role_part = rest.strip_prefix("iam::")?; let (_account, rest) = role_part.split_once(':')?; let role_path = rest.strip_prefix("role/")?; role_path.rsplit('/').next() @@ -182,6 +182,18 @@ mod tests { role_name_from_arn("arn:aws:iam::000000000000:role/service-role/MyRole"), Some("MyRole") ); + assert_eq!( + role_name_from_arn("arn:aws-cn:iam::000000000000:role/MyRole"), + Some("MyRole") + ); + assert_eq!( + role_name_from_arn("arn:aws-us-gov:iam::000000000000:role/svc/MyRole"), + Some("MyRole") + ); + assert_eq!( + role_name_from_arn("arn:aws-cn:sts::000000000000:role/MyRole"), + None + ); assert_eq!(role_name_from_arn("not-an-arn"), None); } diff --git a/crates/fakecloud-iam/src/sts_service/assume.rs b/crates/fakecloud-iam/src/sts_service/assume.rs index 3762740d3..eb466b4b7 100644 --- a/crates/fakecloud-iam/src/sts_service/assume.rs +++ b/crates/fakecloud-iam/src/sts_service/assume.rs @@ -489,7 +489,13 @@ impl StsService { .as_ref() .map(|(_iss, p)| p.arn.clone()) .or(provider_id_param.clone()) - .unwrap_or_else(|| format!("arn:aws:iam::{}:oidc-provider/web-identity", account_id)); + .unwrap_or_else(|| { + format!( + "arn:{}:iam::{}:oidc-provider/web-identity", + partition_for_region(&req.region), + account_id + ) + }); // Trust-policy gate: same shape as AssumeRole, but the caller // principal is the federated provider and the action is