diff --git a/Cargo.lock b/Cargo.lock index ff85c12f2..32193f0ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5631,6 +5631,7 @@ dependencies = [ "fakecloud-aws", "fakecloud-core", "fakecloud-ec2", + "fakecloud-kms", "fakecloud-persistence", "http 1.4.0", "parking_lot", @@ -6000,6 +6001,7 @@ dependencies = [ "chrono", "fakecloud-aws", "fakecloud-core", + "fakecloud-kms", "fakecloud-persistence", "http 1.4.0", "parking_lot", @@ -6438,6 +6440,7 @@ dependencies = [ "fakecloud-aws", "fakecloud-core", "fakecloud-k8s", + "fakecloud-kms", "fakecloud-persistence", "http 1.4.0", "k8s-openapi", @@ -7074,6 +7077,7 @@ dependencies = [ "chrono", "fakecloud-aws", "fakecloud-core", + "fakecloud-kms", "fakecloud-persistence", "http 1.4.0", "parking_lot", diff --git a/crates/fakecloud-athena/src/lib.rs b/crates/fakecloud-athena/src/lib.rs index 2f41ed399..e6f37140d 100644 --- a/crates/fakecloud-athena/src/lib.rs +++ b/crates/fakecloud-athena/src/lib.rs @@ -2,7 +2,7 @@ pub(crate) mod service; pub(crate) mod sql; pub(crate) mod state; -pub use service::AthenaService; +pub use service::{athena_arn, AthenaService}; pub use state::{ AthenaAccounts, AthenaSnapshot, DataCatalog, NamedQuery, PreparedStatement, SharedAthenaState, WorkGroup, ATHENA_SNAPSHOT_SCHEMA_VERSION, diff --git a/crates/fakecloud-athena/src/service/capacity.rs b/crates/fakecloud-athena/src/service/capacity.rs index dcad896b2..8ab12ca33 100644 --- a/crates/fakecloud-athena/src/service/capacity.rs +++ b/crates/fakecloud-athena/src/service/capacity.rs @@ -31,7 +31,11 @@ impl AthenaService { last_allocation: Some(Utc::now()), last_successful_allocation_time: Some(Utc::now()), }; - let arn = capacity_reservation_arn(&req.account_id, &req.region, &name); + let arn = athena_arn( + &req.region, + &req.account_id, + &format!("capacity-reservation/{name}"), + ); account.capacity_reservations.insert(name, cr); if !tags.is_empty() { account.tags.insert(arn, tags); diff --git a/crates/fakecloud-athena/src/service/data_catalogs.rs b/crates/fakecloud-athena/src/service/data_catalogs.rs index b34281b1e..3f19d96dc 100644 --- a/crates/fakecloud-athena/src/service/data_catalogs.rs +++ b/crates/fakecloud-athena/src/service/data_catalogs.rs @@ -45,7 +45,7 @@ impl AthenaService { connection_type, error: None, }; - let arn = datacatalog_arn(&req.account_id, &req.region, &name); + let arn = athena_arn(&req.region, &req.account_id, &format!("datacatalog/{name}")); account.data_catalogs.insert(name, cat); if !tags.is_empty() { account.tags.insert(arn, tags); diff --git a/crates/fakecloud-athena/src/service/mod.rs b/crates/fakecloud-athena/src/service/mod.rs index c687a2342..ef64689cb 100644 --- a/crates/fakecloud-athena/src/service/mod.rs +++ b/crates/fakecloud-athena/src/service/mod.rs @@ -658,37 +658,16 @@ fn synth_uuid() -> String { Uuid::new_v4().to_string() } -fn workgroup_arn(account_id: &str, region: &str, name: &str) -> String { +/// An Athena ARN (`resource` is e.g. `workgroup/`, +/// `datacatalog/` or `capacity-reservation/`), in `region`'s +/// partition. An empty region falls back to `us-east-1`. +pub fn athena_arn(region: &str, account_id: &str, resource: &str) -> String { let region = if region.is_empty() { "us-east-1" } else { region }; - Arn::new("athena", region, account_id, &format!("workgroup/{name}")).to_string() -} - -fn datacatalog_arn(account_id: &str, region: &str, name: &str) -> String { - let region = if region.is_empty() { - "us-east-1" - } else { - region - }; - Arn::new("athena", region, account_id, &format!("datacatalog/{name}")).to_string() -} - -fn capacity_reservation_arn(account_id: &str, region: &str, name: &str) -> String { - let region = if region.is_empty() { - "us-east-1" - } else { - region - }; - Arn::new( - "athena", - region, - account_id, - &format!("capacity-reservation/{name}"), - ) - .to_string() + Arn::regional("athena", region, account_id, resource).to_string() } fn parse_string_list(value: Option<&Value>) -> Vec { @@ -2004,6 +1983,44 @@ mod tests { assert_eq!(deleted["DataCatalog"]["Type"], "GLUE"); assert_eq!(deleted["DataCatalog"]["Status"], "DELETE_COMPLETE"); } + + /// Athena ARNs take the partition of the region they are minted in, so a + /// resource created in a China region is tagged and looked up under its + /// `aws-cn` ARN. + #[test] + fn arns_use_the_regions_partition() { + assert_eq!( + super::athena_arn("cn-north-1", "123456789012", "workgroup/wg"), + "arn:aws-cn:athena:cn-north-1:123456789012:workgroup/wg" + ); + assert_eq!( + super::athena_arn("cn-north-1", "123456789012", "datacatalog/cat"), + "arn:aws-cn:athena:cn-north-1:123456789012:datacatalog/cat" + ); + assert_eq!( + super::athena_arn("us-gov-west-1", "123456789012", "capacity-reservation/cr"), + "arn:aws-us-gov:athena:us-gov-west-1:123456789012:capacity-reservation/cr" + ); + assert_eq!( + super::athena_arn("us-east-1", "123456789012", "workgroup/wg"), + "arn:aws:athena:us-east-1:123456789012:workgroup/wg" + ); + + let svc = AthenaService::new(SharedAthenaState::default()); + let mut create = req( + "CreateWorkGroup", + json!({ "Name": "cnwg", "Tags": [{ "Key": "team", "Value": "data" }] }), + ); + create.region = "cn-north-1".to_string(); + svc.create_work_group(&create).unwrap(); + let mut list = req( + "ListTagsForResource", + json!({ "ResourceARN": "arn:aws-cn:athena:cn-north-1:123456789012:workgroup/cnwg" }), + ); + list.region = "cn-north-1".to_string(); + let tags = parse_json(&svc.list_tags_for_resource(&list).unwrap()); + assert_eq!(tags["Tags"], json!([{ "Key": "team", "Value": "data" }])); + } } #[cfg(test)] diff --git a/crates/fakecloud-athena/src/service/work_groups.rs b/crates/fakecloud-athena/src/service/work_groups.rs index c692eb69e..3ff4f0cc1 100644 --- a/crates/fakecloud-athena/src/service/work_groups.rs +++ b/crates/fakecloud-athena/src/service/work_groups.rs @@ -37,7 +37,7 @@ impl AthenaService { creation_time: Utc::now(), engine_version, }; - let arn = workgroup_arn(&req.account_id, &req.region, &name); + let arn = athena_arn(&req.region, &req.account_id, &format!("workgroup/{name}")); account.work_groups.insert(name, wg); if !tags.is_empty() { account.tags.insert(arn, tags); diff --git a/crates/fakecloud-bedrock/src/prompt_routers.rs b/crates/fakecloud-bedrock/src/prompt_routers.rs index a873e130a..8671b3889 100644 --- a/crates/fakecloud-bedrock/src/prompt_routers.rs +++ b/crates/fakecloud-bedrock/src/prompt_routers.rs @@ -71,9 +71,9 @@ pub(crate) fn get_prompt_router( "promptRouterArn": router.prompt_router_arn, "promptRouterName": router.prompt_router_name, "description": router.description, - "models": ensure_router_models(&router.models), + "models": ensure_router_models(&router.models, &router.prompt_router_arn, &req.region), "routingCriteria": ensure_routing_criteria(&router.routing_criteria), - "fallbackModel": ensure_fallback_model(&router.fallback_model), + "fallbackModel": ensure_fallback_model(&router.fallback_model, &router.prompt_router_arn, &req.region), "status": router.status, "type": router.prompt_router_type, "createdAt": router.created_at.to_rfc3339(), @@ -118,8 +118,8 @@ pub(crate) fn list_prompt_routers( "promptRouterName": r.prompt_router_name, "description": r.description, "routingCriteria": ensure_routing_criteria(&r.routing_criteria), - "models": ensure_router_models(&r.models), - "fallbackModel": ensure_fallback_model(&r.fallback_model), + "models": ensure_router_models(&r.models, &r.prompt_router_arn, &req.region), + "fallbackModel": ensure_fallback_model(&r.fallback_model, &r.prompt_router_arn, &req.region), "status": r.status, "type": r.prompt_router_type, "createdAt": r.created_at.to_rfc3339(), @@ -148,28 +148,37 @@ fn ensure_routing_criteria(criteria: &Value) -> Value { json!({ "responseQualityDifference": 0.0 }) } +/// The foundation model a router defaults to, in the router's own region and +/// partition. The router's stored ARN names that region; `request_region` is +/// the source of truth only when the ARN does not parse. +fn default_router_model_arn(router_arn: &str, request_region: &str) -> String { + let parsed = router_arn.parse::().ok(); + let region = parsed + .as_ref() + .map(|a| a.region.as_str()) + .filter(|r| !r.is_empty()) + .unwrap_or(request_region); + crate::arns::foundation_model_arn(region, "amazon.titan-text-express-v1") +} + /// Default the router's target models to a single Titan entry when none were /// supplied at create time, matching the required Smithy field. -fn ensure_router_models(models: &Value) -> Value { +fn ensure_router_models(models: &Value, router_arn: &str, request_region: &str) -> Value { if let Some(arr) = models.as_array() { if !arr.is_empty() { return models.clone(); } } - json!([{ - "modelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-express-v1" - }]) + json!([{ "modelArn": default_router_model_arn(router_arn, request_region) }]) } /// Default the fallback model when not provided. `fallbackModel` is required /// in the summary even though it's optional on create. -fn ensure_fallback_model(fallback: &Value) -> Value { +fn ensure_fallback_model(fallback: &Value, router_arn: &str, request_region: &str) -> Value { if fallback.get("modelArn").is_some() { return fallback.clone(); } - json!({ - "modelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-express-v1" - }) + json!({ "modelArn": default_router_model_arn(router_arn, request_region) }) } pub(crate) fn delete_prompt_router( @@ -274,6 +283,57 @@ mod tests { assert!(get_prompt_router(&s, &req(), "my-router").is_ok()); } + /// A router created without models defaults them to a foundation model in + /// the router's own region and partition. + #[test] + fn default_models_use_the_routers_partition() { + let s = shared(); + let mut r = req(); + r.region = "cn-north-1".to_string(); + let resp = create_prompt_router(&s, &r, &json!({ "promptRouterName": "cn" })).unwrap(); + let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + let arn = v["promptRouterArn"].as_str().unwrap().to_string(); + assert!(arn.starts_with("arn:aws-cn:bedrock:cn-north-1:"), "{arn}"); + let got = get_prompt_router(&s, &r, &arn).unwrap(); + let g: Value = serde_json::from_slice(got.body.expect_bytes()).unwrap(); + let titan = "arn:aws-cn:bedrock:cn-north-1::foundation-model/amazon.titan-text-express-v1"; + assert_eq!(g["models"], json!([{ "modelArn": titan }])); + assert_eq!(g["fallbackModel"], json!({ "modelArn": titan })); + let listed = list_prompt_routers(&s, &r).unwrap(); + let l: Value = serde_json::from_slice(listed.body.expect_bytes()).unwrap(); + assert_eq!( + l["promptRouterSummaries"][0]["fallbackModel"]["modelArn"], + titan + ); + + // Commercial output is unchanged. + let arn = create_prompt_router(&s, &req(), &json!({ "promptRouterName": "us" })).unwrap(); + let v: Value = serde_json::from_slice(arn.body.expect_bytes()).unwrap(); + let got = get_prompt_router(&s, &req(), v["promptRouterArn"].as_str().unwrap()).unwrap(); + let g: Value = serde_json::from_slice(got.body.expect_bytes()).unwrap(); + assert_eq!( + g["fallbackModel"]["modelArn"], + "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-express-v1" + ); + } + + /// The router's stored ARN names the region; only an ARN that does not + /// parse falls back to the request's region, never to a fixed one. + #[test] + fn default_model_region_comes_from_the_router_arn_then_the_request() { + assert_eq!( + default_router_model_arn( + "arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:prompt-router/x", + "cn-north-1" + ), + "arn:aws-us-gov:bedrock:us-gov-west-1::foundation-model/amazon.titan-text-express-v1" + ); + assert_eq!( + default_router_model_arn("not-an-arn", "cn-north-1"), + "arn:aws-cn:bedrock:cn-north-1::foundation-model/amazon.titan-text-express-v1" + ); + } + #[test] fn get_unknown_returns_not_found() { let s = shared(); diff --git a/crates/fakecloud-cloudwatch/src/lib.rs b/crates/fakecloud-cloudwatch/src/lib.rs index 7b18593bb..e812004bb 100644 --- a/crates/fakecloud-cloudwatch/src/lib.rs +++ b/crates/fakecloud-cloudwatch/src/lib.rs @@ -21,7 +21,7 @@ mod tests; pub use delivery::CloudwatchDeliveryImpl; pub use service::CloudWatchService; pub use state::{ - AlarmMetricQuery, AlarmMetricStat, AlarmState, CloudWatchAccounts, CloudWatchSnapshot, - CloudWatchState, Dashboard, MetricAlarm, MetricDatum, SharedCloudWatchState, - CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION, + alarm_arn, AlarmMetricQuery, AlarmMetricStat, AlarmState, CloudWatchAccounts, + CloudWatchSnapshot, CloudWatchState, Dashboard, MetricAlarm, MetricDatum, + SharedCloudWatchState, CLOUDWATCH_SNAPSHOT_SCHEMA_VERSION, }; diff --git a/crates/fakecloud-cloudwatch/src/state.rs b/crates/fakecloud-cloudwatch/src/state.rs index cb8f1749f..142d1d733 100644 --- a/crates/fakecloud-cloudwatch/src/state.rs +++ b/crates/fakecloud-cloudwatch/src/state.rs @@ -373,7 +373,7 @@ pub struct AlarmHistoryItem { /// The ARN of alarm `name` (metric, composite or log alarm) in `region`'s /// partition. -pub(crate) fn alarm_arn(region: &str, account_id: &str, name: &str) -> String { +pub fn alarm_arn(region: &str, account_id: &str, name: &str) -> String { fakecloud_aws::arn::Arn::regional("cloudwatch", region, account_id, &format!("alarm:{name}")) .to_string() } diff --git a/crates/fakecloud-efs/Cargo.toml b/crates/fakecloud-efs/Cargo.toml index 28ba18e52..99cb568d2 100644 --- a/crates/fakecloud-efs/Cargo.toml +++ b/crates/fakecloud-efs/Cargo.toml @@ -25,5 +25,6 @@ tracing = { workspace = true } uuid = { workspace = true } [dev-dependencies] +fakecloud-kms = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } bytes = { workspace = true } diff --git a/crates/fakecloud-efs/src/service.rs b/crates/fakecloud-efs/src/service.rs index 52de42184..971afb131 100644 --- a/crates/fakecloud-efs/src/service.rs +++ b/crates/fakecloud-efs/src/service.rs @@ -675,16 +675,21 @@ impl EfsService { .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| { - format!( - "arn:aws:kms:{}:{}:key/{}-{}-{}-{}-{}", - ctx.region, - ctx.account, + let key_id = format!( + "{}-{}-{}-{}-{}", &hex17()[..8], &hex17()[..4], &hex17()[..4], &hex17()[..4], &hex17()[..12.min(hex17().len())] + ); + fakecloud_aws::arn::Arn::regional( + "kms", + &ctx.region, + &ctx.account, + &format!("key/{key_id}"), ) + .to_string() }); fs.insert("KmsKeyId".into(), json!(kms)); } @@ -1888,6 +1893,38 @@ mod tests { ); } + /// An encrypted file system's default key is the ARN KMS itself mints for + /// that key id in the caller's region, partition included. + #[test] + fn default_kms_key_uses_the_regions_partition() { + let s = svc(); + let c = Ctx { + account: "000000000000".to_string(), + region: "cn-north-1".to_string(), + }; + let fs = body_value( + &s.create_file_system(&c, &json!({ "CreationToken": "enc", "Encrypted": true })) + .unwrap(), + ); + let kms = fs["KmsKeyId"].as_str().unwrap(); + let key_id = kms + .strip_prefix("arn:aws-cn:kms:cn-north-1:000000000000:key/") + .unwrap_or_else(|| panic!("{kms}")); + assert_eq!( + kms, + fakecloud_kms::kms_key_arn("cn-north-1", "000000000000", key_id) + ); + // Commercial output keeps the `aws` partition. + let fs = body_value( + &s.create_file_system(&ctx(), &json!({ "CreationToken": "us", "Encrypted": true })) + .unwrap(), + ); + assert!(fs["KmsKeyId"] + .as_str() + .unwrap() + .starts_with("arn:aws:kms:us-east-1:000000000000:key/")); + } + // Defect #1: a nonexistent subnet must be rejected with SubnetNotFound once // EC2 state is wired to validate against (rather than synthesizing one). #[test] diff --git a/crates/fakecloud-eventbridge/src/lib.rs b/crates/fakecloud-eventbridge/src/lib.rs index 63ce6d43b..2ef088ab0 100644 --- a/crates/fakecloud-eventbridge/src/lib.rs +++ b/crates/fakecloud-eventbridge/src/lib.rs @@ -8,6 +8,7 @@ pub(crate) mod state; pub use service::helpers::parse_target; pub use service::EventBridgeService; pub use state::{ - ApiDestination, Archive, Connection, Endpoint, EventBridgeSnapshot, EventBridgeState, EventBus, - EventRule, EventTarget, SharedEventBridgeState, EVENTBRIDGE_SNAPSHOT_SCHEMA_VERSION, + bus_arn, rule_arn, ApiDestination, Archive, Connection, Endpoint, EventBridgeSnapshot, + EventBridgeState, EventBus, EventRule, EventTarget, SharedEventBridgeState, + EVENTBRIDGE_SNAPSHOT_SCHEMA_VERSION, }; diff --git a/crates/fakecloud-eventbridge/src/state.rs b/crates/fakecloud-eventbridge/src/state.rs index 28c33cba4..65b4b737c 100644 --- a/crates/fakecloud-eventbridge/src/state.rs +++ b/crates/fakecloud-eventbridge/src/state.rs @@ -49,13 +49,13 @@ pub(crate) fn arn_with_request_region(arn: &str, region: &str) -> String { } /// The ARN of the event bus `name` in `region`'s partition. -pub(crate) fn bus_arn(region: &str, account_id: &str, name: &str) -> String { +pub fn bus_arn(region: &str, account_id: &str, name: &str) -> String { Arn::regional("events", region, account_id, &format!("event-bus/{name}")).to_string() } /// The ARN of rule `name` on bus `bus`. Rules on the default bus leave the bus /// out of the resource path, as AWS does. -pub(crate) fn rule_arn(region: &str, account_id: &str, bus: &str, name: &str) -> String { +pub fn rule_arn(region: &str, account_id: &str, bus: &str, name: &str) -> String { let resource = if bus == "default" { format!("rule/{name}") } else { diff --git a/crates/fakecloud-kafka/Cargo.toml b/crates/fakecloud-kafka/Cargo.toml index b49c211c0..2a661924c 100644 --- a/crates/fakecloud-kafka/Cargo.toml +++ b/crates/fakecloud-kafka/Cargo.toml @@ -25,6 +25,7 @@ tracing = { workspace = true } uuid = { workspace = true } [dev-dependencies] +fakecloud-kms = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } bytes = { workspace = true } http = { workspace = true } diff --git a/crates/fakecloud-kafka/src/builders.rs b/crates/fakecloud-kafka/src/builders.rs index 9fcefe183..fe5095de9 100644 --- a/crates/fakecloud-kafka/src/builders.rs +++ b/crates/fakecloud-kafka/src/builders.rs @@ -34,7 +34,8 @@ pub(crate) fn default_bngi() -> Value { /// (real MSK always echoes a resolved key ARN, so `encryption_info` round-trips /// and `MatchResourceAttrRegionalARN(..., "kms", "key/.+")` matches). fn default_kms_key_arn(region: &str, account: &str) -> String { - format!("arn:aws:kms:{region}:{account}:key/{}", Uuid::new_v4()) + fakecloud_aws::arn::Arn::regional("kms", region, account, &format!("key/{}", Uuid::new_v4())) + .to_string() } /// Fill an `EncryptionInfo` with the sub-objects + AWS defaults `DescribeCluster` diff --git a/crates/fakecloud-kafka/src/service.rs b/crates/fakecloud-kafka/src/service.rs index cfde04326..4869abf87 100644 --- a/crates/fakecloud-kafka/src/service.rs +++ b/crates/fakecloud-kafka/src/service.rs @@ -3543,6 +3543,18 @@ mod tests { settle(&s, &c); let d = json_of(s.describe_cluster(&c, &arn, false).unwrap()); assert_eq!(d["clusterInfo"]["clusterArn"], json!(arn)); + // The synthesized at-rest key is the ARN KMS itself mints for that key + // id in the cluster's region. + let kms = d["clusterInfo"]["encryptionInfo"]["encryptionAtRest"]["dataVolumeKMSKeyId"] + .as_str() + .unwrap(); + let key_id = kms + .strip_prefix("arn:aws-cn:kms:cn-north-1:123456789012:key/") + .unwrap_or_else(|| panic!("{kms}")); + assert_eq!( + kms, + fakecloud_kms::kms_key_arn("cn-north-1", "123456789012", key_id) + ); let nodes = json_of(s.list_nodes(&c, &arn, &[]).unwrap()); let node_arn = nodes["nodeInfoList"][0]["nodeARN"].as_str().unwrap(); assert!( diff --git a/crates/fakecloud-rds/Cargo.toml b/crates/fakecloud-rds/Cargo.toml index 36e1067d9..8fbcb9c58 100644 --- a/crates/fakecloud-rds/Cargo.toml +++ b/crates/fakecloud-rds/Cargo.toml @@ -30,6 +30,7 @@ tracing = { workspace = true } tempfile = { workspace = true } [dev-dependencies] +fakecloud-kms = { workspace = true } # The opt-in k8s-integration test drives a real cluster directly. kube = { workspace = true } diff --git a/crates/fakecloud-rds/src/extras/parse.rs b/crates/fakecloud-rds/src/extras/parse.rs index 2fb25c26f..d5eca34c9 100644 --- a/crates/fakecloud-rds/src/extras/parse.rs +++ b/crates/fakecloud-rds/src/extras/parse.rs @@ -144,9 +144,9 @@ pub(super) fn format_kms_arn(input: &str, region: &str, account_id: &str) -> Str return input.to_string(); } if input.starts_with("alias/") { - return Arn::new("kms", region, account_id, input).to_string(); + return Arn::regional("kms", region, account_id, input).to_string(); } - Arn::new("kms", region, account_id, &format!("key/{input}")).to_string() + Arn::regional("kms", region, account_id, &format!("key/{input}")).to_string() } /// Render a whole extras category as a Describe list response. diff --git a/crates/fakecloud-rds/src/extras/tests.rs b/crates/fakecloud-rds/src/extras/tests.rs index c863f99d2..d394d4ca0 100644 --- a/crates/fakecloud-rds/src/extras/tests.rs +++ b/crates/fakecloud-rds/src/extras/tests.rs @@ -6563,3 +6563,29 @@ fn describe_db_cluster_snapshots_filters_by_snapshot_type_and_cluster() { assert!(body.contains("auto-snap")); assert!(body.contains("manual-snap")); } + +/// A bare key id or alias echoed back as `KmsKeyId` must be the ARN KMS +/// itself mints for that key in the caller's region, partition included. +#[test] +fn kms_key_id_is_expanded_in_the_regions_partition() { + let key = super::format_kms_arn("1234abcd", "cn-north-1", "000000000000"); + assert_eq!(key, "arn:aws-cn:kms:cn-north-1:000000000000:key/1234abcd"); + assert_eq!( + key, + fakecloud_kms::kms_key_arn("cn-north-1", "000000000000", "1234abcd") + ); + let alias = super::format_kms_arn("alias/aws/rds", "cn-north-1", "000000000000"); + assert_eq!( + alias, + "arn:aws-cn:kms:cn-north-1:000000000000:alias/aws/rds" + ); + assert_eq!( + alias, + fakecloud_kms::kms_alias_arn("cn-north-1", "000000000000", "alias/aws/rds") + ); + // Commercial output is unchanged. + assert_eq!( + super::format_kms_arn("1234abcd", "us-east-1", "000000000000"), + "arn:aws:kms:us-east-1:000000000000:key/1234abcd" + ); +} diff --git a/crates/fakecloud-sns/src/helpers.rs b/crates/fakecloud-sns/src/helpers.rs index 9353b248f..6e21c9e4e 100644 --- a/crates/fakecloud-sns/src/helpers.rs +++ b/crates/fakecloud-sns/src/helpers.rs @@ -38,7 +38,7 @@ pub(crate) fn is_mutating_action(action: &str) -> bool { } /// The ARN of topic `name` in `region`'s partition. -pub(crate) fn topic_arn(region: &str, account_id: &str, name: &str) -> String { +pub fn topic_arn(region: &str, account_id: &str, name: &str) -> String { fakecloud_aws::arn::Arn::regional("sns", region, account_id, name).to_string() } diff --git a/crates/fakecloud-sns/src/lib.rs b/crates/fakecloud-sns/src/lib.rs index 809f15337..83756cb2e 100644 --- a/crates/fakecloud-sns/src/lib.rs +++ b/crates/fakecloud-sns/src/lib.rs @@ -5,7 +5,7 @@ pub mod signing; pub mod simulation; pub(crate) mod state; -pub use service::SnsService; +pub use service::{topic_arn, SnsService}; pub use state::{ SharedSnsState, SnsSnapshot, SnsState, SnsSubscription, SnsTopic, SNS_SNAPSHOT_SCHEMA_VERSION, }; diff --git a/crates/fakecloud-sns/src/service.rs b/crates/fakecloud-sns/src/service.rs index c8148b836..2a3cf008f 100644 --- a/crates/fakecloud-sns/src/service.rs +++ b/crates/fakecloud-sns/src/service.rs @@ -1903,6 +1903,7 @@ mod service_sms; #[path = "helpers.rs"] mod helpers; +pub use helpers::topic_arn; pub(crate) use helpers::*; #[cfg(test)] diff --git a/crates/fakecloud-ssm/src/lib.rs b/crates/fakecloud-ssm/src/lib.rs index 0dcfc758f..2048434db 100644 --- a/crates/fakecloud-ssm/src/lib.rs +++ b/crates/fakecloud-ssm/src/lib.rs @@ -1,7 +1,7 @@ pub(crate) mod service; pub(crate) mod state; -pub use service::SsmService; +pub use service::{param_arn, SsmService}; pub use state::{ ParameterPolicyEvent, SharedSsmState, SsmParameter, SsmParameterVersion, SsmSnapshot, SsmState, SSM_SNAPSHOT_SCHEMA_VERSION, diff --git a/crates/fakecloud-ssm/src/service/mod.rs b/crates/fakecloud-ssm/src/service/mod.rs index dd241b42b..ada6f6e67 100644 --- a/crates/fakecloud-ssm/src/service/mod.rs +++ b/crates/fakecloud-ssm/src/service/mod.rs @@ -10,6 +10,7 @@ mod maintenance; mod misc; mod ops; mod parameters; +pub use parameters::param_arn; mod patches; mod resource_sync; mod sessions; diff --git a/crates/fakecloud-ssm/src/service/parameters.rs b/crates/fakecloud-ssm/src/service/parameters.rs index 7e8040dea..63f8b029a 100644 --- a/crates/fakecloud-ssm/src/service/parameters.rs +++ b/crates/fakecloud-ssm/src/service/parameters.rs @@ -1871,7 +1871,7 @@ pub(super) fn remove_param( parameters.remove(&alt) } -pub(super) fn param_arn(region: &str, account_id: &str, name: &str) -> String { +pub fn param_arn(region: &str, account_id: &str, name: &str) -> String { let resource = if name.starts_with('/') { format!("parameter{name}") } else { diff --git a/crates/fakecloud-timestream/Cargo.toml b/crates/fakecloud-timestream/Cargo.toml index 0a1493e31..7b0b2415f 100644 --- a/crates/fakecloud-timestream/Cargo.toml +++ b/crates/fakecloud-timestream/Cargo.toml @@ -24,5 +24,6 @@ tracing = { workspace = true } uuid = { workspace = true } [dev-dependencies] +fakecloud-kms = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } bytes = { workspace = true } diff --git a/crates/fakecloud-timestream/src/service.rs b/crates/fakecloud-timestream/src/service.rs index d8b49fe17..6b3934c49 100644 --- a/crates/fakecloud-timestream/src/service.rs +++ b/crates/fakecloud-timestream/src/service.rs @@ -323,9 +323,15 @@ impl TimestreamService { name: name.clone(), arn: arn.clone(), kms_key_id: kms.or_else(|| { - Some(format!( - "arn:aws:kms:{region}:{account}:key/timestream-default" - )) + Some( + fakecloud_aws::arn::Arn::regional( + "kms", + ®ion, + &account, + "key/timestream-default", + ) + .to_string(), + ) }), table_count: 0, creation_time: now, @@ -1479,6 +1485,32 @@ mod tests { assert_eq!(e.code(), "ResourceNotFoundException"); } + /// The default database key is the ARN KMS itself mints for that key id + /// in the caller's region, partition included. + #[test] + fn default_database_key_uses_the_regions_partition() { + let s = svc(); + let mut r = req("CreateDatabase", json!({ "DatabaseName": "cn" })); + r.region = "cn-north-1".to_string(); + let resp = s.dispatch("CreateDatabase", &r).unwrap(); + let out: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + let kms = out["Database"]["KmsKeyId"].as_str().unwrap(); + assert_eq!( + kms, + "arn:aws-cn:kms:cn-north-1:000000000000:key/timestream-default" + ); + assert_eq!( + kms, + fakecloud_kms::kms_key_arn("cn-north-1", "000000000000", "timestream-default") + ); + // Commercial output is unchanged. + let out = call(&s, "CreateDatabase", json!({ "DatabaseName": "us" })).unwrap(); + assert_eq!( + out["Database"]["KmsKeyId"], + "arn:aws:kms:us-east-1:000000000000:key/timestream-default" + ); + } + #[test] fn table_requires_database() { let s = svc();