diff --git a/Cargo.lock b/Cargo.lock index 7b39b5d1aa..9ec96472c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,6 +1172,7 @@ dependencies = [ "rstest", "serde", "serde_json", + "sha2 0.10.9", "tokio", "tracing", "wasm-bindgen", diff --git a/crates/alien-infra/src/build/gcp.rs b/crates/alien-infra/src/build/gcp.rs index 0c1ae31ec5..30626c183b 100644 --- a/crates/alien-infra/src/build/gcp.rs +++ b/crates/alien-infra/src/build/gcp.rs @@ -1,7 +1,9 @@ use std::{collections::HashMap, time::Duration}; use tracing::{debug, info}; -use crate::core::{EnvironmentVariableBuilder, ResourceControllerContext}; +use crate::core::{ + EnvironmentVariableBuilder, ResourceControllerContext, ResourcePermissionsHelper, +}; use crate::error::{ErrorData, Result}; use alien_core::{ Build, BuildHeartbeatData, BuildHeartbeatStatus, BuildOutputs, GcpCloudBuildHeartbeatData, @@ -287,24 +289,11 @@ impl GcpBuildController { &self, ctx: &ResourceControllerContext<'_>, ) -> Result<()> { - use alien_permissions::{generators::GcpRuntimePermissionsGenerator, PermissionContext}; + use alien_permissions::generators::GcpRuntimePermissionsGenerator; let config = ctx.desired_resource_config::()?; - let gcp_config = ctx.get_gcp_config()?; - - // Build permission context for this specific build resource - let mut permission_context = PermissionContext::new() - .with_project_name(gcp_config.project_id.clone()) - .with_region(gcp_config.region.clone()) - .with_stack_prefix(ctx.resource_prefix.to_string()) - .with_resource_name(config.id.clone()); - if let Some(deployment_name) = ctx.deployment_name_for_metadata() { - permission_context = - permission_context.with_deployment_name(deployment_name.to_string()); - } - if let Some(ref project_number) = gcp_config.project_number { - permission_context = permission_context.with_project_number(project_number.clone()); - } + let permission_context = + ResourcePermissionsHelper::build_gcp_permission_context(ctx, &config.id)?; let generator = GcpRuntimePermissionsGenerator::new(); diff --git a/crates/alien-infra/src/core/controller_test.rs b/crates/alien-infra/src/core/controller_test.rs index b1d9df41e1..587ac0de28 100644 --- a/crates/alien-infra/src/core/controller_test.rs +++ b/crates/alien-infra/src/core/controller_test.rs @@ -638,6 +638,7 @@ pub struct SingleControllerExecutorBuilder { client_config: Option, resource_lifecycle: ResourceLifecycle, initial_setup_authority: alien_core::InitialSetupAuthority, + resource_prefix: String, } impl SingleControllerExecutorBuilder { @@ -664,9 +665,16 @@ impl SingleControllerExecutorBuilder { client_config: None, resource_lifecycle: ResourceLifecycle::Live, initial_setup_authority: alien_core::InitialSetupAuthority::DirectSetup, + resource_prefix: "test".to_string(), } } + /// Sets the deployment's resource prefix. Defaults to `test`. + pub fn resource_prefix(mut self, resource_prefix: impl Into) -> Self { + self.resource_prefix = resource_prefix.into(); + self + } + /// Sets the authority the controller runs under. Direct setup's by default; an update or /// the runtime loop runs as `ImportedHandoff`. pub fn initial_setup_authority(mut self, authority: alien_core::InitialSetupAuthority) -> Self { @@ -1093,7 +1101,7 @@ impl SingleControllerExecutorBuilder { }; // Set resource prefix in stack state - stack_state.resource_prefix = "test".to_string(); + stack_state.resource_prefix = self.resource_prefix.clone(); // Apply mutations only (skip compile-time checks) to process the stack let preflight_runner = PreflightRunner::new(); @@ -1224,7 +1232,7 @@ impl SingleControllerExecutorBuilder { service_provider: self .service_provider .unwrap_or_else(|| Arc::new(DefaultPlatformServiceProvider::default())), - resource_prefix: "test".to_string(), + resource_prefix: self.resource_prefix, last_heartbeats: Vec::new(), initial_setup_authority: self.initial_setup_authority, }) diff --git a/crates/alien-infra/src/core/gcp_custom_role_naming.rs b/crates/alien-infra/src/core/gcp_custom_role_naming.rs new file mode 100644 index 0000000000..5d7f08480b --- /dev/null +++ b/crates/alien-infra/src/core/gcp_custom_role_naming.rs @@ -0,0 +1,281 @@ +//! How a GCP deployment's custom role IDs derive from its resource prefix. + +use alien_core::{Platform, RemoteStackManagement, ServiceAccount, StackState}; +use alien_error::{Context, IntoAlienError}; +use alien_permissions::generators::{ + custom_role_namespace_for_prefix, legacy_custom_role_namespace_for_prefix, +}; +use serde::{Deserialize, Serialize}; + +use crate::remote_stack_management::GcpRemoteStackManagementController; +use crate::service_account::GcpServiceAccountController; +use crate::{ErrorData, Result}; + +/// The rule that turns a resource prefix into the namespace of a deployment's +/// GCP custom role IDs (`role__`). +/// +/// The GCP identity controllers record it when they create their service +/// account, so every controller of the deployment keeps naming roles the way +/// they were created. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GcpCustomRoleNaming { + /// Prefixes longer than 18 characters keep 9 characters plus a hash, so + /// they stay distinct. Terraform setup names roles the same way. + HashedLongPrefix, + /// The prefix cut to 18 characters. Runtime setup named roles this way + /// before long prefixes were hashed; two long prefixes can share it. + TruncatedPrefix, +} + +impl GcpCustomRoleNaming { + /// The custom role namespace for `resource_prefix` under this rule. + pub fn namespace(self, resource_prefix: &str) -> String { + match self { + Self::HashedLongPrefix => custom_role_namespace_for_prefix(resource_prefix), + Self::TruncatedPrefix => legacy_custom_role_namespace_for_prefix(resource_prefix), + } + } + + /// The naming rule of the deployment whose stack state is `state`. + /// + /// A GCP service account created before the rule was recorded means the + /// deployment's roles were created with `TruncatedPrefix`; moving it to + /// the new rule would bind its service accounts to roles that do not exist. + /// Unreadable identity state must be reported before choosing role IDs. + pub fn for_deployment(state: &StackState) -> Result { + let mut resource_ids: Vec<&String> = state.resources.keys().collect(); + resource_ids.sort(); + + let mut created_before_recording = false; + let mut recorded_naming = None; + for resource_id in resource_ids { + let resource = &state.resources[resource_id]; + if resource + .controller_platform + .is_some_and(|platform| platform != Platform::Gcp) + { + continue; + } + let Some(internal_state) = &resource.internal_state else { + continue; + }; + let identity = if resource.resource_type == ServiceAccount::RESOURCE_TYPE.as_ref() { + let controller = GcpServiceAccountController::deserialize(internal_state) + .into_alien_error() + .context(ErrorData::ResourceStateSerializationFailed { + resource_id: resource_id.clone(), + message: + "Cannot determine GCP custom role naming from service account state" + .to_string(), + })?; + ( + controller.service_account_email, + controller.custom_role_naming, + ) + } else if resource.resource_type == RemoteStackManagement::RESOURCE_TYPE.as_ref() { + let controller = GcpRemoteStackManagementController::deserialize(internal_state) + .into_alien_error() + .context(ErrorData::ResourceStateSerializationFailed { + resource_id: resource_id.clone(), + message: + "Cannot determine GCP custom role naming from management identity state" + .to_string(), + })?; + ( + controller.service_account_email, + controller.custom_role_naming, + ) + } else { + continue; + }; + match identity { + (_, Some(naming)) => { + recorded_naming.get_or_insert(naming); + } + (Some(_), None) => created_before_recording = true, + _ => {} + } + } + + Ok(recorded_naming.unwrap_or(if created_before_recording { + Self::TruncatedPrefix + } else { + Self::HashedLongPrefix + })) + } +} + +#[cfg(test)] +mod tests { + use alien_core::{Resource, ResourceStatus, StackResourceState}; + + use super::*; + use crate::core::serialize_controller; + use crate::service_account::AwsServiceAccountController; + + const LONG_PREFIX: &str = "customer-acme-prod-eu"; + + fn service_account( + email: Option<&str>, + naming: Option, + ) -> StackResourceState { + let controller = GcpServiceAccountController { + service_account_email: email.map(str::to_string), + custom_role_naming: naming, + ..Default::default() + }; + let mut state = StackResourceState::new_pending( + ServiceAccount::RESOURCE_TYPE.to_string(), + Resource::new(ServiceAccount::new("runtime-sa".to_string()).build()), + None, + vec![], + ); + state.status = ResourceStatus::Running; + state.internal_state = Some(serialize_controller(&controller).unwrap()); + state + } + + /// The stored state of a service account created before the naming rule + /// was recorded: the same JSON without the field. + fn service_account_created_before_recording() -> StackResourceState { + let mut state = service_account(Some("sa@p.iam.gserviceaccount.com"), None); + let internal_state = state.internal_state.as_mut().unwrap(); + internal_state + .as_object_mut() + .unwrap() + .remove("customRoleNaming"); + assert!(internal_state.get("serviceAccountEmail").is_some()); + state + } + + fn deployment(resources: Vec<(&str, StackResourceState)>) -> StackState { + let mut state = StackState::with_resource_prefix(Platform::Gcp, LONG_PREFIX.to_string()); + for (id, resource) in resources { + state.resources.insert(id.to_string(), resource); + } + state + } + + #[test] + fn new_deployment_hashes_long_prefixes() { + let state = deployment(vec![("runtime-sa", service_account(None, None))]); + + let naming = + GcpCustomRoleNaming::for_deployment(&state).expect("identity state is readable"); + + assert_eq!(naming, GcpCustomRoleNaming::HashedLongPrefix); + assert_eq!(naming.namespace(LONG_PREFIX), "customer__2e6bb8cf"); + } + + #[test] + fn deployment_created_before_recording_keeps_its_truncated_role_ids() { + let state = deployment(vec![( + "runtime-sa", + service_account_created_before_recording(), + )]); + + let naming = + GcpCustomRoleNaming::for_deployment(&state).expect("identity state is readable"); + + assert_eq!(naming, GcpCustomRoleNaming::TruncatedPrefix); + assert_eq!(naming.namespace(LONG_PREFIX), "customer_acme_prod"); + } + + #[test] + fn recorded_naming_wins_over_service_accounts_without_a_record() { + for recorded in [ + GcpCustomRoleNaming::HashedLongPrefix, + GcpCustomRoleNaming::TruncatedPrefix, + ] { + let state = deployment(vec![ + ("a-sa", service_account_created_before_recording()), + ( + "b-sa", + service_account(Some("b@p.iam.gserviceaccount.com"), Some(recorded)), + ), + ]); + + assert_eq!( + GcpCustomRoleNaming::for_deployment(&state).expect("identity state is readable"), + recorded + ); + } + } + + #[test] + fn unreadable_service_account_state_does_not_select_a_new_namespace() { + let mut identity = service_account_created_before_recording(); + identity.internal_state.as_mut().unwrap()["serviceAccountEmail"] = serde_json::json!(123); + let state = deployment(vec![("runtime-sa", identity)]); + + let error = GcpCustomRoleNaming::for_deployment(&state) + .expect_err("unreadable identity state cannot establish role naming"); + + assert_eq!(error.code, "RESOURCE_STATE_SERIALIZATION_FAILED"); + assert!(!error.retryable); + assert!(error.message.contains("runtime-sa")); + assert!(error.source.is_some()); + } + + #[test] + fn unreadable_management_identity_state_is_reported() { + let controller = GcpRemoteStackManagementController { + service_account_email: Some("management@p.iam.gserviceaccount.com".to_string()), + ..Default::default() + }; + let mut identity = StackResourceState::new_pending( + RemoteStackManagement::RESOURCE_TYPE.to_string(), + Resource::new(RemoteStackManagement::new("management".to_string()).build()), + None, + vec![], + ); + let mut internal_state = serialize_controller(&controller).unwrap(); + internal_state["customRoleNaming"] = serde_json::json!("unknownNaming"); + identity.internal_state = Some(internal_state); + let state = deployment(vec![("management", identity)]); + + let error = GcpCustomRoleNaming::for_deployment(&state) + .expect_err("an unknown naming rule cannot be replaced with a default"); + + assert_eq!(error.code, "RESOURCE_STATE_SERIALIZATION_FAILED"); + assert!(!error.retryable); + assert!(error.message.contains("management")); + assert!(error.source.is_some()); + } + + #[test] + fn recorded_naming_does_not_hide_another_unreadable_identity() { + let recorded = service_account( + Some("a@p.iam.gserviceaccount.com"), + Some(GcpCustomRoleNaming::HashedLongPrefix), + ); + let mut unreadable = service_account_created_before_recording(); + unreadable.internal_state.as_mut().unwrap()["serviceAccountEmail"] = serde_json::json!(123); + let state = deployment(vec![("a-recorded", recorded), ("z-unreadable", unreadable)]); + + let error = GcpCustomRoleNaming::for_deployment(&state) + .expect_err("all existing identity states must be readable"); + + assert_eq!(error.code, "RESOURCE_STATE_SERIALIZATION_FAILED"); + assert!(error.message.contains("z-unreadable")); + } + + #[test] + fn identities_owned_by_other_platforms_do_not_determine_gcp_naming() { + let mut internal_state = + serialize_controller(&AwsServiceAccountController::default()).unwrap(); + internal_state["state"] = serde_json::json!("creatingRole"); + AwsServiceAccountController::deserialize(&internal_state) + .expect("the stored state is valid for its owning platform"); + let mut identity = service_account(None, None); + identity.controller_platform = Some(Platform::Aws); + identity.internal_state = Some(internal_state); + let state = deployment(vec![("aws-identity", identity)]); + + let naming = GcpCustomRoleNaming::for_deployment(&state) + .expect("another platform's controller state is not a GCP identity"); + + assert_eq!(naming, GcpCustomRoleNaming::HashedLongPrefix); + } +} diff --git a/crates/alien-infra/src/core/mod.rs b/crates/alien-infra/src/core/mod.rs index ba4a6f3919..e78cbb0e45 100644 --- a/crates/alien-infra/src/core/mod.rs +++ b/crates/alien-infra/src/core/mod.rs @@ -38,6 +38,9 @@ pub use azure_permissions_helper::*; mod resource_permissions_helper; pub use resource_permissions_helper::*; +mod gcp_custom_role_naming; +pub use gcp_custom_role_naming::GcpCustomRoleNaming; + use std::collections::BTreeMap; use alien_core::{ diff --git a/crates/alien-infra/src/core/resource_permissions_helper.rs b/crates/alien-infra/src/core/resource_permissions_helper.rs index aa7fa4df8f..56ded9021d 100644 --- a/crates/alien-infra/src/core/resource_permissions_helper.rs +++ b/crates/alien-infra/src/core/resource_permissions_helper.rs @@ -5,7 +5,10 @@ use std::collections::HashSet; -use crate::core::{azure_permissions_helper::AzurePermissionsHelper, ResourceControllerContext}; +use crate::core::{ + azure_permissions_helper::AzurePermissionsHelper, GcpCustomRoleNaming, + ResourceControllerContext, +}; use crate::error::{ErrorData, Result}; use alien_azure_clients::authorization::Scope; use alien_client_core::ErrorData as CloudClientErrorData; @@ -92,6 +95,7 @@ impl ResourcePermissionsHelper { let mut permission_context = PermissionContext::new() .with_stack_prefix(ctx.resource_prefix.to_string()) + .with_gcp_custom_role_namespace(Self::gcp_custom_role_namespace(ctx)?) .with_project_name(project_id) .with_region(region) .with_resource_name(Self::kubernetes_cluster_name_for_permissions( @@ -412,21 +416,30 @@ impl ResourcePermissionsHelper { Ok(()) } - /// Setup-delete: delete the GCP custom roles generated for the selected permission sets. + /// Setup-delete: delete every GCP custom role this deployment created. + /// + /// A role belongs to this deployment when its ID is in one of the + /// deployment's namespaces and its description names the deployment's + /// resource prefix. The ID alone does not prove it: `role_acme_` also + /// starts every role of a deployment with prefix `acme-prod`. Listing the + /// project's roles, rather than regenerating IDs from the current stack, + /// also finds the roles of permission sets an earlier update removed. /// /// Project IAM/resource IAM bindings must be removed before this runs. Missing /// roles are tolerated so delete stays idempotent. - pub async fn delete_gcp_custom_roles( - ctx: &ResourceControllerContext<'_>, - permission_context: &PermissionContext, - ) -> Result<()> { + pub async fn delete_gcp_custom_roles(ctx: &ResourceControllerContext<'_>) -> Result<()> { let gcp_config = ctx.get_gcp_config()?; let iam_client = ctx.service_provider.get_gcp_iam_client(gcp_config)?; - let role_name_prefix = format!( - "projects/{}/roles/{}", - gcp_config.project_id, - custom_role_prefix(permission_context) - ); + let mut namespaces = vec![ + GcpCustomRoleNaming::HashedLongPrefix.namespace(ctx.resource_prefix), + GcpCustomRoleNaming::TruncatedPrefix.namespace(ctx.resource_prefix), + ]; + namespaces.sort(); + namespaces.dedup(); + let role_name_prefixes: Vec = namespaces + .iter() + .map(|namespace| format!("projects/{}/roles/role_{namespace}_", gcp_config.project_id)) + .collect(); let mut role_names = Vec::new(); let mut page_token = None; @@ -443,7 +456,21 @@ impl ResourcePermissionsHelper { let Some(role_name) = role.name else { continue; }; - if role_name.starts_with(&role_name_prefix) { + if !role_name_prefixes + .iter() + .any(|prefix| role_name.starts_with(prefix)) + { + continue; + } + let Some(description) = role.description else { + warn!( + role_name = %role_name, + resource_prefix = %ctx.resource_prefix, + "Skipping GCP custom role without a description; ownership cannot be verified" + ); + continue; + }; + if custom_role_description_names_prefix(&description, ctx.resource_prefix) { role_names.push(role_name); } } @@ -866,6 +893,13 @@ impl ResourcePermissionsHelper { pub(crate) fn build_gcp_permission_context( ctx: &ResourceControllerContext<'_>, resource_name: &str, + ) -> Result { + Ok(Self::gcp_permission_context(ctx)?.with_resource_name(resource_name.to_string())) + } + + /// Build the deployment-wide GCP permission context. + pub(crate) fn gcp_permission_context( + ctx: &ResourceControllerContext<'_>, ) -> Result { let gcp_config = ctx.get_gcp_config()?; @@ -873,7 +907,7 @@ impl ResourcePermissionsHelper { .with_project_name(gcp_config.project_id.clone()) .with_region(gcp_config.region.clone()) .with_stack_prefix(ctx.resource_prefix.to_string()) - .with_resource_name(resource_name.to_string()); + .with_gcp_custom_role_namespace(Self::gcp_custom_role_namespace(ctx)?); if let Some(deployment_name) = ctx.deployment_name_for_metadata() { permission_ctx = permission_ctx.with_deployment_name(deployment_name.to_string()); } @@ -883,6 +917,11 @@ impl ResourcePermissionsHelper { Ok(permission_ctx) } + /// Return the namespace of this deployment's GCP custom role IDs. + pub fn gcp_custom_role_namespace(ctx: &ResourceControllerContext<'_>) -> Result { + Ok(GcpCustomRoleNaming::for_deployment(ctx.state)?.namespace(ctx.resource_prefix)) + } + /// Process GCP permissions for a specific profile async fn process_gcp_profile_permissions( ctx: &ResourceControllerContext<'_>, diff --git a/crates/alien-infra/src/remote_stack_management/gcp.rs b/crates/alien-infra/src/remote_stack_management/gcp.rs index 2a91a9697e..dd30b3f018 100644 --- a/crates/alien-infra/src/remote_stack_management/gcp.rs +++ b/crates/alien-infra/src/remote_stack_management/gcp.rs @@ -1,7 +1,7 @@ use std::time::Duration; use tracing::info; -use crate::core::{ResourceControllerContext, ResourcePermissionsHelper}; +use crate::core::{GcpCustomRoleNaming, ResourceControllerContext, ResourcePermissionsHelper}; use crate::error::{ErrorData, Result}; use alien_core::permissions::PermissionSet; use alien_core::{ @@ -39,6 +39,9 @@ pub struct GcpRemoteStackManagementController { pub(crate) role_bound: bool, /// Whether impersonation permissions have been granted pub(crate) impersonation_granted: bool, + /// How the deployment names its custom roles, recorded with the service + /// account. `None` for a service account created before it was recorded. + pub(crate) custom_role_naming: Option, } #[controller] @@ -56,6 +59,7 @@ impl GcpRemoteStackManagementController { ctx: &ResourceControllerContext<'_>, ) -> Result { let config = ctx.desired_resource_config::()?; + let custom_role_naming = GcpCustomRoleNaming::for_deployment(ctx.state)?; let gcp_config = ctx.get_gcp_config()?; let client = ctx.service_provider.get_gcp_iam_client(gcp_config)?; @@ -124,6 +128,7 @@ impl GcpRemoteStackManagementController { self.service_account_email = Some(email); self.service_account_unique_id = Some(unique_id); + self.custom_role_naming = Some(custom_role_naming); Ok(HandlerAction::Continue { state: BindingRole, @@ -182,18 +187,8 @@ impl GcpRemoteStackManagementController { .next() .unwrap_or(service_account_email); - let mut permission_context = PermissionContext::new() - .with_stack_prefix(ctx.resource_prefix.to_string()) - .with_project_name(gcp_config.project_id.clone()) - .with_region(gcp_config.region.clone()) + let permission_context = ResourcePermissionsHelper::gcp_permission_context(ctx)? .with_service_account_name(service_account_id.to_string()); - if let Some(deployment_name) = ctx.deployment_name_for_metadata() { - permission_context = - permission_context.with_deployment_name(deployment_name.to_string()); - } - if let Some(ref project_number) = gcp_config.project_number { - permission_context = permission_context.with_project_number(project_number.clone()); - } let mut new_bindings = Vec::new(); @@ -540,10 +535,7 @@ impl GcpRemoteStackManagementController { &mut self, ctx: &ResourceControllerContext<'_>, ) -> Result { - let permission_context = - ResourcePermissionsHelper::build_gcp_permission_context(ctx, ctx.resource_prefix)?; - - ResourcePermissionsHelper::delete_gcp_custom_roles(ctx, &permission_context).await?; + ResourcePermissionsHelper::delete_gcp_custom_roles(ctx).await?; Ok(HandlerAction::Continue { state: DeletingServiceAccount, @@ -830,6 +822,7 @@ impl GcpRemoteStackManagementController { service_account_unique_id: Some("123456789012345678901".to_string()), role_bound: true, impersonation_granted: true, + custom_role_naming: Some(GcpCustomRoleNaming::HashedLongPrefix), _internal_stay_count: None, } } @@ -837,7 +830,254 @@ impl GcpRemoteStackManagementController { #[cfg(test)] mod tests { + use std::sync::{Arc, Mutex}; + use super::*; + use crate::core::{controller_test::SingleControllerExecutor, MockPlatformServiceProvider}; + use alien_gcp_clients::iam::{ListRolesResponse, MockIamApi, Role}; + use alien_gcp_clients::resource_manager::MockResourceManagerApi; + + const PROJECT: &str = "test-project-123"; + + /// The custom roles a deployment with `context` creates for `permission_set_ids`, + /// exactly as the runtime generator names and describes them. + fn deployment_roles(context: &PermissionContext, permission_set_ids: &[&str]) -> Vec { + let generator = GcpRuntimePermissionsGenerator::new(); + permission_set_ids + .iter() + .flat_map(|id| { + let permission_set = get_permission_set(id).expect("permission set exists"); + generator + .generate_custom_roles(permission_set, context) + .expect("custom roles generate") + }) + .map(|role| Role { + name: Some(role.name), + title: Some(role.title), + description: Some(role.description), + included_permissions: Vec::new(), + stage: None, + etag: None, + deleted: None, + }) + .collect() + } + + fn deployment_context(resource_prefix: &str) -> PermissionContext { + PermissionContext::new() + .with_stack_prefix(resource_prefix.to_string()) + .with_project_name(PROJECT.to_string()) + .with_deployment_name(format!("{resource_prefix} deployment")) + } + + fn role_names(roles: &[Role]) -> Vec { + let mut names: Vec = roles.iter().filter_map(|r| r.name.clone()).collect(); + names.sort(); + names + } + + /// Run the delete flow of a ready management controller for `resource_prefix` + /// in a project whose custom roles are `project_roles`, and return the role + /// names it deleted. + async fn delete_management_roles( + resource_prefix: &str, + controller: GcpRemoteStackManagementController, + project_roles: Vec, + ) -> Vec { + let deleted = Arc::new(Mutex::new(Vec::new())); + + let mut iam = MockIamApi::new(); + let (first_page, second_page) = project_roles.split_at(project_roles.len() / 2); + let (first_page, second_page) = (first_page.to_vec(), second_page.to_vec()); + iam.expect_list_roles() + .returning(move |_, page_token, show_deleted| { + assert_eq!(show_deleted, Some(false)); + Ok(match page_token.as_deref() { + None => ListRolesResponse { + roles: first_page.clone(), + next_page_token: Some("page-2".to_string()), + }, + Some("page-2") => ListRolesResponse { + roles: second_page.clone(), + next_page_token: None, + }, + Some(other) => panic!("unexpected page token {other}"), + }) + }); + let deleted_by_iam = deleted.clone(); + iam.expect_delete_role().returning(move |name| { + deleted_by_iam.lock().unwrap().push(name.clone()); + Ok(Role { + name: Some(name), + title: None, + description: None, + included_permissions: Vec::new(), + stage: None, + etag: None, + deleted: Some(true), + }) + }); + iam.expect_delete_service_account().returning(|_| Ok(())); + let iam = Arc::new(iam); + + let mut resource_manager = MockResourceManagerApi::new(); + resource_manager + .expect_get_project_iam_policy() + .returning(|_, _| Ok(IamPolicy::builder().bindings(Vec::new()).build())); + resource_manager + .expect_set_project_iam_policy() + .returning(|_, policy, _| Ok(policy)); + let resource_manager = Arc::new(resource_manager); + + let mut provider = MockPlatformServiceProvider::new(); + provider + .expect_get_gcp_iam_client() + .returning(move |_| Ok(iam.clone())); + provider + .expect_get_gcp_resource_manager_client() + .returning(move |_| Ok(resource_manager.clone())); + + let mut executor = SingleControllerExecutor::builder() + .resource(RemoteStackManagement::new("management".to_string()).build()) + .controller(controller) + .platform(Platform::Gcp) + .resource_prefix(resource_prefix) + .service_provider(Arc::new(provider)) + .build() + .await + .expect("executor builds"); + + executor.delete().expect("delete starts"); + executor + .run_until_terminal() + .await + .expect("delete runs to a terminal state"); + assert_eq!(executor.status(), ResourceStatus::Deleted); + + let mut deleted = deleted.lock().unwrap().clone(); + deleted.sort(); + deleted + } + + fn ready_management_controller(resource_prefix: &str) -> GcpRemoteStackManagementController { + GcpRemoteStackManagementController { + state: GcpRemoteStackManagementState::Ready, + service_account_email: Some(format!( + "{resource_prefix}-management@{PROJECT}.iam.gserviceaccount.com" + )), + service_account_unique_id: Some("123456789012345678901".to_string()), + role_bound: true, + impersonation_granted: true, + ..Default::default() + } + } + + #[tokio::test] + async fn deleting_a_deployment_keeps_roles_of_a_deployment_whose_prefix_extends_it() { + let acme = deployment_roles( + &deployment_context("acme"), + // `build/management` stands for a permission set removed from the + // stack by an earlier update: its role must still be cleaned up. + &["storage/data-read", "worker/provision", "build/management"], + ); + let acme_prod = deployment_roles( + &deployment_context("acme-prod"), + &["storage/data-read", "worker/provision"], + ); + let mut project_roles = acme_prod.clone(); + project_roles.extend(acme.clone()); + + let deleted = + delete_management_roles("acme", ready_management_controller("acme"), project_roles) + .await; + + assert_eq!(deleted, role_names(&acme)); + for role in role_names(&acme_prod) { + assert!( + !deleted.contains(&role), + "deleting acme must keep acme-prod's role {role}" + ); + } + } + + #[tokio::test] + async fn deleting_a_deployment_keeps_roles_without_matching_ownership() { + let own = deployment_roles(&deployment_context("acme"), &["storage/data-read"]); + let mut unverified = deployment_roles( + &deployment_context("acme"), + &["worker/provision", "build/management"], + ); + assert!(unverified.len() >= 2); + unverified[0].description = None; + for role in &mut unverified[1..] { + role.description = Some("Description edited outside setup".to_string()); + } + let mut project_roles = own.clone(); + project_roles.extend(unverified.clone()); + + let deleted = + delete_management_roles("acme", ready_management_controller("acme"), project_roles) + .await; + + assert_eq!(deleted, role_names(&own)); + for role in role_names(&unverified) { + assert!( + !deleted.contains(&role), + "ownership is unverified for {role}" + ); + } + } + + /// A long-prefix deployment set up before the naming rule was recorded + /// still has roles under the prefix cut to 18 characters. Deleting it must + /// remove those, and keep the roles of the deployment whose whole prefix + /// is those 18 characters. + #[tokio::test] + async fn deleting_a_deployment_set_up_before_hashed_names_removes_its_truncated_roles() { + let prefix = "customer-acme-prod-eu"; + let legacy = deployment_roles( + &deployment_context(prefix).with_gcp_custom_role_namespace("customer_acme_prod"), + &["storage/data-read", "worker/provision"], + ); + let neighbour = deployment_roles( + &deployment_context("customer-acme-prod"), + &["build/management"], + ); + assert!(role_names(&neighbour)[0].starts_with(&format!( + "projects/{PROJECT}/roles/role_customer_acme_prod_" + ))); + let mut project_roles = legacy.clone(); + project_roles.extend(neighbour.clone()); + + let controller = GcpRemoteStackManagementController { + custom_role_naming: None, + ..ready_management_controller(prefix) + }; + let deleted = delete_management_roles(prefix, controller, project_roles).await; + + assert_eq!(deleted, role_names(&legacy)); + } + + #[tokio::test] + async fn deleting_a_long_prefix_deployment_removes_its_hashed_roles() { + let prefix = "customer-acme-prod-eu"; + let own = deployment_roles(&deployment_context(prefix), &["storage/data-read"]); + assert!(role_names(&own)[0].contains("/roles/role_customer__2e6bb8cf_")); + let sibling = deployment_roles( + &deployment_context("customer-acme-prod-us"), + &["storage/data-read"], + ); + let mut project_roles = own.clone(); + project_roles.extend(sibling.clone()); + + let controller = GcpRemoteStackManagementController { + custom_role_naming: Some(GcpCustomRoleNaming::HashedLongPrefix), + ..ready_management_controller(prefix) + }; + let deleted = delete_management_roles(prefix, controller, project_roles).await; + + assert_eq!(deleted, role_names(&own)); + } fn test_permission_context() -> PermissionContext { PermissionContext::new() diff --git a/crates/alien-infra/src/remote_stack_management/gcp_import.rs b/crates/alien-infra/src/remote_stack_management/gcp_import.rs index ce337a83cb..f33602a6a2 100644 --- a/crates/alien-infra/src/remote_stack_management/gcp_import.rs +++ b/crates/alien-infra/src/remote_stack_management/gcp_import.rs @@ -5,6 +5,7 @@ use alien_core::{ Result, StackResourceState, }; +use crate::core::GcpCustomRoleNaming; use crate::import::ResourceImporter; use crate::import_helpers::make_imported_state; use crate::remote_stack_management::{ @@ -30,6 +31,7 @@ impl ResourceImporter for GcpRemoteStackManagementImporter { service_account_unique_id: Some(data.service_account_unique_id), role_bound: data.management_permissions_applied, impersonation_granted: data.management_permissions_applied, + custom_role_naming: Some(GcpCustomRoleNaming::HashedLongPrefix), _internal_stay_count: None, }; make_imported_state(controller, ctx) diff --git a/crates/alien-infra/src/service_account/gcp.rs b/crates/alien-infra/src/service_account/gcp.rs index f4660e2b21..2fb152b47c 100644 --- a/crates/alien-infra/src/service_account/gcp.rs +++ b/crates/alien-infra/src/service_account/gcp.rs @@ -1,7 +1,7 @@ use std::time::Duration; use tracing::info; -use crate::core::{ResourceControllerContext, ResourcePermissionsHelper}; +use crate::core::{GcpCustomRoleNaming, ResourceControllerContext, ResourcePermissionsHelper}; use crate::error::{ErrorData, Result}; use alien_core::{ permissions::PermissionSetReference, GcpServiceAccountHeartbeatData, HeartbeatBackend, @@ -31,6 +31,9 @@ pub struct GcpServiceAccountController { pub service_account_email: Option, /// The unique ID of the created service account. pub(crate) service_account_unique_id: Option, + /// How the deployment names its custom roles, recorded with the service + /// account. `None` for a service account created before it was recorded. + pub(crate) custom_role_naming: Option, } #[controller] @@ -48,6 +51,7 @@ impl GcpServiceAccountController { ctx: &ResourceControllerContext<'_>, ) -> Result { let config = ctx.desired_resource_config::()?; + let custom_role_naming = GcpCustomRoleNaming::for_deployment(ctx.state)?; let gcp_config = ctx.get_gcp_config()?; let client = ctx.service_provider.get_gcp_iam_client(gcp_config)?; @@ -116,6 +120,7 @@ impl GcpServiceAccountController { self.service_account_email = Some(email); self.service_account_unique_id = Some(unique_id); + self.custom_role_naming = Some(custom_role_naming); Ok(HandlerAction::Continue { state: BindingStackRoles, @@ -417,18 +422,8 @@ impl GcpServiceAccountController { .next() .unwrap_or(service_account_email); - let mut permission_context = PermissionContext::new() - .with_stack_prefix(ctx.resource_prefix.to_string()) - .with_project_name(gcp_config.project_id.clone()) - .with_region(gcp_config.region.clone()) + let permission_context = ResourcePermissionsHelper::gcp_permission_context(ctx)? .with_service_account_name(service_account_id.to_string()); - if let Some(deployment_name) = ctx.deployment_name_for_metadata() { - permission_context = - permission_context.with_deployment_name(deployment_name.to_string()); - } - if let Some(ref project_number) = gcp_config.project_number { - permission_context = permission_context.with_project_number(project_number.clone()); - } let mut new_bindings = Vec::new(); @@ -732,6 +727,7 @@ impl GcpServiceAccountController { role_name )), service_account_unique_id: Some("123456789012345678901".to_string()), + custom_role_naming: Some(GcpCustomRoleNaming::HashedLongPrefix), _internal_stay_count: None, } } diff --git a/crates/alien-infra/src/service_account/gcp_import.rs b/crates/alien-infra/src/service_account/gcp_import.rs index b91b53c650..2fe0febf5f 100644 --- a/crates/alien-infra/src/service_account/gcp_import.rs +++ b/crates/alien-infra/src/service_account/gcp_import.rs @@ -5,6 +5,7 @@ use alien_core::{ Result, StackResourceState, }; +use crate::core::GcpCustomRoleNaming; use crate::import::ResourceImporter; use crate::import_helpers::make_imported_state; use crate::service_account::GcpServiceAccountController; @@ -29,6 +30,7 @@ impl ResourceImporter for GcpServiceAccountImporter { state: GcpServiceAccountState::Ready, service_account_email: Some(data.service_account_email), service_account_unique_id: Some(data.service_account_unique_id), + custom_role_naming: Some(GcpCustomRoleNaming::HashedLongPrefix), _internal_stay_count: None, }; make_imported_state(controller, ctx) diff --git a/crates/alien-infra/src/vault/gcp.rs b/crates/alien-infra/src/vault/gcp.rs index c20f05147f..b05882df75 100644 --- a/crates/alien-infra/src/vault/gcp.rs +++ b/crates/alien-infra/src/vault/gcp.rs @@ -295,6 +295,9 @@ impl GcpVaultController { .with_project_name(gcp_config.project_id.clone()) .with_region(gcp_config.region.clone()) .with_stack_prefix(ctx.resource_prefix.to_string()) + .with_gcp_custom_role_namespace(ResourcePermissionsHelper::gcp_custom_role_namespace( + ctx, + )?) .with_resource_name(vault_prefix.to_string()); if let Some(deployment_name) = ctx.deployment_name_for_metadata() { permission_context = diff --git a/crates/alien-infra/src/worker/gcp.rs b/crates/alien-infra/src/worker/gcp.rs index 43cb413ee3..22d2bbd970 100644 --- a/crates/alien-infra/src/worker/gcp.rs +++ b/crates/alien-infra/src/worker/gcp.rs @@ -4280,18 +4280,8 @@ impl GcpWorkerController { .collect(); let gcp_config = ctx.get_gcp_config()?; - let mut permission_context = alien_permissions::PermissionContext::new() - .with_project_name(gcp_config.project_id.clone()) - .with_region(gcp_config.region.clone()) - .with_stack_prefix(ctx.resource_prefix.to_string()) - .with_resource_name(topic_name.to_string()); - if let Some(deployment_name) = ctx.deployment_name_for_metadata() { - permission_context = - permission_context.with_deployment_name(deployment_name.to_string()); - } - if let Some(ref project_number) = gcp_config.project_number { - permission_context = permission_context.with_project_number(project_number.clone()); - } + let permission_context = + ResourcePermissionsHelper::build_gcp_permission_context(ctx, topic_name)?; let generator = alien_permissions::generators::GcpRuntimePermissionsGenerator::new(); let mut all_bindings = Vec::new(); @@ -4801,24 +4791,11 @@ impl GcpWorkerController { service_name: &str, all_bindings: &mut Vec, ) -> Result<()> { - use alien_permissions::{generators::GcpRuntimePermissionsGenerator, PermissionContext}; + use alien_permissions::generators::GcpRuntimePermissionsGenerator; let config = ctx.desired_resource_config::()?; - let gcp_config = ctx.get_gcp_config()?; - - // Build permission context for this specific worker resource - let mut permission_context = PermissionContext::new() - .with_project_name(gcp_config.project_id.clone()) - .with_region(gcp_config.region.clone()) - .with_stack_prefix(ctx.resource_prefix.to_string()) - .with_resource_name(service_name.to_string()); - if let Some(deployment_name) = ctx.deployment_name_for_metadata() { - permission_context = - permission_context.with_deployment_name(deployment_name.to_string()); - } - if let Some(ref project_number) = gcp_config.project_number { - permission_context = permission_context.with_project_number(project_number.clone()); - } + let permission_context = + ResourcePermissionsHelper::build_gcp_permission_context(ctx, service_name)?; let generator = GcpRuntimePermissionsGenerator::new(); let type_prefix = "worker/"; diff --git a/crates/alien-permissions/Cargo.toml b/crates/alien-permissions/Cargo.toml index fb251d09f6..22e10f7e16 100644 --- a/crates/alien-permissions/Cargo.toml +++ b/crates/alien-permissions/Cargo.toml @@ -8,7 +8,7 @@ license-file.workspace = true [features] default = ["infrastructure"] -infrastructure = ["dep:alien-core", "dep:regex", "dep:json5", "dep:tracing"] +infrastructure = ["dep:alien-core", "dep:regex", "dep:json5", "dep:tracing", "dep:sha2"] wasm = ["dep:wasm-bindgen"] [dependencies] @@ -22,6 +22,7 @@ indexmap = { workspace = true, features = ["serde"] } tracing = { workspace = true, optional = true } regex = { workspace = true, optional = true } json5 = { version = "0.4", optional = true } +sha2 = { workspace = true, optional = true } [build-dependencies] json5 = "0.4" diff --git a/crates/alien-permissions/src/generators/gcp_runtime.rs b/crates/alien-permissions/src/generators/gcp_runtime.rs index fa05ee95b4..c3f0302172 100644 --- a/crates/alien-permissions/src/generators/gcp_runtime.rs +++ b/crates/alien-permissions/src/generators/gcp_runtime.rs @@ -8,8 +8,10 @@ use crate::{ }; use alien_core::{GcpBindingSpec, PermissionGrant, PermissionSet}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; const GCP_CUSTOM_ROLE_ID_MAX_LEN: usize = 64; +const CUSTOM_ROLE_NAMESPACE_MAX_LEN: usize = 18; const ROLE_ID_HASH_LEN: usize = 8; /// GCP IAM binding condition. @@ -509,7 +511,10 @@ fn generate_role_id( } } -/// Return the project custom-role prefix for all roles owned by this stack. +/// Return the project custom-role ID prefix of this stack's roles. +/// +/// Another deployment's namespace can extend this one (`acme` and +/// `acme_prod`), so the prefix never proves which deployment owns a role. pub fn custom_role_prefix(context: &PermissionContext) -> String { format!("role_{}_", custom_role_namespace(context)) } @@ -524,7 +529,43 @@ pub fn custom_role_permission_set_prefix( } fn custom_role_namespace(context: &PermissionContext) -> String { - sanitize_role_segment(context.stack_prefix.as_deref().unwrap_or("stack"), 18) + match &context.gcp_custom_role_namespace { + Some(namespace) => namespace.clone(), + None => { + custom_role_namespace_for_prefix(context.stack_prefix.as_deref().unwrap_or("stack")) + } + } +} + +/// Return the custom-role namespace for a resource prefix. +/// +/// A prefix of up to 18 characters is used as is. A longer one keeps its first +/// 9 characters and appends the first 8 hex characters of its SHA-256, so +/// prefixes that share a beginning still get distinct role IDs. This is the +/// `gcp_custom_role_prefix` local of the generated Terraform module, so both +/// setup paths name a deployment's roles identically. +pub fn custom_role_namespace_for_prefix(resource_prefix: &str) -> String { + let segment = sanitize_role_segment(resource_prefix, usize::MAX); + if segment.len() <= CUSTOM_ROLE_NAMESPACE_MAX_LEN { + return segment; + } + let digest = Sha256::digest(resource_prefix.as_bytes()); + let hash: String = digest[..4].iter().map(|b| format!("{b:02x}")).collect(); + format!("{}_{hash}", &segment[..9]) +} + +/// Return the namespace runtime setup used before long prefixes were hashed: +/// the prefix cut to 18 characters, which two long prefixes can share. +pub fn legacy_custom_role_namespace_for_prefix(resource_prefix: &str) -> String { + sanitize_role_segment(resource_prefix, CUSTOM_ROLE_NAMESPACE_MAX_LEN) +} + +/// Whether a custom role's description names `resource_prefix` as its owner. +/// +/// Every role generated for a deployment ends its description with +/// `Resource prefix: .`, both at runtime and from Terraform. +pub fn custom_role_description_names_prefix(description: &str, resource_prefix: &str) -> bool { + description.ends_with(&format!("Resource prefix: {resource_prefix}.")) } fn custom_role_title( diff --git a/crates/alien-permissions/src/generators/mod.rs b/crates/alien-permissions/src/generators/mod.rs index ae00dc7b56..f9572afbe1 100644 --- a/crates/alien-permissions/src/generators/mod.rs +++ b/crates/alien-permissions/src/generators/mod.rs @@ -17,7 +17,8 @@ pub use azure_runtime::{ AzureRuntimePermissionsGenerator, }; pub use gcp_runtime::{ - custom_role_permission_set_prefix, custom_role_prefix, GcpBindingResourceKind, - GcpBindingTargetScope, GcpCustomRole, GcpGrantPlan, GcpIamBinding, GcpIamBindings, - GcpIamCondition, GcpRuntimePermissionsGenerator, + custom_role_description_names_prefix, custom_role_namespace_for_prefix, + custom_role_permission_set_prefix, custom_role_prefix, legacy_custom_role_namespace_for_prefix, + GcpBindingResourceKind, GcpBindingTargetScope, GcpCustomRole, GcpGrantPlan, GcpIamBinding, + GcpIamBindings, GcpIamCondition, GcpRuntimePermissionsGenerator, }; diff --git a/crates/alien-permissions/src/infrastructure.rs b/crates/alien-permissions/src/infrastructure.rs index 4e9a49b0d8..9b5baee616 100644 --- a/crates/alien-permissions/src/infrastructure.rs +++ b/crates/alien-permissions/src/infrastructure.rs @@ -37,6 +37,10 @@ pub struct PermissionContext { pub external_id: Option, pub managing_role_arn: Option, pub managing_account_id: Option, + + /// GCP custom-role namespace to use instead of the one derived from + /// `stack_prefix`, for deployments whose roles already use another one. + pub gcp_custom_role_namespace: Option, } impl PermissionContext { @@ -64,6 +68,7 @@ impl PermissionContext { external_id: None, managing_role_arn: None, managing_account_id: None, + gcp_custom_role_namespace: None, } } @@ -193,6 +198,12 @@ impl PermissionContext { self } + /// Builder pattern for the GCP custom-role namespace override + pub fn with_gcp_custom_role_namespace(mut self, namespace: impl Into) -> Self { + self.gcp_custom_role_namespace = Some(namespace.into()); + self + } + /// Extract AWS account ID from an IAM role ARN /// Format: arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME pub fn extract_account_id_from_role_arn(role_arn: &str) -> Option { diff --git a/crates/alien-permissions/tests/gcp_runtime.rs b/crates/alien-permissions/tests/gcp_runtime.rs index b164f26b29..0943e0483a 100644 --- a/crates/alien-permissions/tests/gcp_runtime.rs +++ b/crates/alien-permissions/tests/gcp_runtime.rs @@ -688,3 +688,108 @@ fn is_resource_data_permission_set(permission_set_id: &str) -> bool { || permission_set_id.starts_with("kv/") || permission_set_id.starts_with("queue/") } + +fn custom_role_ids(resource_prefix: &str, permission_set_id: &str) -> Vec { + let permission_set = get_permission_set(permission_set_id).expect("permission set exists"); + GcpRuntimePermissionsGenerator::new() + .generate_custom_roles( + permission_set, + &create_test_context().with_stack_prefix(resource_prefix), + ) + .expect("GCP custom roles should compile") + .into_iter() + .map(|role| role.role_id) + .collect() +} + +#[test] +fn gcp_custom_role_ids_differ_for_prefixes_sharing_their_first_18_characters() { + for permission_set_id in list_permission_set_ids() { + let permission_set = get_permission_set(permission_set_id).expect("permission set exists"); + if permission_set.platforms.gcp.is_none() { + continue; + } + let eu = custom_role_ids("customer-acme-prod-eu", permission_set_id); + let us = custom_role_ids("customer-acme-prod-us", permission_set_id); + + assert_eq!(eu.len(), us.len()); + for role_id in &eu { + assert!( + !us.contains(role_id), + "'{permission_set_id}' gives both deployments the role ID '{role_id}'" + ); + } + } +} + +/// Expected namespaces were produced by `terraform console` evaluating the +/// generated module's `gcp_custom_role_prefix` local with each `resource_prefix`. +#[rstest] +#[case::short("acme", "acme")] +#[case::hyphenated("acme-prod", "acme_prod")] +#[case::exactly_18("customer-acme-prod", "customer_acme_prod")] +#[case::nineteen("acme-platform-eu-12", "acme_plat_20cb2504")] +#[case::hyphen_at_cut("customer-acme-prod-eu", "customer__2e6bb8cf")] +#[case::forty("abcdefgh-ijklmnopqrs-tuvwxyz0123-456789", "abcdefgh__10cdde91")] +fn gcp_custom_role_namespace_matches_terraform( + #[case] resource_prefix: &str, + #[case] expected: &str, +) { + assert_eq!( + alien_permissions::generators::custom_role_namespace_for_prefix(resource_prefix), + expected + ); + let role_ids = custom_role_ids(resource_prefix, "storage/data-read"); + assert!(!role_ids.is_empty()); + for role_id in role_ids { + assert!( + role_id.starts_with(&format!("role_{expected}_")), + "{role_id}" + ); + } +} + +#[test] +fn gcp_custom_role_namespace_override_keeps_existing_role_ids() { + let permission_set = get_permission_set("storage/data-read").expect("permission set exists"); + let context = create_test_context() + .with_stack_prefix("customer-acme-prod-eu") + .with_gcp_custom_role_namespace("customer_acme_prod"); + + let roles = GcpRuntimePermissionsGenerator::new() + .generate_custom_roles(permission_set, &context) + .expect("GCP custom roles should compile"); + + // The IDs runtime setup created for this prefix before long prefixes were hashed. + assert_eq!( + roles + .iter() + .map(|role| role.name.as_str()) + .collect::>(), + vec![ + "projects/my-project/roles/role_customer_acme_prod_read_cloud_storage_objects", + "projects/my-project/roles/role_customer_acme_prod_sign_cloud_storage_download_urls", + ] + ); + assert_eq!( + alien_permissions::generators::legacy_custom_role_namespace_for_prefix( + "customer-acme-prod-eu" + ), + "customer_acme_prod" + ); +} + +#[test] +fn gcp_custom_role_description_names_only_its_exact_prefix() { + use alien_permissions::generators::custom_role_description_names_prefix as names; + + let description = "Used by Acme. Allows reading data. Resource prefix: acme-prod."; + assert!(names(description, "acme-prod")); + assert!(!names(description, "acme")); + assert!(!names(description, "prod")); + assert!(!names("Allows reading data. Resource prefix: acme.", "cme")); + assert!(!names( + "Allows reading data. Resource prefix: acme.extra.", + "acme" + )); +} diff --git a/crates/alien-terraform/src/emitters/gcp/helpers.rs b/crates/alien-terraform/src/emitters/gcp/helpers.rs index d90d9c276d..7024c5b52b 100644 --- a/crates/alien-terraform/src/emitters/gcp/helpers.rs +++ b/crates/alien-terraform/src/emitters/gcp/helpers.rs @@ -28,6 +28,11 @@ use hcl::{ structure::{Block, Structure}, }; +/// Namespace the generator puts in role IDs, replaced in the rendered module +/// by `local.gcp_custom_role_prefix`. It is 18 characters, the longest real +/// namespace, so generated suffixes leave room for every real one. +const CUSTOM_ROLE_NAMESPACE_PLACEHOLDER: &str = "local_resource_pre"; + /// Downcast `ctx.resource.config` to the typed resource definition or /// return a typed `UnexpectedResourceType` error. pub fn downcast<'a, T: ResourceDefinition>( @@ -218,6 +223,7 @@ pub fn service_account_member_for_var(variable: &str) -> Expression { pub fn permission_context(label: &str, _stack_name: &str) -> PermissionContext { PermissionContext::new() .with_stack_prefix("${local.resource_prefix}".to_string()) + .with_gcp_custom_role_namespace(CUSTOM_ROLE_NAMESPACE_PLACEHOLDER) .with_deployment_name("${local.deployment_name}".to_string()) .with_project_name("${var.gcp_project}".to_string()) .with_project_number("${data.google_project.current.number}".to_string()) @@ -419,7 +425,7 @@ fn custom_role_id_template(custom_role: &GcpCustomRole) -> Expression { fn custom_role_suffix(custom_role: &GcpCustomRole) -> String { custom_role .role_id - .strip_prefix("role_local_resource_pre_") + .strip_prefix(&format!("role_{CUSTOM_ROLE_NAMESPACE_PLACEHOLDER}_")) .unwrap_or(&custom_role.role_id) .to_string() }