From d4c318f925507aaf6488d6ea96ea2cd53328e6c1 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 15 Jan 2026 12:59:31 +0000 Subject: [PATCH 01/22] feat: accelerator permission flattening --- alz/azuredevops/main.tf | 6 +- alz/azuredevops/variables.tf | 220 ++++++------------ alz/github/main.tf | 6 +- alz/github/variables.tf | 220 ++++++------------ alz/local/main.tf | 6 +- alz/local/variables.tf | 220 ++++++------------ modules/azure/management_group.tf | 34 +++ modules/azure/role_assignments.tf | 28 ++- modules/azure/role_definitions.tf | 2 +- modules/azure/variables.tf | 34 ++- ...cals.intermediate_root_management_group.tf | 27 +++ modules/file_manipulation/outputs.tf | 17 +- modules/file_manipulation/variables.tf | 12 +- 13 files changed, 355 insertions(+), 477 deletions(-) create mode 100644 modules/azure/management_group.tf create mode 100644 modules/file_manipulation/locals.intermediate_root_management_group.tf diff --git a/alz/azuredevops/main.tf b/alz/azuredevops/main.tf index 85db8b3..707212d 100644 --- a/alz/azuredevops/main.tf +++ b/alz/azuredevops/main.tf @@ -59,7 +59,7 @@ module "azure" { container_registry_dockerfile_name = var.agent_container_image_dockerfile container_registry_dockerfile_repository_folder_url = local.agent_container_instance_dockerfile_url custom_role_definitions = var.iac_type == "terraform" ? local.custom_role_definitions_terraform : (var.iac_type == "bicep" ? local.custom_role_definitions_bicep : local.custom_role_definitions_bicep_classic) - role_assignments = var.iac_type == "terraform" ? var.role_assignments_terraform : var.role_assignments_bicep + role_assignments = var.iac_type == "terraform" ? var.role_assignments_terraform : (var.iac_type == "bicep" ? var.role_assignments_bicep : var.role_assignments_bicep_classic) storage_account_blob_soft_delete_enabled = var.storage_account_blob_soft_delete_enabled storage_account_blob_soft_delete_retention_days = var.storage_account_blob_soft_delete_retention_days storage_account_blob_versioning_enabled = var.storage_account_blob_versioning_enabled @@ -67,6 +67,9 @@ module "azure" { storage_account_container_soft_delete_retention_days = var.storage_account_container_soft_delete_retention_days tenant_role_assignment_enabled = var.iac_type == "bicep" && var.bicep_tenant_role_assignment_enabled tenant_role_assignment_role_definition_name = var.bicep_tenant_role_assignment_role_definition_name + intermediate_root_management_group_creation_enabled = var.iac_type != "bicep-classic" + intermediate_root_management_group_id = module.file_manipulation.intermediate_root_management_group_id + intermediate_root_management_group_display_name = module.file_manipulation.intermediate_root_management_group_display_name } module "azure_devops" { @@ -120,4 +123,5 @@ module "file_manipulation" { agent_pool_or_runner_configuration = local.agent_pool_or_runner_configuration pipeline_files_directory_path = local.pipeline_files_directory_path pipeline_template_files_directory_path = local.pipeline_template_files_directory_path + terraform_architecture_file_path = var.terraform_architecture_file_path } \ No newline at end of file diff --git a/alz/azuredevops/variables.tf b/alz/azuredevops/variables.tf index 09f3aab..394947c 100644 --- a/alz/azuredevops/variables.tf +++ b/alz/azuredevops/variables.tf @@ -593,11 +593,7 @@ variable "custom_role_definitions_terraform" { - `actions` (list(string)) - Allowed Azure actions - `not_actions` (list(string)) - Denied Azure actions - Default includes 4 predefined roles: - - `alz_management_group_contributor` - Manage management group hierarchy and governance - - `alz_management_group_reader` - Read management group structure and validate deployments - - `alz_subscription_owner` - Full access to platform subscriptions - - `alz_subscription_reader` - Read/write access for platform subscription resources + Default is empty, meaning no custom roles are created. See default value for complete role action definitions. EOT @@ -609,89 +605,7 @@ variable "custom_role_definitions_terraform" { not_actions = list(string) }) })) - default = { - alz_management_group_contributor = { - name = "Azure Landing Zones Management Group Contributor ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Writing the Management Group Structure." - permissions = { - actions = [ - "Microsoft.Management/managementGroups/delete", - "Microsoft.Management/managementGroups/read", - "Microsoft.Management/managementGroups/subscriptions/delete", - "Microsoft.Management/managementGroups/subscriptions/write", - "Microsoft.Management/managementGroups/settings/read", - "Microsoft.Management/managementGroups/settings/write", - "Microsoft.Management/managementGroups/settings/delete", - "Microsoft.Management/managementGroups/write", - "Microsoft.Management/managementGroups/subscriptions/read", - "Microsoft.Authorization/policyDefinitions/write", - "Microsoft.Authorization/policySetDefinitions/write", - "Microsoft.Authorization/policyAssignments/write", - "Microsoft.Authorization/roleDefinitions/write", - "Microsoft.Authorization/*/read", - "Microsoft.Authorization/roleAssignments/write", - "Microsoft.Authorization/roleAssignments/delete", - "Microsoft.Insights/diagnosticSettings/write" - ] - not_actions = [] - } - } - alz_management_group_reader = { - name = "Azure Landing Zones Management Group Reader ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Reading the Management Group Structure." - permissions = { - actions = [ - "Microsoft.Management/managementGroups/read", - "Microsoft.Management/managementGroups/subscriptions/read", - "Microsoft.Management/managementGroups/settings/read", - "Microsoft.Authorization/*/read", - "Microsoft.Authorization/policyDefinitions/write", - "Microsoft.Authorization/policySetDefinitions/write", - "Microsoft.Authorization/roleDefinitions/write", - "Microsoft.Authorization/policyAssignments/write", - "Microsoft.Insights/diagnosticSettings/write", - "Microsoft.Insights/diagnosticSettings/read", - "Microsoft.Resources/deployments/whatIf/action", - "Microsoft.Resources/deployments/write", - "Microsoft.Resources/deploymentStacks/read", - "Microsoft.Resources/deploymentStacks/validate/action" - ] - not_actions = [] - } - } - alz_subscription_owner = { - name = "Azure Landing Zones Subscription Owner ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Writing in platform subscriptions." - permissions = { - actions = [ - "*" - ] - not_actions = [] - } - } - alz_subscription_reader = { - name = "Azure Landing Zones Subscription Reader ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Reading the platform subscriptions." - permissions = { - actions = [ - "*/read", - "Microsoft.Resources/subscriptions/resourceGroups/write", - "Microsoft.ManagedIdentity/userAssignedIdentities/write", - "Microsoft.Automation/automationAccounts/write", - "Microsoft.OperationalInsights/workspaces/write", - "Microsoft.OperationalInsights/workspaces/linkedServices/write", - "Microsoft.OperationsManagement/solutions/write", - "Microsoft.Insights/dataCollectionRules/write", - "Microsoft.Authorization/locks/write", - "Microsoft.Network/*/write", - "Microsoft.Resources/deployments/whatIf/action", - "Microsoft.Resources/deployments/write", - "Microsoft.SecurityInsights/onboardingStates/write" - ] - not_actions = [] - } - } - } + default = {} } variable "custom_role_definitions_bicep" { @@ -707,11 +621,8 @@ variable "custom_role_definitions_bicep" { - `actions` (list(string)) - Allowed Azure actions - `not_actions` (list(string)) - Denied Azure actions - Default includes 4 predefined roles: - - `alz_management_group_contributor` - Manage management group hierarchy and governance - - `alz_management_group_reader` - Run Bicep What-If validations (requires --validation-level providerNoRbac flag) - - `alz_subscription_owner` - Full access to platform subscriptions - - `alz_subscription_reader` - Run Bicep What-If for subscription deployments + Default includes 1 predefined roles: + - `alz_reader` - Run Bicep What-If validations (requires --validation-level providerNoRbac flag)s See default value for complete role action definitions. EOT @@ -724,25 +635,7 @@ variable "custom_role_definitions_bicep" { }) })) default = { - alz_management_group_contributor = { - name = "Azure Landing Zones Management Group Contributor ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for creating and managing the Management Group hierarchy and its associated governance resources such as policy, RBAC etc..." - permissions = { - actions = [ - "*/read", - "Microsoft.Management/*", - "Microsoft.Authorization/*", - "Microsoft.Resources/*", - "Microsoft.Support/*", - "Microsoft.Insights/diagnosticSettings/*" - ] - not_actions = [ - "Microsoft.Resources/subscriptions/resourceGroups/write", - "Microsoft.Resources/subscriptions/resourceGroups/delete" - ] - } - } - alz_management_group_reader = { + alz_reader = { name = "Azure Landing Zones Management Group What If ({{service_name}}-{{environment_name}})" description = "This is a custom role created by the Azure Landing Zones Accelerator for running Bicep What If for the Management Group hierarchy and its associated governance resources such as policy, RBAC etc... You must use the `--validation-level providerNoRbac` (Az CLI 2.75.0 or later) or `-ValidationLevel providerNoRbac` (Az PowerShell 13.4.0 or later (Az.Resources 7.10.0 or later)) flag when running Bicep What If with this role." permissions = { @@ -756,30 +649,6 @@ variable "custom_role_definitions_bicep" { not_actions = [] } } - alz_subscription_owner = { - name = "Azure Landing Zones Subscription Owner ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Writing in platform subscriptions." - permissions = { - actions = [ - "*" - ] - not_actions = [] - } - } - alz_subscription_reader = { - name = "Azure Landing Zones Subscription What If ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for running Bicep What If for the Management Group hierarchy and its associated governance resources such as policy, RBAC etc... You must use the `--validation-level providerNoRbac` (Az CLI 2.75.0 or later) or `-ValidationLevel providerNoRbac` (Az PowerShell 13.4.0 or later (Az.Resources 7.10.0 or later)) flag when running Bicep What If with this role." - permissions = { - actions = [ - "*/read", - "Microsoft.Resources/deployments/whatIf/action", - "Microsoft.Resources/deployments/validate/action", - "Microsoft.Resources/subscriptions/operationResults/read", - "Microsoft.Management/operationResults/*/read" - ] - not_actions = [] - } - } } } @@ -909,50 +778,83 @@ variable "role_assignments_terraform" { Map of role assignment configurations where: - **Key**: Assignment identifier (e.g., 'plan_management_group') - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (e.g., 'Owner', 'Contributor') - `custom_role_definition_key` (string) - Key from custom_role_definitions_terraform - `user_assigned_managed_identity_key` (string) - Managed identity key ('plan' or 'apply') - `scope` (string) - Assignment scope ('management_group' or 'subscription') - Default includes 4 assignments: - - Plan and apply access for management group operations - - Plan and apply access for subscription operations + Default includes 2 assignments: + - Plan and apply access + EOT type = map(object({ - custom_role_definition_key = string + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) user_assigned_managed_identity_key = string scope = string })) default = { - plan_management_group = { - custom_role_definition_key = "alz_management_group_reader" + plan = { + built_in_role_definition_name = "Reader" user_assigned_managed_identity_key = "plan" scope = "management_group" } - apply_management_group = { - custom_role_definition_key = "alz_management_group_contributor" + apply = { + built_in_role_definition_name = "Owner" user_assigned_managed_identity_key = "apply" scope = "management_group" } - plan_subscription = { - custom_role_definition_key = "alz_subscription_reader" + } +} + +variable "role_assignments_bicep" { + description = <<-EOT + **(Optional)** RBAC role assignments for Bicep-based deployments. + + Map of role assignment configurations where: + - **Key**: Assignment identifier (e.g., 'plan_management_group') + - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (e.g., 'Owner', 'Contributor') + - `custom_role_definition_key` (string) - Key from custom_role_definitions_bicep + - `user_assigned_managed_identity_key` (string) - Managed identity key ('plan' or 'apply') + - `scope` (string) - Assignment scope ('management_group' or 'subscription') + + Default includes 3 assignments: + - Plan and apply access operations + EOT + type = map(object({ + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) + user_assigned_managed_identity_key = string + scope = string + })) + default = { + plan = { + custom_role_definition_key = "Reader" user_assigned_managed_identity_key = "plan" - scope = "subscription" + scope = "management_group" } - apply_subscription = { - custom_role_definition_key = "alz_subscription_owner" + plan_custom = { + custom_role_definition_key = "alz_reader" + user_assigned_managed_identity_key = "plan" + scope = "management_group" + } + apply_management_group = { + custom_role_definition_key = "Owner" user_assigned_managed_identity_key = "apply" - scope = "subscription" + scope = "management_group" } } } -variable "role_assignments_bicep" { +variable "role_assignments_bicep_classic" { description = <<-EOT - **(Optional)** RBAC role assignments for Bicep-based deployments. + **(Optional)** RBAC role assignments for Bicep Classic based deployments. Map of role assignment configurations where: - **Key**: Assignment identifier (e.g., 'plan_management_group') - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (e.g., 'Owner', 'Contributor') - `custom_role_definition_key` (string) - Key from custom_role_definitions_bicep - `user_assigned_managed_identity_key` (string) - Managed identity key ('plan' or 'apply') - `scope` (string) - Assignment scope ('management_group' or 'subscription') @@ -962,7 +864,8 @@ variable "role_assignments_bicep" { - Plan and apply access for subscription operations EOT type = map(object({ - custom_role_definition_key = string + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) user_assigned_managed_identity_key = string scope = string })) @@ -1078,3 +981,14 @@ variable "bicep_tenant_role_assignment_role_definition_name" { type = string default = "Landing Zone Management Owner" } + +variable "terraform_architecture_file_path" { + description = <<-EOT + **(Required)** Relative path to the Terraform architecture definition JSON file within the module folder. + + This file defines the structure and components of the Terraform deployment architecture. + Used for dynamic file manipulation based on architecture specifics. + EOT + type = string + default = "lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" +} diff --git a/alz/github/main.tf b/alz/github/main.tf index 478b6f4..dc8ec6c 100644 --- a/alz/github/main.tf +++ b/alz/github/main.tf @@ -60,7 +60,7 @@ module "azure" { container_registry_dockerfile_name = var.runner_container_image_dockerfile container_registry_dockerfile_repository_folder_url = local.runner_container_instance_dockerfile_url custom_role_definitions = var.iac_type == "terraform" ? local.custom_role_definitions_terraform : (var.iac_type == "bicep" ? local.custom_role_definitions_bicep : local.custom_role_definitions_bicep_classic) - role_assignments = var.iac_type == "terraform" ? var.role_assignments_terraform : var.role_assignments_bicep + role_assignments = var.iac_type == "terraform" ? var.role_assignments_terraform : (var.iac_type == "bicep" ? var.role_assignments_bicep : var.role_assignments_bicep_classic) storage_account_blob_soft_delete_enabled = var.storage_account_blob_soft_delete_enabled storage_account_blob_soft_delete_retention_days = var.storage_account_blob_soft_delete_retention_days storage_account_blob_versioning_enabled = var.storage_account_blob_versioning_enabled @@ -68,6 +68,9 @@ module "azure" { storage_account_container_soft_delete_retention_days = var.storage_account_container_soft_delete_retention_days tenant_role_assignment_enabled = var.iac_type == "bicep" && var.bicep_tenant_role_assignment_enabled tenant_role_assignment_role_definition_name = var.bicep_tenant_role_assignment_role_definition_name + intermediate_root_management_group_creation_enabled = var.iac_type != "bicep-classic" + intermediate_root_management_group_id = module.file_manipulation.intermediate_root_management_group_id + intermediate_root_management_group_display_name = module.file_manipulation.intermediate_root_management_group_display_name } module "github" { @@ -122,4 +125,5 @@ module "file_manipulation" { pipeline_files_directory_path = local.pipeline_files_directory_path pipeline_template_files_directory_path = local.pipeline_template_files_directory_path concurrency_value = local.resource_names.storage_container + terraform_architecture_file_path = var.terraform_architecture_file_path } diff --git a/alz/github/variables.tf b/alz/github/variables.tf index 8567c8c..28d7a93 100644 --- a/alz/github/variables.tf +++ b/alz/github/variables.tf @@ -645,11 +645,7 @@ variable "custom_role_definitions_terraform" { - `actions` (list(string)) - Allowed Azure actions - `not_actions` (list(string)) - Denied Azure actions - Default includes 4 predefined roles: - - `alz_management_group_contributor` - Manage management group hierarchy and governance - - `alz_management_group_reader` - Read management group structure and validate deployments - - `alz_subscription_owner` - Full access to platform subscriptions - - `alz_subscription_reader` - Read/write access for platform subscription resources + Default is empty, meaning no custom roles are created. See default value for complete role action definitions. EOT @@ -661,89 +657,7 @@ variable "custom_role_definitions_terraform" { not_actions = list(string) }) })) - default = { - alz_management_group_contributor = { - name = "Azure Landing Zones Management Group Contributor ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Writing the Management Group Structure." - permissions = { - actions = [ - "Microsoft.Management/managementGroups/delete", - "Microsoft.Management/managementGroups/read", - "Microsoft.Management/managementGroups/subscriptions/delete", - "Microsoft.Management/managementGroups/subscriptions/write", - "Microsoft.Management/managementGroups/settings/read", - "Microsoft.Management/managementGroups/settings/write", - "Microsoft.Management/managementGroups/settings/delete", - "Microsoft.Management/managementGroups/write", - "Microsoft.Management/managementGroups/subscriptions/read", - "Microsoft.Authorization/policyDefinitions/write", - "Microsoft.Authorization/policySetDefinitions/write", - "Microsoft.Authorization/policyAssignments/write", - "Microsoft.Authorization/roleDefinitions/write", - "Microsoft.Authorization/*/read", - "Microsoft.Authorization/roleAssignments/write", - "Microsoft.Authorization/roleAssignments/delete", - "Microsoft.Insights/diagnosticSettings/write" - ] - not_actions = [] - } - } - alz_management_group_reader = { - name = "Azure Landing Zones Management Group Reader ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Reading the Management Group Structure." - permissions = { - actions = [ - "Microsoft.Management/managementGroups/read", - "Microsoft.Management/managementGroups/subscriptions/read", - "Microsoft.Management/managementGroups/settings/read", - "Microsoft.Authorization/*/read", - "Microsoft.Authorization/policyDefinitions/write", - "Microsoft.Authorization/policySetDefinitions/write", - "Microsoft.Authorization/roleDefinitions/write", - "Microsoft.Authorization/policyAssignments/write", - "Microsoft.Insights/diagnosticSettings/write", - "Microsoft.Insights/diagnosticSettings/read", - "Microsoft.Resources/deployments/whatIf/action", - "Microsoft.Resources/deployments/write", - "Microsoft.Resources/deploymentStacks/read", - "Microsoft.Resources/deploymentStacks/validate/action" - ] - not_actions = [] - } - } - alz_subscription_owner = { - name = "Azure Landing Zones Subscription Owner ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Writing in platform subscriptions." - permissions = { - actions = [ - "*" - ] - not_actions = [] - } - } - alz_subscription_reader = { - name = "Azure Landing Zones Subscription Reader ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Reading the platform subscriptions." - permissions = { - actions = [ - "*/read", - "Microsoft.Resources/subscriptions/resourceGroups/write", - "Microsoft.ManagedIdentity/userAssignedIdentities/write", - "Microsoft.Automation/automationAccounts/write", - "Microsoft.OperationalInsights/workspaces/write", - "Microsoft.OperationalInsights/workspaces/linkedServices/write", - "Microsoft.OperationsManagement/solutions/write", - "Microsoft.Insights/dataCollectionRules/write", - "Microsoft.Authorization/locks/write", - "Microsoft.Network/*/write", - "Microsoft.Resources/deployments/whatIf/action", - "Microsoft.Resources/deployments/write", - "Microsoft.SecurityInsights/onboardingStates/write" - ] - not_actions = [] - } - } - } + default = {} } variable "custom_role_definitions_bicep" { @@ -759,11 +673,8 @@ variable "custom_role_definitions_bicep" { - `actions` (list(string)) - Allowed Azure actions - `not_actions` (list(string)) - Denied Azure actions - Default includes 4 predefined roles: - - `alz_management_group_contributor` - Manage management group hierarchy and governance - - `alz_management_group_reader` - Run Bicep What-If validations (requires --validation-level providerNoRbac flag) - - `alz_subscription_owner` - Full access to platform subscriptions - - `alz_subscription_reader` - Run Bicep What-If for subscription deployments + Default includes 1 predefined roles: + - `alz_reader` - Run Bicep What-If validations (requires --validation-level providerNoRbac flag)s See default value for complete role action definitions. EOT @@ -776,25 +687,7 @@ variable "custom_role_definitions_bicep" { }) })) default = { - alz_management_group_contributor = { - name = "Azure Landing Zones Management Group Contributor ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for creating and managing the Management Group hierarchy and its associated governance resources such as policy, RBAC etc..." - permissions = { - actions = [ - "*/read", - "Microsoft.Management/*", - "Microsoft.Authorization/*", - "Microsoft.Resources/*", - "Microsoft.Support/*", - "Microsoft.Insights/diagnosticSettings/*" - ] - not_actions = [ - "Microsoft.Resources/subscriptions/resourceGroups/write", - "Microsoft.Resources/subscriptions/resourceGroups/delete" - ] - } - } - alz_management_group_reader = { + alz_reader = { name = "Azure Landing Zones Management Group What If ({{service_name}}-{{environment_name}})" description = "This is a custom role created by the Azure Landing Zones Accelerator for running Bicep What If for the Management Group hierarchy and its associated governance resources such as policy, RBAC etc... You must use the `--validation-level providerNoRbac` (Az CLI 2.75.0 or later) or `-ValidationLevel providerNoRbac` (Az PowerShell 13.4.0 or later (Az.Resources 7.10.0 or later)) flag when running Bicep What If with this role." permissions = { @@ -808,30 +701,6 @@ variable "custom_role_definitions_bicep" { not_actions = [] } } - alz_subscription_owner = { - name = "Azure Landing Zones Subscription Owner ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Writing in platform subscriptions." - permissions = { - actions = [ - "*" - ] - not_actions = [] - } - } - alz_subscription_reader = { - name = "Azure Landing Zones Subscription What If ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for running Bicep What If for the Management Group hierarchy and its associated governance resources such as policy, RBAC etc... You must use the `--validation-level providerNoRbac` (Az CLI 2.75.0 or later) or `-ValidationLevel providerNoRbac` (Az PowerShell 13.4.0 or later (Az.Resources 7.10.0 or later)) flag when running Bicep What If with this role." - permissions = { - actions = [ - "*/read", - "Microsoft.Resources/deployments/whatIf/action", - "Microsoft.Resources/deployments/validate/action", - "Microsoft.Resources/subscriptions/operationResults/read", - "Microsoft.Management/operationResults/*/read" - ] - not_actions = [] - } - } } } @@ -961,50 +830,83 @@ variable "role_assignments_terraform" { Map of role assignment configurations where: - **Key**: Assignment identifier (e.g., 'plan_management_group') - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (e.g., 'Owner', 'Contributor') - `custom_role_definition_key` (string) - Key from custom_role_definitions_terraform - `user_assigned_managed_identity_key` (string) - Managed identity key ('plan' or 'apply') - `scope` (string) - Assignment scope ('management_group' or 'subscription') - Default includes 4 assignments: - - Plan and apply access for management group operations - - Plan and apply access for subscription operations + Default includes 2 assignments: + - Plan and apply access + EOT type = map(object({ - custom_role_definition_key = string + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) user_assigned_managed_identity_key = string scope = string })) default = { - plan_management_group = { - custom_role_definition_key = "alz_management_group_reader" + plan = { + built_in_role_definition_name = "Reader" user_assigned_managed_identity_key = "plan" scope = "management_group" } - apply_management_group = { - custom_role_definition_key = "alz_management_group_contributor" + apply = { + built_in_role_definition_name = "Owner" user_assigned_managed_identity_key = "apply" scope = "management_group" } - plan_subscription = { - custom_role_definition_key = "alz_subscription_reader" + } +} + +variable "role_assignments_bicep" { + description = <<-EOT + **(Optional)** RBAC role assignments for Bicep-based deployments. + + Map of role assignment configurations where: + - **Key**: Assignment identifier (e.g., 'plan_management_group') + - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (e.g., 'Owner', 'Contributor') + - `custom_role_definition_key` (string) - Key from custom_role_definitions_bicep + - `user_assigned_managed_identity_key` (string) - Managed identity key ('plan' or 'apply') + - `scope` (string) - Assignment scope ('management_group' or 'subscription') + + Default includes 3 assignments: + - Plan and apply access operations + EOT + type = map(object({ + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) + user_assigned_managed_identity_key = string + scope = string + })) + default = { + plan = { + custom_role_definition_key = "Reader" user_assigned_managed_identity_key = "plan" - scope = "subscription" + scope = "management_group" } - apply_subscription = { - custom_role_definition_key = "alz_subscription_owner" + plan_custom = { + custom_role_definition_key = "alz_reader" + user_assigned_managed_identity_key = "plan" + scope = "management_group" + } + apply_management_group = { + custom_role_definition_key = "Owner" user_assigned_managed_identity_key = "apply" - scope = "subscription" + scope = "management_group" } } } -variable "role_assignments_bicep" { +variable "role_assignments_bicep_classic" { description = <<-EOT - **(Optional)** RBAC role assignments for Bicep-based deployments. + **(Optional)** RBAC role assignments for Bicep Classic based deployments. Map of role assignment configurations where: - **Key**: Assignment identifier (e.g., 'plan_management_group') - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (e.g., 'Owner', 'Contributor') - `custom_role_definition_key` (string) - Key from custom_role_definitions_bicep - `user_assigned_managed_identity_key` (string) - Managed identity key ('plan' or 'apply') - `scope` (string) - Assignment scope ('management_group' or 'subscription') @@ -1014,7 +916,8 @@ variable "role_assignments_bicep" { - Plan and apply access for subscription operations EOT type = map(object({ - custom_role_definition_key = string + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) user_assigned_managed_identity_key = string scope = string })) @@ -1130,3 +1033,14 @@ variable "bicep_tenant_role_assignment_role_definition_name" { type = string default = "Landing Zone Management Owner" } + +variable "terraform_architecture_file_path" { + description = <<-EOT + **(Required)** Relative path to the Terraform architecture definition JSON file within the module folder. + + This file defines the structure and components of the Terraform deployment architecture. + Used for dynamic file manipulation based on architecture specifics. + EOT + type = string + default = "lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" +} diff --git a/alz/local/main.tf b/alz/local/main.tf index 27e9104..0e38dc6 100644 --- a/alz/local/main.tf +++ b/alz/local/main.tf @@ -33,7 +33,7 @@ module "azure" { use_self_hosted_agents = false use_private_networking = false custom_role_definitions = var.iac_type == "terraform" ? local.custom_role_definitions_terraform : (var.iac_type == "bicep" ? local.custom_role_definitions_bicep : local.custom_role_definitions_bicep_classic) - role_assignments = var.iac_type == "terraform" ? var.role_assignments_terraform : var.role_assignments_bicep + role_assignments = var.iac_type == "terraform" ? var.role_assignments_terraform : (var.iac_type == "bicep" ? var.role_assignments_bicep : var.role_assignments_bicep_classic) additional_role_assignment_principal_ids = var.grant_permissions_to_current_user ? { current_user = data.azurerm_client_config.current.object_id } : {} storage_account_blob_soft_delete_enabled = var.storage_account_blob_soft_delete_enabled storage_account_blob_soft_delete_retention_days = var.storage_account_blob_soft_delete_retention_days @@ -42,6 +42,9 @@ module "azure" { storage_account_container_soft_delete_retention_days = var.storage_account_container_soft_delete_retention_days tenant_role_assignment_enabled = var.iac_type == "bicep" && var.bicep_tenant_role_assignment_enabled tenant_role_assignment_role_definition_name = var.bicep_tenant_role_assignment_role_definition_name + intermediate_root_management_group_creation_enabled = var.iac_type != "bicep-classic" + intermediate_root_management_group_id = module.file_manipulation.intermediate_root_management_group_id + intermediate_root_management_group_display_name = module.file_manipulation.intermediate_root_management_group_display_name } module "file_manipulation" { @@ -59,6 +62,7 @@ module "file_manipulation" { pipeline_target_folder_name = local.script_target_folder_name bicep_parameters_file_path = var.bicep_parameters_file_path pipeline_files_directory_path = local.script_source_folder_path + terraform_architecture_file_path = var.terraform_architecture_file_path } resource "local_file" "alz" { diff --git a/alz/local/variables.tf b/alz/local/variables.tf index e714b93..d1a4968 100644 --- a/alz/local/variables.tf +++ b/alz/local/variables.tf @@ -358,11 +358,7 @@ variable "custom_role_definitions_terraform" { - `actions` (list(string)) - Allowed Azure actions - `not_actions` (list(string)) - Denied Azure actions - Default includes 4 predefined roles: - - `alz_management_group_contributor` - Manage management group hierarchy and governance - - `alz_management_group_reader` - Read management group structure and validate deployments - - `alz_subscription_owner` - Full access to platform subscriptions - - `alz_subscription_reader` - Read/write access for platform subscription resources + Default is empty, meaning no custom roles are created. See default value for complete role action definitions. EOT @@ -374,89 +370,7 @@ variable "custom_role_definitions_terraform" { not_actions = list(string) }) })) - default = { - alz_management_group_contributor = { - name = "Azure Landing Zones Management Group Contributor ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Writing the Management Group Structure." - permissions = { - actions = [ - "Microsoft.Management/managementGroups/delete", - "Microsoft.Management/managementGroups/read", - "Microsoft.Management/managementGroups/subscriptions/delete", - "Microsoft.Management/managementGroups/subscriptions/write", - "Microsoft.Management/managementGroups/settings/read", - "Microsoft.Management/managementGroups/settings/write", - "Microsoft.Management/managementGroups/settings/delete", - "Microsoft.Management/managementGroups/write", - "Microsoft.Management/managementGroups/subscriptions/read", - "Microsoft.Authorization/policyDefinitions/write", - "Microsoft.Authorization/policySetDefinitions/write", - "Microsoft.Authorization/policyAssignments/write", - "Microsoft.Authorization/roleDefinitions/write", - "Microsoft.Authorization/*/read", - "Microsoft.Authorization/roleAssignments/write", - "Microsoft.Authorization/roleAssignments/delete", - "Microsoft.Insights/diagnosticSettings/write" - ] - not_actions = [] - } - } - alz_management_group_reader = { - name = "Azure Landing Zones Management Group Reader ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Reading the Management Group Structure." - permissions = { - actions = [ - "Microsoft.Management/managementGroups/read", - "Microsoft.Management/managementGroups/subscriptions/read", - "Microsoft.Management/managementGroups/settings/read", - "Microsoft.Authorization/*/read", - "Microsoft.Authorization/policyDefinitions/write", - "Microsoft.Authorization/policySetDefinitions/write", - "Microsoft.Authorization/roleDefinitions/write", - "Microsoft.Authorization/policyAssignments/write", - "Microsoft.Insights/diagnosticSettings/write", - "Microsoft.Insights/diagnosticSettings/read", - "Microsoft.Resources/deployments/whatIf/action", - "Microsoft.Resources/deployments/write", - "Microsoft.Resources/deploymentStacks/read", - "Microsoft.Resources/deploymentStacks/validate/action" - ] - not_actions = [] - } - } - alz_subscription_owner = { - name = "Azure Landing Zones Subscription Owner ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Writing in platform subscriptions." - permissions = { - actions = [ - "*" - ] - not_actions = [] - } - } - alz_subscription_reader = { - name = "Azure Landing Zones Subscription Reader ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Reading the platform subscriptions." - permissions = { - actions = [ - "*/read", - "Microsoft.Resources/subscriptions/resourceGroups/write", - "Microsoft.ManagedIdentity/userAssignedIdentities/write", - "Microsoft.Automation/automationAccounts/write", - "Microsoft.OperationalInsights/workspaces/write", - "Microsoft.OperationalInsights/workspaces/linkedServices/write", - "Microsoft.OperationsManagement/solutions/write", - "Microsoft.Insights/dataCollectionRules/write", - "Microsoft.Authorization/locks/write", - "Microsoft.Network/*/write", - "Microsoft.Resources/deployments/whatIf/action", - "Microsoft.Resources/deployments/write", - "Microsoft.SecurityInsights/onboardingStates/write" - ] - not_actions = [] - } - } - } + default = {} } variable "custom_role_definitions_bicep" { @@ -472,11 +386,8 @@ variable "custom_role_definitions_bicep" { - `actions` (list(string)) - Allowed Azure actions - `not_actions` (list(string)) - Denied Azure actions - Default includes 4 predefined roles: - - `alz_management_group_contributor` - Manage management group hierarchy and governance - - `alz_management_group_reader` - Run Bicep What-If validations (requires --validation-level providerNoRbac flag) - - `alz_subscription_owner` - Full access to platform subscriptions - - `alz_subscription_reader` - Run Bicep What-If for subscription deployments + Default includes 1 predefined roles: + - `alz_reader` - Run Bicep What-If validations (requires --validation-level providerNoRbac flag)s See default value for complete role action definitions. EOT @@ -489,25 +400,7 @@ variable "custom_role_definitions_bicep" { }) })) default = { - alz_management_group_contributor = { - name = "Azure Landing Zones Management Group Contributor ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for creating and managing the Management Group hierarchy and its associated governance resources such as policy, RBAC etc..." - permissions = { - actions = [ - "*/read", - "Microsoft.Management/*", - "Microsoft.Authorization/*", - "Microsoft.Resources/*", - "Microsoft.Support/*", - "Microsoft.Insights/diagnosticSettings/*" - ] - not_actions = [ - "Microsoft.Resources/subscriptions/resourceGroups/write", - "Microsoft.Resources/subscriptions/resourceGroups/delete" - ] - } - } - alz_management_group_reader = { + alz_reader = { name = "Azure Landing Zones Management Group What If ({{service_name}}-{{environment_name}})" description = "This is a custom role created by the Azure Landing Zones Accelerator for running Bicep What If for the Management Group hierarchy and its associated governance resources such as policy, RBAC etc... You must use the `--validation-level providerNoRbac` (Az CLI 2.75.0 or later) or `-ValidationLevel providerNoRbac` (Az PowerShell 13.4.0 or later (Az.Resources 7.10.0 or later)) flag when running Bicep What If with this role." permissions = { @@ -521,30 +414,6 @@ variable "custom_role_definitions_bicep" { not_actions = [] } } - alz_subscription_owner = { - name = "Azure Landing Zones Subscription Owner ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for Writing in platform subscriptions." - permissions = { - actions = [ - "*" - ] - not_actions = [] - } - } - alz_subscription_reader = { - name = "Azure Landing Zones Subscription What If ({{service_name}}-{{environment_name}})" - description = "This is a custom role created by the Azure Landing Zones Accelerator for running Bicep What If for the Management Group hierarchy and its associated governance resources such as policy, RBAC etc... You must use the `--validation-level providerNoRbac` (Az CLI 2.75.0 or later) or `-ValidationLevel providerNoRbac` (Az PowerShell 13.4.0 or later (Az.Resources 7.10.0 or later)) flag when running Bicep What If with this role." - permissions = { - actions = [ - "*/read", - "Microsoft.Resources/deployments/whatIf/action", - "Microsoft.Resources/deployments/validate/action", - "Microsoft.Resources/subscriptions/operationResults/read", - "Microsoft.Management/operationResults/*/read" - ] - not_actions = [] - } - } } } @@ -674,50 +543,83 @@ variable "role_assignments_terraform" { Map of role assignment configurations where: - **Key**: Assignment identifier (e.g., 'plan_management_group') - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (e.g., 'Owner', 'Contributor') - `custom_role_definition_key` (string) - Key from custom_role_definitions_terraform - `user_assigned_managed_identity_key` (string) - Managed identity key ('plan' or 'apply') - `scope` (string) - Assignment scope ('management_group' or 'subscription') - Default includes 4 assignments: - - Plan and apply access for management group operations - - Plan and apply access for subscription operations + Default includes 2 assignments: + - Plan and apply access + EOT type = map(object({ - custom_role_definition_key = string + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) user_assigned_managed_identity_key = string scope = string })) default = { - plan_management_group = { - custom_role_definition_key = "alz_management_group_reader" + plan = { + built_in_role_definition_name = "Reader" user_assigned_managed_identity_key = "plan" scope = "management_group" } - apply_management_group = { - custom_role_definition_key = "alz_management_group_contributor" + apply = { + built_in_role_definition_name = "Owner" user_assigned_managed_identity_key = "apply" scope = "management_group" } - plan_subscription = { - custom_role_definition_key = "alz_subscription_reader" + } +} + +variable "role_assignments_bicep" { + description = <<-EOT + **(Optional)** RBAC role assignments for Bicep-based deployments. + + Map of role assignment configurations where: + - **Key**: Assignment identifier (e.g., 'plan_management_group') + - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (e.g., 'Owner', 'Contributor') + - `custom_role_definition_key` (string) - Key from custom_role_definitions_bicep + - `user_assigned_managed_identity_key` (string) - Managed identity key ('plan' or 'apply') + - `scope` (string) - Assignment scope ('management_group' or 'subscription') + + Default includes 3 assignments: + - Plan and apply access operations + EOT + type = map(object({ + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) + user_assigned_managed_identity_key = string + scope = string + })) + default = { + plan = { + custom_role_definition_key = "Reader" user_assigned_managed_identity_key = "plan" - scope = "subscription" + scope = "management_group" } - apply_subscription = { - custom_role_definition_key = "alz_subscription_owner" + plan_custom = { + custom_role_definition_key = "alz_reader" + user_assigned_managed_identity_key = "plan" + scope = "management_group" + } + apply_management_group = { + custom_role_definition_key = "Owner" user_assigned_managed_identity_key = "apply" - scope = "subscription" + scope = "management_group" } } } -variable "role_assignments_bicep" { +variable "role_assignments_bicep_classic" { description = <<-EOT - **(Optional)** RBAC role assignments for Bicep-based deployments. + **(Optional)** RBAC role assignments for Bicep Classic based deployments. Map of role assignment configurations where: - **Key**: Assignment identifier (e.g., 'plan_management_group') - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (e.g., 'Owner', 'Contributor') - `custom_role_definition_key` (string) - Key from custom_role_definitions_bicep - `user_assigned_managed_identity_key` (string) - Managed identity key ('plan' or 'apply') - `scope` (string) - Assignment scope ('management_group' or 'subscription') @@ -727,7 +629,8 @@ variable "role_assignments_bicep" { - Plan and apply access for subscription operations EOT type = map(object({ - custom_role_definition_key = string + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) user_assigned_managed_identity_key = string scope = string })) @@ -829,3 +732,14 @@ variable "bicep_tenant_role_assignment_role_definition_name" { description = "The name of the Azure role definition to assign at the tenant level for Bicep deployments. This role grants the managed identity permissions to manage Azure Landing Zones resources across the tenant. Common values: 'Landing Zone Management Owner', 'Owner', or a custom role name." default = "Landing Zone Management Owner" } + +variable "terraform_architecture_file_path" { + description = <<-EOT + **(Required)** Relative path to the Terraform architecture definition JSON file within the module folder. + + This file defines the structure and components of the Terraform deployment architecture. + Used for dynamic file manipulation based on architecture specifics. + EOT + type = string + default = "lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" +} diff --git a/modules/azure/management_group.tf b/modules/azure/management_group.tf new file mode 100644 index 0000000..df6c175 --- /dev/null +++ b/modules/azure/management_group.tf @@ -0,0 +1,34 @@ +resource "azapi_resource" "intermediate_root_management_group" { + count = var.intermediate_root_management_group_creation_enabled ? 1 : 0 + name = var.intermediate_root_management_group_id + parent_id = "/" + type = "Microsoft.Management/managementGroups@2023-04-01" + body = { + properties = { + details = { + parent = { + id = "/providers/Microsoft.Management/managementGroups/${var.root_parent_management_group_id}" + } + } + displayName = var.intermediate_root_management_group_display_name + } + } + + replace_triggers_external_values = [ + var.root_parent_management_group_id, + ] + response_export_values = [] + retry = { + error_message_regex = [ + "AuthorizationFailed", # Avoids a eventual consistency issue where a recently created management group is not yet available for a GET operation. + "Permission to Microsoft.Management/managementGroups on resources of type 'Write' is required on the management group or its ancestors." + ] + } + + timeouts { + create = "60m" + delete = "5m" + read = "60m" + update = "5m" + } +} diff --git a/modules/azure/role_assignments.tf b/modules/azure/role_assignments.tf index 3f1c3e0..8f623c5 100644 --- a/modules/azure/role_assignments.tf +++ b/modules/azure/role_assignments.tf @@ -1,6 +1,7 @@ locals { role_assignments = { for key, value in var.role_assignments : key => { user_assigned_managed_identity_key = value.user_assigned_managed_identity_key + built_in_role_definition_name = value.built_in_role_definition_name custom_role_definition_key = value.custom_role_definition_key scope = value.scope principal_id = azurerm_user_assigned_identity.alz[value.user_assigned_managed_identity_key].principal_id @@ -11,12 +12,14 @@ locals { for princial_key, principal_value in var.additional_role_assignment_principal_ids : { composite_key = "${value.scope}-${value.custom_role_definition_key}-${princial_key}" user_assigned_managed_identity_key = "${value.scope}-${value.custom_role_definition_key}-${princial_key}" + built_in_role_definition_name = value.built_in_role_definition_name custom_role_definition_key = value.custom_role_definition_key scope = value.scope principal_id = principal_value } ]]) : assignment.composite_key => { user_assigned_managed_identity_key = assignment.user_assigned_managed_identity_key + built_in_role_definition_name = assignment.built_in_role_definition_name custom_role_definition_key = assignment.custom_role_definition_key scope = assignment.scope principal_id = assignment.principal_id @@ -27,10 +30,11 @@ locals { subscription_role_assignments = { for assignment in flatten([ for key, value in local.combined_role_assignments : [ for subscription_id, subscription in data.azurerm_subscription.alz : { - key = "${value.user_assigned_managed_identity_key}-${value.custom_role_definition_key}-${subscription_id}" - scope = subscription.id - role_definition_id = "${subscription.id}${azurerm_role_definition.alz[value.custom_role_definition_key].role_definition_resource_id}" - principal_id = value.principal_id + key = "${value.user_assigned_managed_identity_key}-${value.custom_role_definition_key}-${subscription_id}" + scope = subscription.id + role_definition_id = value.built_in_role_definition_name ? "${subscription.id}${azurerm_role_definition.alz[value.custom_role_definition_key].role_definition_resource_id}" : null + role_definition_name = value.built_in_role_definition_name + principal_id = value.principal_id } ] if value.scope == "subscription" ]) : assignment.key => { @@ -41,19 +45,21 @@ locals { management_group_role_assignments = { for key, value in local.combined_role_assignments : key => { - scope = data.azurerm_management_group.alz.id - role_definition_id = azurerm_role_definition.alz[value.custom_role_definition_key].role_definition_resource_id - principal_id = value.principal_id + scope = var.intermediate_root_management_group_creation_enabled ? azapi_resource.intermediate_root_management_group.id : data.azurerm_management_group.alz.id + role_definition_id = value.built_in_role_definition_name == null ? azurerm_role_definition.alz[value.custom_role_definition_key].role_definition_resource_id : null + role_definition_name = value.built_in_role_definition_name + principal_id = value.principal_id } if value.scope == "management_group" } final_role_assignments = merge(local.subscription_role_assignments, local.management_group_role_assignments) } resource "azurerm_role_assignment" "alz" { - for_each = local.final_role_assignments - scope = each.value.scope - role_definition_id = each.value.role_definition_id - principal_id = each.value.principal_id + for_each = local.final_role_assignments + scope = each.value.scope + role_definition_id = each.value.role_definition_id + role_definition_name = each.value.role_definition_name + principal_id = each.value.principal_id } # Bicep needs some permissions at tenant level to deploy management groups and policy in the same deployment diff --git a/modules/azure/role_definitions.tf b/modules/azure/role_definitions.tf index cc01271..c19c111 100644 --- a/modules/azure/role_definitions.tf +++ b/modules/azure/role_definitions.tf @@ -1,7 +1,7 @@ resource "azurerm_role_definition" "alz" { for_each = var.custom_role_definitions name = each.value.name - scope = data.azurerm_management_group.alz.id + scope = var.intermediate_root_management_group_creation_enabled ? azapi_resource.intermediate_root_management_group.id : data.azurerm_management_group.alz.id description = each.value.description permissions { diff --git a/modules/azure/variables.tf b/modules/azure/variables.tf index 80d7087..513686c 100644 --- a/modules/azure/variables.tf +++ b/modules/azure/variables.tf @@ -271,6 +271,36 @@ variable "root_parent_management_group_id" { type = string } +variable "intermediate_root_management_group_creation_enabled" { + description = <<-EOT + **(Optional, default: `true`)** Controls whether to create an intermediate root management group under the root parent. + + When enabled, creates a dedicated management group to serve as the root for all Azure Landing Zones management groups and subscriptions. + Helps isolate landing zone resources from other management groups in the tenant. + EOT + type = bool + default = true +} + +variable "intermediate_root_management_group_id" { + description = <<-EOT + **(Required)** The ID of the intermediate root management group to create under the root parent. + + This management group serves as the root for all Azure Landing Zones management groups and subscriptions. + Must be unique within the tenant. + EOT + type = string +} + +variable "intermediate_root_management_group_display_name" { + description = <<-EOT + **(Required)** The display name for the intermediate root management group. + + This is a human-readable name shown in the Azure portal for the management group. + EOT + type = string +} + variable "resource_providers" { description = <<-EOT **(Optional, default: comprehensive list)** The resource providers to register in the Azure subscription. @@ -556,12 +586,14 @@ variable "role_assignments" { Map structure: - **Key**: Assignment identifier (e.g., 'plan_management_group') - **Value**: Object containing: + - `built_in_role_definition_name` (string) - Name of built-in role (optional) - `custom_role_definition_key` (string) - Key from custom_role_definitions - `user_assigned_managed_identity_key` (string) - Key from user_assigned_managed_identities - `scope` (string) - Assignment scope ('management_group' or 'subscription') EOT type = map(object({ - custom_role_definition_key = string + built_in_role_definition_name = optional(string) + custom_role_definition_key = optional(string) user_assigned_managed_identity_key = string scope = string })) diff --git a/modules/file_manipulation/locals.intermediate_root_management_group.tf b/modules/file_manipulation/locals.intermediate_root_management_group.tf new file mode 100644 index 0000000..ab6e121 --- /dev/null +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -0,0 +1,27 @@ +locals { + is_terraform_iac_type = var.iac_type == "terraform" + terraform_architecture = local.is_terraform_iac_type ? endswith(var.terraform_architecture_file_path, ".yaml") || endswith(var.terraform_architecture_file_path, ".json") ? yamldecode(file("${var.module_folder_path}/${var.terraform_architecture_file_path}")) : jsondecode(file("${var.module_folder_path}/${var.terraform_architecture_file_path}")) : null + terraform_intermediate_root_management_group = local.is_terraform_iac_type ? ({ for management_group in local.terraform_architecture.management_groups : management_group.id => management_group if management_group.parent_id == null })[0] : null + intermediate_root_management_group = local.is_terraform_iac_type ? { + id = local.terraform_intermediate_root_management_group.id + display_name = local.terraform_intermediate_root_management_group.display_name + } : { + id = try("${local.bicep_parameters.management_group_id_prefix}${local.bicep_parameters.management_group_int_root_id}${local.bicep_parameters.management_group_id_postfix}", "") + display_name = try("${local.bicep_parameters.management_group_name_prefix}${local.bicep_parameters.management_group_int_root_name}${local.bicep_parameters.management_group_name_postfix}", "") + } +} + +locals { + import_block = < Date: Thu, 15 Jan 2026 13:01:20 +0000 Subject: [PATCH 02/22] fmt --- .../locals.intermediate_root_management_group.tf | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/file_manipulation/locals.intermediate_root_management_group.tf b/modules/file_manipulation/locals.intermediate_root_management_group.tf index ab6e121..3993319 100644 --- a/modules/file_manipulation/locals.intermediate_root_management_group.tf +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -12,16 +12,16 @@ locals { } locals { - import_block = < Date: Thu, 15 Jan 2026 13:32:41 +0000 Subject: [PATCH 03/22] add lib to test --- .github/tests/scripts/generate-matrix.ps1 | 4 ++-- .github/workflows/end-to-end-test.yml | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/tests/scripts/generate-matrix.ps1 b/.github/tests/scripts/generate-matrix.ps1 index ab8d2e7..bd67f43 100644 --- a/.github/tests/scripts/generate-matrix.ps1 +++ b/.github/tests/scripts/generate-matrix.ps1 @@ -48,7 +48,7 @@ $combinations = [ordered]@{ infrastructureAsCode = @("terraform") agentType = @("public", "private", "none") operatingSystem = @("ubuntu") - starterModule = @("test_nested") + starterModule = @("test") regions = @("multi") terraformVersion = @("latest") deployAzureResources = @("true") @@ -58,7 +58,7 @@ $combinations = [ordered]@{ infrastructureAsCode = @("terraform") agentType = @("public", "private", "none") operatingSystem = @("ubuntu") - starterModule = @("test_nested") + starterModule = @("test") regions = @("multi") terraformVersion = @("latest") deployAzureResources = @("true") diff --git a/.github/workflows/end-to-end-test.yml b/.github/workflows/end-to-end-test.yml index 3b6ebad..0d0ecea 100644 --- a/.github/workflows/end-to-end-test.yml +++ b/.github/workflows/end-to-end-test.yml @@ -297,6 +297,16 @@ jobs: $Inputs["child_management_group_display_name"] = "E2E Test" $Inputs["resource_group_location"] = $location + # Terraform + if($infrastructureAsCode -eq "terraform") { + $Inputs["resource_name_suffix"] = $uniqueId + $architectureFilePath = "${{ env.STARTER_MODULE_FOLDER }}/lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" + $architectureFile = Get-Content -Path $architectureFilePath -Raw + $architectureFile = $architectureFile.Replace("id: test", "id: test-$uniqueId") + $architectureFile = $architectureFile.Replace("display_name: Test", "display_name: Test $uniqueId") + $architectureFile | Out-File -FilePath $architectureFilePath -Encoding utf8 -Force + } + # Bicep Classic if($infrastructureAsCode -eq "bicep-classic") { $Inputs["Prefix"] = $uniqueId From 053046440bb77f6c494decc5f773ee881a3cc3c6 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 15 Jan 2026 13:33:09 +0000 Subject: [PATCH 04/22] fmt --- .../locals.intermediate_root_management_group.tf | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/file_manipulation/locals.intermediate_root_management_group.tf b/modules/file_manipulation/locals.intermediate_root_management_group.tf index 3993319..ab6e121 100644 --- a/modules/file_manipulation/locals.intermediate_root_management_group.tf +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -12,16 +12,16 @@ locals { } locals { - import_block = < Date: Thu, 15 Jan 2026 13:39:19 +0000 Subject: [PATCH 05/22] typo --- modules/azure/role_assignments.tf | 2 +- modules/azure/role_definitions.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/azure/role_assignments.tf b/modules/azure/role_assignments.tf index 8f623c5..670ee8f 100644 --- a/modules/azure/role_assignments.tf +++ b/modules/azure/role_assignments.tf @@ -45,7 +45,7 @@ locals { management_group_role_assignments = { for key, value in local.combined_role_assignments : key => { - scope = var.intermediate_root_management_group_creation_enabled ? azapi_resource.intermediate_root_management_group.id : data.azurerm_management_group.alz.id + scope = var.intermediate_root_management_group_creation_enabled ? azapi_resource.intermediate_root_management_group[0].id : data.azurerm_management_group.alz.id role_definition_id = value.built_in_role_definition_name == null ? azurerm_role_definition.alz[value.custom_role_definition_key].role_definition_resource_id : null role_definition_name = value.built_in_role_definition_name principal_id = value.principal_id diff --git a/modules/azure/role_definitions.tf b/modules/azure/role_definitions.tf index c19c111..953d708 100644 --- a/modules/azure/role_definitions.tf +++ b/modules/azure/role_definitions.tf @@ -1,7 +1,7 @@ resource "azurerm_role_definition" "alz" { for_each = var.custom_role_definitions name = each.value.name - scope = var.intermediate_root_management_group_creation_enabled ? azapi_resource.intermediate_root_management_group.id : data.azurerm_management_group.alz.id + scope = var.intermediate_root_management_group_creation_enabled ? azapi_resource.intermediate_root_management_group[0].id : data.azurerm_management_group.alz.id description = each.value.description permissions { From 60cdac81433202e3fe8d17bf0d4b6a37880975c2 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 15 Jan 2026 13:43:53 +0000 Subject: [PATCH 06/22] fix path --- .github/workflows/end-to-end-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/end-to-end-test.yml b/.github/workflows/end-to-end-test.yml index 0d0ecea..fa0763f 100644 --- a/.github/workflows/end-to-end-test.yml +++ b/.github/workflows/end-to-end-test.yml @@ -300,7 +300,7 @@ jobs: # Terraform if($infrastructureAsCode -eq "terraform") { $Inputs["resource_name_suffix"] = $uniqueId - $architectureFilePath = "${{ env.STARTER_MODULE_FOLDER }}/lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" + $architectureFilePath = "${{ env.STARTER_MODULE_FOLDER }}/templates/$starterModule/lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" $architectureFile = Get-Content -Path $architectureFilePath -Raw $architectureFile = $architectureFile.Replace("id: test", "id: test-$uniqueId") $architectureFile = $architectureFile.Replace("display_name: Test", "display_name: Test $uniqueId") From e1b424f1d29df54dce365f059c171409d6241d32 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 15 Jan 2026 14:24:59 +0000 Subject: [PATCH 07/22] fix data type --- .../locals.intermediate_root_management_group.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/file_manipulation/locals.intermediate_root_management_group.tf b/modules/file_manipulation/locals.intermediate_root_management_group.tf index ab6e121..c576cd9 100644 --- a/modules/file_manipulation/locals.intermediate_root_management_group.tf +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -1,7 +1,7 @@ locals { is_terraform_iac_type = var.iac_type == "terraform" terraform_architecture = local.is_terraform_iac_type ? endswith(var.terraform_architecture_file_path, ".yaml") || endswith(var.terraform_architecture_file_path, ".json") ? yamldecode(file("${var.module_folder_path}/${var.terraform_architecture_file_path}")) : jsondecode(file("${var.module_folder_path}/${var.terraform_architecture_file_path}")) : null - terraform_intermediate_root_management_group = local.is_terraform_iac_type ? ({ for management_group in local.terraform_architecture.management_groups : management_group.id => management_group if management_group.parent_id == null })[0] : null + terraform_intermediate_root_management_group = local.is_terraform_iac_type ? ([ for management_group in local.terraform_architecture.management_groups : management_group if management_group.parent_id == null ])[0] : null intermediate_root_management_group = local.is_terraform_iac_type ? { id = local.terraform_intermediate_root_management_group.id display_name = local.terraform_intermediate_root_management_group.display_name From 9cc08195cafb46e721104ca1cc08f6ddfbd891a1 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 15 Jan 2026 14:29:00 +0000 Subject: [PATCH 08/22] fix roles --- alz/azuredevops/variables.tf | 7 ++----- alz/github/variables.tf | 7 ++----- alz/local/variables.tf | 7 ++----- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/alz/azuredevops/variables.tf b/alz/azuredevops/variables.tf index 394947c..c773121 100644 --- a/alz/azuredevops/variables.tf +++ b/alz/azuredevops/variables.tf @@ -640,11 +640,8 @@ variable "custom_role_definitions_bicep" { description = "This is a custom role created by the Azure Landing Zones Accelerator for running Bicep What If for the Management Group hierarchy and its associated governance resources such as policy, RBAC etc... You must use the `--validation-level providerNoRbac` (Az CLI 2.75.0 or later) or `-ValidationLevel providerNoRbac` (Az PowerShell 13.4.0 or later (Az.Resources 7.10.0 or later)) flag when running Bicep What If with this role." permissions = { actions = [ - "*/read", "Microsoft.Resources/deployments/whatIf/action", "Microsoft.Resources/deployments/validate/action", - "Microsoft.Resources/subscriptions/operationResults/read", - "Microsoft.Management/operationResults/*/read" ] not_actions = [] } @@ -830,7 +827,7 @@ variable "role_assignments_bicep" { })) default = { plan = { - custom_role_definition_key = "Reader" + built_in_role_definition_name = "Reader" user_assigned_managed_identity_key = "plan" scope = "management_group" } @@ -840,7 +837,7 @@ variable "role_assignments_bicep" { scope = "management_group" } apply_management_group = { - custom_role_definition_key = "Owner" + built_in_role_definition_name = "Owner" user_assigned_managed_identity_key = "apply" scope = "management_group" } diff --git a/alz/github/variables.tf b/alz/github/variables.tf index 28d7a93..11dd9fc 100644 --- a/alz/github/variables.tf +++ b/alz/github/variables.tf @@ -692,11 +692,8 @@ variable "custom_role_definitions_bicep" { description = "This is a custom role created by the Azure Landing Zones Accelerator for running Bicep What If for the Management Group hierarchy and its associated governance resources such as policy, RBAC etc... You must use the `--validation-level providerNoRbac` (Az CLI 2.75.0 or later) or `-ValidationLevel providerNoRbac` (Az PowerShell 13.4.0 or later (Az.Resources 7.10.0 or later)) flag when running Bicep What If with this role." permissions = { actions = [ - "*/read", "Microsoft.Resources/deployments/whatIf/action", "Microsoft.Resources/deployments/validate/action", - "Microsoft.Resources/subscriptions/operationResults/read", - "Microsoft.Management/operationResults/*/read" ] not_actions = [] } @@ -882,7 +879,7 @@ variable "role_assignments_bicep" { })) default = { plan = { - custom_role_definition_key = "Reader" + built_in_role_definition_name = "Reader" user_assigned_managed_identity_key = "plan" scope = "management_group" } @@ -892,7 +889,7 @@ variable "role_assignments_bicep" { scope = "management_group" } apply_management_group = { - custom_role_definition_key = "Owner" + built_in_role_definition_name = "Owner" user_assigned_managed_identity_key = "apply" scope = "management_group" } diff --git a/alz/local/variables.tf b/alz/local/variables.tf index d1a4968..12eddf9 100644 --- a/alz/local/variables.tf +++ b/alz/local/variables.tf @@ -405,11 +405,8 @@ variable "custom_role_definitions_bicep" { description = "This is a custom role created by the Azure Landing Zones Accelerator for running Bicep What If for the Management Group hierarchy and its associated governance resources such as policy, RBAC etc... You must use the `--validation-level providerNoRbac` (Az CLI 2.75.0 or later) or `-ValidationLevel providerNoRbac` (Az PowerShell 13.4.0 or later (Az.Resources 7.10.0 or later)) flag when running Bicep What If with this role." permissions = { actions = [ - "*/read", "Microsoft.Resources/deployments/whatIf/action", "Microsoft.Resources/deployments/validate/action", - "Microsoft.Resources/subscriptions/operationResults/read", - "Microsoft.Management/operationResults/*/read" ] not_actions = [] } @@ -595,7 +592,7 @@ variable "role_assignments_bicep" { })) default = { plan = { - custom_role_definition_key = "Reader" + built_in_role_definition_name = "Reader" user_assigned_managed_identity_key = "plan" scope = "management_group" } @@ -605,7 +602,7 @@ variable "role_assignments_bicep" { scope = "management_group" } apply_management_group = { - custom_role_definition_key = "Owner" + built_in_role_definition_name = "Owner" user_assigned_managed_identity_key = "apply" scope = "management_group" } From 98f9abd07eb4852040e886bd19b1217ca1d1e7fa Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 15 Jan 2026 14:55:26 +0000 Subject: [PATCH 09/22] fix formatting --- .../locals.intermediate_root_management_group.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/file_manipulation/locals.intermediate_root_management_group.tf b/modules/file_manipulation/locals.intermediate_root_management_group.tf index c576cd9..c9d18b4 100644 --- a/modules/file_manipulation/locals.intermediate_root_management_group.tf +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -14,8 +14,8 @@ locals { locals { import_block = < Date: Thu, 15 Jan 2026 16:05:30 +0000 Subject: [PATCH 10/22] move subs --- alz/azuredevops/main.tf | 1 + alz/github/main.tf | 1 + alz/local/main.tf | 1 + modules/azure/subscription_placements.tf | 20 ++++++++++++++++++++ modules/azure/variables.tf | 11 +++++++++++ 5 files changed, 34 insertions(+) create mode 100644 modules/azure/subscription_placements.tf diff --git a/alz/azuredevops/main.tf b/alz/azuredevops/main.tf index 707212d..4881972 100644 --- a/alz/azuredevops/main.tf +++ b/alz/azuredevops/main.tf @@ -70,6 +70,7 @@ module "azure" { intermediate_root_management_group_creation_enabled = var.iac_type != "bicep-classic" intermediate_root_management_group_id = module.file_manipulation.intermediate_root_management_group_id intermediate_root_management_group_display_name = module.file_manipulation.intermediate_root_management_group_display_name + move_subscriptions_to_target_management_group = var.iac_type != "bicep-classic" } module "azure_devops" { diff --git a/alz/github/main.tf b/alz/github/main.tf index dc8ec6c..2249a74 100644 --- a/alz/github/main.tf +++ b/alz/github/main.tf @@ -71,6 +71,7 @@ module "azure" { intermediate_root_management_group_creation_enabled = var.iac_type != "bicep-classic" intermediate_root_management_group_id = module.file_manipulation.intermediate_root_management_group_id intermediate_root_management_group_display_name = module.file_manipulation.intermediate_root_management_group_display_name + move_subscriptions_to_target_management_group = var.iac_type != "bicep-classic" } module "github" { diff --git a/alz/local/main.tf b/alz/local/main.tf index 0e38dc6..9812d52 100644 --- a/alz/local/main.tf +++ b/alz/local/main.tf @@ -45,6 +45,7 @@ module "azure" { intermediate_root_management_group_creation_enabled = var.iac_type != "bicep-classic" intermediate_root_management_group_id = module.file_manipulation.intermediate_root_management_group_id intermediate_root_management_group_display_name = module.file_manipulation.intermediate_root_management_group_display_name + move_subscriptions_to_target_management_group = var.iac_type != "bicep-classic" } module "file_manipulation" { diff --git a/modules/azure/subscription_placements.tf b/modules/azure/subscription_placements.tf new file mode 100644 index 0000000..a1ec2a0 --- /dev/null +++ b/modules/azure/subscription_placements.tf @@ -0,0 +1,20 @@ +resource "azapi_resource" "subscription_placement" { + for_each = var.move_subscriptions_to_target_management_group ? { for subscription_id in var.target_subscriptions : subscription_id => subscription_id } : {} + + name = each.value + parent_id = var.intermediate_root_management_group_creation_enabled ? azapi_resource.intermediate_root_management_group[0].id : data.azurerm_management_group.alz.id + type = "Microsoft.Management/managementGroups/subscriptions@2023-04-01" + response_export_values = [] + retry = { + error_message_regex = [ + "AuthorizationFailed", # Avoids a eventual consistency issue where a recently created management group is not yet available for a GET operation. + ] + } + + timeouts { + create = "60m" + delete = "5m" + read = "60m" + update = "5m" + } +} diff --git a/modules/azure/variables.tf b/modules/azure/variables.tf index 513686c..c44c84d 100644 --- a/modules/azure/variables.tf +++ b/modules/azure/variables.tf @@ -271,6 +271,17 @@ variable "root_parent_management_group_id" { type = string } +variable "move_subscriptions_to_target_management_group" { + description = <<-EOT + **(Optional, default: `true`)** Controls whether to move target subscriptions under the intermediate root management group. + + When enabled, subscriptions listed in `target_subscriptions` are moved under the created intermediate root management group. + Ensures all landing zone subscriptions are organized under the same management group hierarchy. + EOT + type = bool + default = true +} + variable "intermediate_root_management_group_creation_enabled" { description = <<-EOT **(Optional, default: `true`)** Controls whether to create an intermediate root management group under the root parent. From 7eb8d874aba6253b18c90852ad3fff16a794f809 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 15 Jan 2026 16:05:54 +0000 Subject: [PATCH 11/22] fmt --- alz/github/variables.tf | 4 ++-- alz/local/main.tf | 2 +- alz/local/variables.tf | 4 ++-- .../locals.intermediate_root_management_group.tf | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/alz/github/variables.tf b/alz/github/variables.tf index 11dd9fc..ef90d39 100644 --- a/alz/github/variables.tf +++ b/alz/github/variables.tf @@ -879,7 +879,7 @@ variable "role_assignments_bicep" { })) default = { plan = { - built_in_role_definition_name = "Reader" + built_in_role_definition_name = "Reader" user_assigned_managed_identity_key = "plan" scope = "management_group" } @@ -889,7 +889,7 @@ variable "role_assignments_bicep" { scope = "management_group" } apply_management_group = { - built_in_role_definition_name = "Owner" + built_in_role_definition_name = "Owner" user_assigned_managed_identity_key = "apply" scope = "management_group" } diff --git a/alz/local/main.tf b/alz/local/main.tf index 9812d52..9772724 100644 --- a/alz/local/main.tf +++ b/alz/local/main.tf @@ -45,7 +45,7 @@ module "azure" { intermediate_root_management_group_creation_enabled = var.iac_type != "bicep-classic" intermediate_root_management_group_id = module.file_manipulation.intermediate_root_management_group_id intermediate_root_management_group_display_name = module.file_manipulation.intermediate_root_management_group_display_name - move_subscriptions_to_target_management_group = var.iac_type != "bicep-classic" + move_subscriptions_to_target_management_group = var.iac_type != "bicep-classic" } module "file_manipulation" { diff --git a/alz/local/variables.tf b/alz/local/variables.tf index 12eddf9..3aca01a 100644 --- a/alz/local/variables.tf +++ b/alz/local/variables.tf @@ -592,7 +592,7 @@ variable "role_assignments_bicep" { })) default = { plan = { - built_in_role_definition_name = "Reader" + built_in_role_definition_name = "Reader" user_assigned_managed_identity_key = "plan" scope = "management_group" } @@ -602,7 +602,7 @@ variable "role_assignments_bicep" { scope = "management_group" } apply_management_group = { - built_in_role_definition_name = "Owner" + built_in_role_definition_name = "Owner" user_assigned_managed_identity_key = "apply" scope = "management_group" } diff --git a/modules/file_manipulation/locals.intermediate_root_management_group.tf b/modules/file_manipulation/locals.intermediate_root_management_group.tf index c9d18b4..fbb439e 100644 --- a/modules/file_manipulation/locals.intermediate_root_management_group.tf +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -1,7 +1,7 @@ locals { is_terraform_iac_type = var.iac_type == "terraform" terraform_architecture = local.is_terraform_iac_type ? endswith(var.terraform_architecture_file_path, ".yaml") || endswith(var.terraform_architecture_file_path, ".json") ? yamldecode(file("${var.module_folder_path}/${var.terraform_architecture_file_path}")) : jsondecode(file("${var.module_folder_path}/${var.terraform_architecture_file_path}")) : null - terraform_intermediate_root_management_group = local.is_terraform_iac_type ? ([ for management_group in local.terraform_architecture.management_groups : management_group if management_group.parent_id == null ])[0] : null + terraform_intermediate_root_management_group = local.is_terraform_iac_type ? ([for management_group in local.terraform_architecture.management_groups : management_group if management_group.parent_id == null])[0] : null intermediate_root_management_group = local.is_terraform_iac_type ? { id = local.terraform_intermediate_root_management_group.id display_name = local.terraform_intermediate_root_management_group.display_name From 358a8842279fbbf97db156cd6116f7866b8f9032 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 15 Jan 2026 17:40:05 +0000 Subject: [PATCH 12/22] bug fixes --- alz/azuredevops/main.tf | 45 +++++++++--------- alz/azuredevops/variables.tf | 11 +++++ alz/github/main.tf | 47 ++++++++++--------- alz/github/variables.tf | 12 +++++ alz/local/main.tf | 31 ++++++------ alz/local/variables.tf | 12 +++++ ...cals.intermediate_root_management_group.tf | 2 +- modules/file_manipulation/variables.tf | 10 ++++ 8 files changed, 109 insertions(+), 61 deletions(-) diff --git a/alz/azuredevops/main.tf b/alz/azuredevops/main.tf index 4881972..da68bd3 100644 --- a/alz/azuredevops/main.tf +++ b/alz/azuredevops/main.tf @@ -103,26 +103,27 @@ module "azure_devops" { } module "file_manipulation" { - source = "../../modules/file_manipulation" - vcs_type = "azuredevops" - files = module.files.files - use_self_hosted_agents_runners = var.use_self_hosted_agents - resource_names = local.resource_names - use_separate_repository_for_templates = var.use_separate_repository_for_templates - iac_type = var.iac_type - module_folder_path = local.starter_module_folder_path - bicep_config_file_path = var.bicep_config_file_path - starter_module_name = var.starter_module_name - project_or_organization_name = var.azure_devops_project_name - root_module_folder_relative_path = var.root_module_folder_relative_path - on_demand_folder_repository = var.on_demand_folder_repository - on_demand_folder_artifact_name = var.on_demand_folder_artifact_name - ci_template_file_name = local.ci_template_file_name - cd_template_file_name = local.cd_template_file_name - pipeline_target_folder_name = local.target_folder_name - bicep_parameters_file_path = var.bicep_parameters_file_path - agent_pool_or_runner_configuration = local.agent_pool_or_runner_configuration - pipeline_files_directory_path = local.pipeline_files_directory_path - pipeline_template_files_directory_path = local.pipeline_template_files_directory_path - terraform_architecture_file_path = var.terraform_architecture_file_path + source = "../../modules/file_manipulation" + vcs_type = "azuredevops" + files = module.files.files + use_self_hosted_agents_runners = var.use_self_hosted_agents + resource_names = local.resource_names + use_separate_repository_for_templates = var.use_separate_repository_for_templates + iac_type = var.iac_type + module_folder_path = local.starter_module_folder_path + bicep_config_file_path = var.bicep_config_file_path + starter_module_name = var.starter_module_name + project_or_organization_name = var.azure_devops_project_name + root_module_folder_relative_path = var.root_module_folder_relative_path + on_demand_folder_repository = var.on_demand_folder_repository + on_demand_folder_artifact_name = var.on_demand_folder_artifact_name + ci_template_file_name = local.ci_template_file_name + cd_template_file_name = local.cd_template_file_name + pipeline_target_folder_name = local.target_folder_name + bicep_parameters_file_path = var.bicep_parameters_file_path + agent_pool_or_runner_configuration = local.agent_pool_or_runner_configuration + pipeline_files_directory_path = local.pipeline_files_directory_path + pipeline_template_files_directory_path = local.pipeline_template_files_directory_path + terraform_architecture_file_path = var.terraform_architecture_file_path + terraform_intermediate_root_management_group_state_resource_path_for_import = var.terraform_intermediate_root_management_group_state_resource_path_for_import } \ No newline at end of file diff --git a/alz/azuredevops/variables.tf b/alz/azuredevops/variables.tf index c773121..3767c53 100644 --- a/alz/azuredevops/variables.tf +++ b/alz/azuredevops/variables.tf @@ -989,3 +989,14 @@ variable "terraform_architecture_file_path" { type = string default = "lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" } + +variable "terraform_intermediate_root_management_group_state_resource_path_for_import" { + description = <<-EOT + **(Optional, default: `null`)** Resource path for the management group in the Terraform architecture. + + Used for generating accurate resource references in Terraform deployments. + Null when not applicable. + EOT + type = string + default = "module.management_groups[0].module.management_groups.azapi_resource.management_groups_level_0" +} diff --git a/alz/github/main.tf b/alz/github/main.tf index 2249a74..bcda742 100644 --- a/alz/github/main.tf +++ b/alz/github/main.tf @@ -104,27 +104,28 @@ module "github" { } module "file_manipulation" { - source = "../../modules/file_manipulation" - vcs_type = "github" - files = module.files.files - use_self_hosted_agents_runners = var.use_self_hosted_runners - resource_names = local.resource_names - use_separate_repository_for_templates = var.use_separate_repository_for_templates - iac_type = var.iac_type - module_folder_path = local.starter_module_folder_path - bicep_config_file_path = var.bicep_config_file_path - starter_module_name = var.starter_module_name - project_or_organization_name = var.github_organization_name - root_module_folder_relative_path = var.root_module_folder_relative_path - on_demand_folder_repository = var.on_demand_folder_repository - on_demand_folder_artifact_name = var.on_demand_folder_artifact_name - ci_template_file_name = local.ci_template_file_name - cd_template_file_name = local.cd_template_file_name - pipeline_target_folder_name = local.target_folder_name - bicep_parameters_file_path = var.bicep_parameters_file_path - agent_pool_or_runner_configuration = local.agent_pool_or_runner_configuration - pipeline_files_directory_path = local.pipeline_files_directory_path - pipeline_template_files_directory_path = local.pipeline_template_files_directory_path - concurrency_value = local.resource_names.storage_container - terraform_architecture_file_path = var.terraform_architecture_file_path + source = "../../modules/file_manipulation" + vcs_type = "github" + files = module.files.files + use_self_hosted_agents_runners = var.use_self_hosted_runners + resource_names = local.resource_names + use_separate_repository_for_templates = var.use_separate_repository_for_templates + iac_type = var.iac_type + module_folder_path = local.starter_module_folder_path + bicep_config_file_path = var.bicep_config_file_path + starter_module_name = var.starter_module_name + project_or_organization_name = var.github_organization_name + root_module_folder_relative_path = var.root_module_folder_relative_path + on_demand_folder_repository = var.on_demand_folder_repository + on_demand_folder_artifact_name = var.on_demand_folder_artifact_name + ci_template_file_name = local.ci_template_file_name + cd_template_file_name = local.cd_template_file_name + pipeline_target_folder_name = local.target_folder_name + bicep_parameters_file_path = var.bicep_parameters_file_path + agent_pool_or_runner_configuration = local.agent_pool_or_runner_configuration + pipeline_files_directory_path = local.pipeline_files_directory_path + pipeline_template_files_directory_path = local.pipeline_template_files_directory_path + concurrency_value = local.resource_names.storage_container + terraform_architecture_file_path = var.terraform_architecture_file_path + terraform_intermediate_root_management_group_state_resource_path_for_import = var.terraform_intermediate_root_management_group_state_resource_path_for_import } diff --git a/alz/github/variables.tf b/alz/github/variables.tf index ef90d39..484ceaa 100644 --- a/alz/github/variables.tf +++ b/alz/github/variables.tf @@ -1041,3 +1041,15 @@ variable "terraform_architecture_file_path" { type = string default = "lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" } + +variable "terraform_intermediate_root_management_group_state_resource_path_for_import" { + description = <<-EOT + **(Optional, default: `null`)** Resource path for the management group in the Terraform architecture. + + Used for generating accurate resource references in Terraform deployments. + Null when not applicable. + EOT + type = string + default = "module.management_groups[0].module.management_groups.azapi_resource.management_groups_level_0" +} + diff --git a/alz/local/main.tf b/alz/local/main.tf index 9772724..ba7a9ea 100644 --- a/alz/local/main.tf +++ b/alz/local/main.tf @@ -49,21 +49,22 @@ module "azure" { } module "file_manipulation" { - source = "../../modules/file_manipulation" - vcs_type = "local" - files = module.files.files - resource_names = local.resource_names - iac_type = var.iac_type - module_folder_path = local.starter_module_folder_path - bicep_config_file_path = var.bicep_config_file_path - starter_module_name = var.starter_module_name - root_module_folder_relative_path = var.root_module_folder_relative_path - on_demand_folder_repository = var.on_demand_folder_repository - on_demand_folder_artifact_name = var.on_demand_folder_artifact_name - pipeline_target_folder_name = local.script_target_folder_name - bicep_parameters_file_path = var.bicep_parameters_file_path - pipeline_files_directory_path = local.script_source_folder_path - terraform_architecture_file_path = var.terraform_architecture_file_path + source = "../../modules/file_manipulation" + vcs_type = "local" + files = module.files.files + resource_names = local.resource_names + iac_type = var.iac_type + module_folder_path = local.starter_module_folder_path + bicep_config_file_path = var.bicep_config_file_path + starter_module_name = var.starter_module_name + root_module_folder_relative_path = var.root_module_folder_relative_path + on_demand_folder_repository = var.on_demand_folder_repository + on_demand_folder_artifact_name = var.on_demand_folder_artifact_name + pipeline_target_folder_name = local.script_target_folder_name + bicep_parameters_file_path = var.bicep_parameters_file_path + pipeline_files_directory_path = local.script_source_folder_path + terraform_architecture_file_path = var.terraform_architecture_file_path + terraform_intermediate_root_management_group_state_resource_path_for_import = var.terraform_intermediate_root_management_group_state_resource_path_for_import } resource "local_file" "alz" { diff --git a/alz/local/variables.tf b/alz/local/variables.tf index 3aca01a..f91125c 100644 --- a/alz/local/variables.tf +++ b/alz/local/variables.tf @@ -740,3 +740,15 @@ variable "terraform_architecture_file_path" { type = string default = "lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" } + +variable "terraform_intermediate_root_management_group_state_resource_path_for_import" { + description = <<-EOT + **(Optional, default: `null`)** Resource path for the management group in the Terraform architecture. + + Used for generating accurate resource references in Terraform deployments. + Null when not applicable. + EOT + type = string + default = "module.management_groups[0].module.management_groups.azapi_resource.management_groups_level_0" +} + diff --git a/modules/file_manipulation/locals.intermediate_root_management_group.tf b/modules/file_manipulation/locals.intermediate_root_management_group.tf index fbb439e..fcc77c8 100644 --- a/modules/file_manipulation/locals.intermediate_root_management_group.tf +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -14,7 +14,7 @@ locals { locals { import_block = < Date: Fri, 16 Jan 2026 17:14:49 +0000 Subject: [PATCH 13/22] Move exists = true method --- .github/workflows/end-to-end-test.yml | 5 ++- ...cals.intermediate_root_management_group.tf | 35 +++++++++++++------ modules/file_manipulation/outputs.tf | 7 +--- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.github/workflows/end-to-end-test.yml b/.github/workflows/end-to-end-test.yml index fa0763f..d42e76a 100644 --- a/.github/workflows/end-to-end-test.yml +++ b/.github/workflows/end-to-end-test.yml @@ -302,8 +302,11 @@ jobs: $Inputs["resource_name_suffix"] = $uniqueId $architectureFilePath = "${{ env.STARTER_MODULE_FOLDER }}/templates/$starterModule/lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" $architectureFile = Get-Content -Path $architectureFilePath -Raw - $architectureFile = $architectureFile.Replace("id: test", "id: test-$uniqueId") + $architectureFile = $architectureFile.Replace("- id: child-test", "- id: child-test-$uniqueId") + $architectureFile = $architectureFile.Replace("display_name: Child Test", "display_name: Child Test $uniqueId") + $architectureFile = $architectureFile.Replace("- id: test", "- id: test-$uniqueId") $architectureFile = $architectureFile.Replace("display_name: Test", "display_name: Test $uniqueId") + $architectureFile = $architectureFile.Replace("parent_id: test", "prefix: $uniqueId") $architectureFile | Out-File -FilePath $architectureFilePath -Encoding utf8 -Force } diff --git a/modules/file_manipulation/locals.intermediate_root_management_group.tf b/modules/file_manipulation/locals.intermediate_root_management_group.tf index fcc77c8..89403f0 100644 --- a/modules/file_manipulation/locals.intermediate_root_management_group.tf +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -1,6 +1,10 @@ +# Get the intermediate root management group from the terraform architecture file or bicep parameters locals { is_terraform_iac_type = var.iac_type == "terraform" - terraform_architecture = local.is_terraform_iac_type ? endswith(var.terraform_architecture_file_path, ".yaml") || endswith(var.terraform_architecture_file_path, ".json") ? yamldecode(file("${var.module_folder_path}/${var.terraform_architecture_file_path}")) : jsondecode(file("${var.module_folder_path}/${var.terraform_architecture_file_path}")) : null + terraform_architecture_file_path = "${var.module_folder_path}/${var.terraform_architecture_file_path}" + terraform_architecture_file_extension = split(".", var.terraform_architecture_file_path)[length(split(".", var.terraform_architecture_file_path)) - 1] + terraform_architecture_file_is_yaml = local.terraform_architecture_file_extension == "yaml" || local.terraform_architecture_file_extension == "yml" + terraform_architecture = local.is_terraform_iac_type ? (local.terraform_architecture_file_is_yaml ? yamldecode(file(local.terraform_architecture_file_path)) : jsondecode(file(local.terraform_architecture_file_path))) : null terraform_intermediate_root_management_group = local.is_terraform_iac_type ? ([for management_group in local.terraform_architecture.management_groups : management_group if management_group.parent_id == null])[0] : null intermediate_root_management_group = local.is_terraform_iac_type ? { id = local.terraform_intermediate_root_management_group.id @@ -11,17 +15,28 @@ locals { } } +# Transform the intermediate root management group in the terraform architecture file to ensure it is marked as existing locals { - import_block = < Date: Thu, 22 Jan 2026 17:36:37 +0000 Subject: [PATCH 14/22] bin redundant role assignments --- modules/azure/storage.tf | 16 ---------------- .../locals.intermediate_root_management_group.tf | 2 +- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/modules/azure/storage.tf b/modules/azure/storage.tf index e606750..ab33a4f 100644 --- a/modules/azure/storage.tf +++ b/modules/azure/storage.tf @@ -71,19 +71,3 @@ resource "azurerm_role_assignment" "alz_storage_container_additional" { role_definition_name = "Storage Blob Data Owner" principal_id = each.value } - -# These role assignments are a temporary addition to handle this issue in the Terraform CLI: https://github.com/hashicorp/terraform/issues/36595 -# They will be removed once the issue has been resolved -resource "azurerm_role_assignment" "alz_storage_reader" { - for_each = var.create_storage_account ? var.user_assigned_managed_identities : {} - scope = azurerm_storage_account.alz[0].id - role_definition_name = "Reader" - principal_id = azurerm_user_assigned_identity.alz[each.key].principal_id -} - -resource "azurerm_role_assignment" "alz_storage_reader_additional" { - for_each = var.create_storage_account ? var.additional_role_assignment_principal_ids : {} - scope = azurerm_storage_account.alz[0].id - role_definition_name = "Reader" - principal_id = each.value -} diff --git a/modules/file_manipulation/locals.intermediate_root_management_group.tf b/modules/file_manipulation/locals.intermediate_root_management_group.tf index 89403f0..90b3571 100644 --- a/modules/file_manipulation/locals.intermediate_root_management_group.tf +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -17,7 +17,7 @@ locals { # Transform the intermediate root management group in the terraform architecture file to ensure it is marked as existing locals { - terraform_management_groups_non_root = local.is_terraform_iac_type ? [ for management_group in local.terraform_architecture.management_groups : management_group if management_group.parent_id != null ] : null + terraform_management_groups_non_root = local.is_terraform_iac_type ? [for management_group in local.terraform_architecture.management_groups : management_group if management_group.parent_id != null] : null terraform_intermediate_root_management_group_updated = local.is_terraform_iac_type ? merge( local.terraform_intermediate_root_management_group, { From 4e74d99f8f8ba0f3223de75fdb8f8539f9a93d79 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 13:27:16 +0000 Subject: [PATCH 15/22] fix ternary --- modules/azure/role_assignments.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/azure/role_assignments.tf b/modules/azure/role_assignments.tf index 670ee8f..6eeac2c 100644 --- a/modules/azure/role_assignments.tf +++ b/modules/azure/role_assignments.tf @@ -32,7 +32,7 @@ locals { for subscription_id, subscription in data.azurerm_subscription.alz : { key = "${value.user_assigned_managed_identity_key}-${value.custom_role_definition_key}-${subscription_id}" scope = subscription.id - role_definition_id = value.built_in_role_definition_name ? "${subscription.id}${azurerm_role_definition.alz[value.custom_role_definition_key].role_definition_resource_id}" : null + role_definition_id = value.built_in_role_definition_name == null ? "${subscription.id}${azurerm_role_definition.alz[value.custom_role_definition_key].role_definition_resource_id}" : null role_definition_name = value.built_in_role_definition_name principal_id = value.principal_id } From f13ae1e9533f10675bae185c40a0d7a644e48894 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 13:39:34 +0000 Subject: [PATCH 16/22] bit of debugging --- .github/workflows/end-to-end-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/end-to-end-test.yml b/.github/workflows/end-to-end-test.yml index d42e76a..7e94b89 100644 --- a/.github/workflows/end-to-end-test.yml +++ b/.github/workflows/end-to-end-test.yml @@ -332,6 +332,9 @@ jobs: $json = ConvertTo-Json $Inputs -Depth 100 $json | Out-File -FilePath inputs.json -Encoding utf8 -Force + Write-Host "Inputs File Content:" + Write-Host $json + shell: pwsh - name: Run ALZ PowerShell From 615f16a21aade3881e4331adb5b21457fc4f1d31 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 13:41:15 +0000 Subject: [PATCH 17/22] more debugging --- .github/workflows/end-to-end-test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/end-to-end-test.yml b/.github/workflows/end-to-end-test.yml index 7e94b89..f57d33d 100644 --- a/.github/workflows/end-to-end-test.yml +++ b/.github/workflows/end-to-end-test.yml @@ -308,6 +308,8 @@ jobs: $architectureFile = $architectureFile.Replace("display_name: Test", "display_name: Test $uniqueId") $architectureFile = $architectureFile.Replace("parent_id: test", "prefix: $uniqueId") $architectureFile | Out-File -FilePath $architectureFilePath -Encoding utf8 -Force + Write-Host "Modified Architecture File Content:" + Write-Host $architectureFile } # Bicep Classic From 19745f54c3daa6b1d095f9b04d72adcf2848e28b Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 13:56:24 +0000 Subject: [PATCH 18/22] fix test --- .github/workflows/end-to-end-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/end-to-end-test.yml b/.github/workflows/end-to-end-test.yml index f57d33d..9b1ecd2 100644 --- a/.github/workflows/end-to-end-test.yml +++ b/.github/workflows/end-to-end-test.yml @@ -324,7 +324,8 @@ jobs: # Bicep if($infrastructureAsCode -eq "bicep") { $Inputs["network_type"] = "none" - $Inputs["intermediate_root_management_group_id"] = "alz-$uniqueId" + $Inputs["management_group_int_root_id"] = "alz-$uniqueId" + $Inputs["management_group_int_root_name"] = "alz-$uniqueId" $Inputs["management_group_id_prefix"] = "" $Inputs["management_group_id_postfix"] = "" $Inputs["management_group_name_prefix"] = "" From e498b37e3cfcd2725bb3db51732cb504aa9815ff Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 14:10:23 +0000 Subject: [PATCH 19/22] bug in role assignment --- modules/azure/role_assignments.tf | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/azure/role_assignments.tf b/modules/azure/role_assignments.tf index 6eeac2c..16a5549 100644 --- a/modules/azure/role_assignments.tf +++ b/modules/azure/role_assignments.tf @@ -38,9 +38,10 @@ locals { } ] if value.scope == "subscription" ]) : assignment.key => { - scope = assignment.scope - role_definition_id = assignment.role_definition_id - principal_id = assignment.principal_id + scope = assignment.scope + role_definition_id = assignment.role_definition_id + role_definition_name = assignment.role_definition_name + principal_id = assignment.principal_id } } management_group_role_assignments = { From 48ca0c8306d1a0d8576b43ced4110f5f10f9e644 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 15:15:59 +0000 Subject: [PATCH 20/22] fix test --- .github/workflows/end-to-end-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/end-to-end-test.yml b/.github/workflows/end-to-end-test.yml index 9b1ecd2..606a1ba 100644 --- a/.github/workflows/end-to-end-test.yml +++ b/.github/workflows/end-to-end-test.yml @@ -306,7 +306,7 @@ jobs: $architectureFile = $architectureFile.Replace("display_name: Child Test", "display_name: Child Test $uniqueId") $architectureFile = $architectureFile.Replace("- id: test", "- id: test-$uniqueId") $architectureFile = $architectureFile.Replace("display_name: Test", "display_name: Test $uniqueId") - $architectureFile = $architectureFile.Replace("parent_id: test", "prefix: $uniqueId") + $architectureFile = $architectureFile.Replace("parent_id: test", "parent_id: test-$uniqueId") $architectureFile | Out-File -FilePath $architectureFilePath -Encoding utf8 -Force Write-Host "Modified Architecture File Content:" Write-Host $architectureFile From b2bd82bf77753ba0f551c11b541d7932ede7d570 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 16:30:39 +0000 Subject: [PATCH 21/22] fix first run check --- .../helpers/bicep-first-deployment-check.yaml | 15 ++++++++++----- .../bicep-first-deployment-check/action.yaml | 17 +++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-first-deployment-check.yaml b/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-first-deployment-check.yaml index c86eea7..18432ef 100644 --- a/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-first-deployment-check.yaml +++ b/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-first-deployment-check.yaml @@ -14,16 +14,21 @@ steps: Inline: | $intRootMgId = "$(MANAGEMENT_GROUP_ID_PREFIX)$(INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID)$(MANAGEMENT_GROUP_ID_POSTFIX)" - $managementGroups = Get-AzManagementGroup - $intRootMg = $managementGroups | Where-Object { $_.Name -eq $intRootMgId } + $managementGroup = Get-AzManagementGroup -GroupName $intRootMgId -Expand $firstDeployment = $true - if($intRootMg -eq $null) { + if($managementGroup -eq $null) { Write-Warning "Cannot find the $intRootMgId Management Group, so assuming this is the first deployment." } else { - Write-Host "Found the $intRootMgId Management Group, so assuming this is not the first deployment." - $firstDeployment = $false + Write-Host "Found the $intRootMgId Management Group." + $children = $managementGroup.Children | Where-Object { $_.Type -eq "Microsoft.Management/managementGroups" } + if($children.Count -gt 0) { + Write-Host "The $intRootMgId Management Group has child management groups, so this is NOT the first deployment." + $firstDeployment = $false + } else { + Write-Host "The $intRootMgId Management Group has NO child management groups, so assuming this is the first deployment." + } } Write-Host "##vso[task.setvariable variable=FIRST_DEPLOYMENT;]$firstDeployment" diff --git a/alz/github/actions/bicep/templates/actions/bicep-first-deployment-check/action.yaml b/alz/github/actions/bicep/templates/actions/bicep-first-deployment-check/action.yaml index a247b14..adc9743 100644 --- a/alz/github/actions/bicep/templates/actions/bicep-first-deployment-check/action.yaml +++ b/alz/github/actions/bicep/templates/actions/bicep-first-deployment-check/action.yaml @@ -17,16 +17,21 @@ runs: inlineScript: | $intRootMgId = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX - $managementGroups = Get-AzManagementGroup - $intRootMg = $managementGroups | Where-Object { $_.Name -eq $intRootMgId } + $managementGroup = Get-AzManagementGroup -GroupName $intRootMgId -Expand $firstDeployment = $true - if($intRootMg -eq $null) { - Write-Warning "Cannot find the $intRootMgId Management Group, so assuming this is the first deployment. We must skip checking some deployments since their dependent resources do not exist yet." + if($managementGroup -eq $null) { + Write-Warning "Cannot find the $intRootMgId Management Group, so assuming this is the first deployment." } else { - Write-Host "Found the $intRootMgId Management Group, so assuming this is not the first deployment." - $firstDeployment = $false + Write-Host "Found the $intRootMgId Management Group." + $children = $managementGroup.Children | Where-Object { $_.Type -eq "Microsoft.Management/managementGroups" } + if($children.Count -gt 0) { + Write-Host "The $intRootMgId Management Group has child management groups, so this is NOT the first deployment." + $firstDeployment = $false + } else { + Write-Host "The $intRootMgId Management Group has NO child management groups, so assuming this is the first deployment." + } } echo "firstDeployment=$firstDeployment" >> $env:GITHUB_ENV env: From e08e85b4c358fcd898f981d851e2dd4cdd8a00f4 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 23 Jan 2026 18:23:05 +0000 Subject: [PATCH 22/22] fix composite keys and bicep mg --- .../bicep/templates/cd-template.yaml | 2 -- .../bicep/templates/ci-template.yaml | 1 - .../bicep/templates/helpers/bicep-deploy.yaml | 26 +++++------------- .../actions/bicep-deploy/action.yaml | 27 +++++-------------- .../templates/workflows/cd-template.yaml | 2 -- .../templates/workflows/ci-template.yaml | 1 - alz/local/scripts-bicep/bicep-deploy.ps1 | 16 +++++------ alz/local/scripts-bicep/deploy-local.ps1 | 1 - modules/azure/role_assignments.tf | 6 ++--- modules/file_manipulation/locals.bicep.tf | 1 - 10 files changed, 23 insertions(+), 60 deletions(-) diff --git a/alz/azuredevops/pipelines/bicep/templates/cd-template.yaml b/alz/azuredevops/pipelines/bicep/templates/cd-template.yaml index 22196c2..b1bb460 100644 --- a/alz/azuredevops/pipelines/bicep/templates/cd-template.yaml +++ b/alz/azuredevops/pipelines/bicep/templates/cd-template.yaml @@ -67,7 +67,6 @@ stages: serviceConnection: '${service_connection_name_plan}' templateFilePath: '${script_file.templateFilePath}' templateParametersFilePath: '${script_file.templateParametersFilePath}' - managementGroupId: '${script_file.managementGroupIdVariable}' subscriptionId: '${script_file.subscriptionIdVariable}' resourceGroupName: '${script_file.resourceGroupNameVariable}' location: '$(LOCATION)' @@ -128,7 +127,6 @@ stages: serviceConnection: '${service_connection_name_apply}' templateFilePath: '${script_file.templateFilePath}' templateParametersFilePath: '${script_file.templateParametersFilePath}' - managementGroupId: '${script_file.managementGroupIdVariable}' subscriptionId: '${script_file.subscriptionIdVariable}' resourceGroupName: '${script_file.resourceGroupNameVariable}' location: '$(LOCATION)' diff --git a/alz/azuredevops/pipelines/bicep/templates/ci-template.yaml b/alz/azuredevops/pipelines/bicep/templates/ci-template.yaml index 7a2225d..58c5cf1 100644 --- a/alz/azuredevops/pipelines/bicep/templates/ci-template.yaml +++ b/alz/azuredevops/pipelines/bicep/templates/ci-template.yaml @@ -82,7 +82,6 @@ stages: serviceConnection: '${service_connection_name_plan}' templateFilePath: '${script_file.templateFilePath}' templateParametersFilePath: '${script_file.templateParametersFilePath}' - managementGroupId: '${script_file.managementGroupIdVariable}' subscriptionId: '${script_file.subscriptionIdVariable}' resourceGroupName: '${script_file.resourceGroupNameVariable}' location: '$(LOCATION)' diff --git a/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml b/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml index ff15744..087c85e 100644 --- a/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml +++ b/alz/azuredevops/pipelines/bicep/templates/helpers/bicep-deploy.yaml @@ -10,9 +10,6 @@ parameters: type: string - name: templateParametersFilePath type: string - - name: managementGroupId - type: string - default: '' - name: subscriptionId type: string default: '' @@ -71,7 +68,8 @@ steps: } # Generate deployment stack name - $deploymentPrefix = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX + $intRootMgId = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX + $deploymentPrefix = $intRootMgId $deploymentNameBase = "$${{ parameters.name }}".Replace(" ", "-") $deploymentNameMaxLength = 64 - $deploymentPrefix.Length - 1 if ($deploymentNameBase.Length -gt $deploymentNameMaxLength) { @@ -89,7 +87,7 @@ steps: Write-Host "Deployment Name: $deploymentName" -ForegroundColor DarkGray Write-Host "Template File Path: $${{ parameters.templateFilePath }}" -ForegroundColor DarkGray Write-Host "Template Parameters File Path: $${{ parameters.templateParametersFilePath }}" -ForegroundColor DarkGray - Write-Host "Management Group Id: $${{ parameters.managementGroupId }}" -ForegroundColor DarkGray + Write-Host "Management Group Id: $intRootMgId" -ForegroundColor DarkGray Write-Host "Subscription Id: $${{ parameters.subscriptionId }}" -ForegroundColor DarkGray Write-Host "Resource Group Name: $${{ parameters.resourceGroupName }}" -ForegroundColor DarkGray Write-Host "Location: $${{ parameters.location }}" -ForegroundColor DarkGray @@ -128,14 +126,9 @@ steps: try { switch ($deploymentType) { "managementGroup" { - $targetManagementGroupId = "$${{ parameters.managementGroupId }}" - if ([string]::IsNullOrWhiteSpace($targetManagementGroupId)) { - $targetManagementGroupId = (Get-AzContext).Tenant.TenantId - } - Write-Host "Running Management Group What-If: $deploymentName" -ForegroundColor Cyan $whatIfParameters.Location = "$${{ parameters.location }}" - $whatIfParameters.ManagementGroupId = $targetManagementGroupId + $whatIfParameters.ManagementGroupId = $intRootMgId $result = New-AzManagementGroupDeployment @whatIfParameters } "subscription" { @@ -191,15 +184,10 @@ steps: try { switch ($deploymentType) { "managementGroup" { - $targetManagementGroupId = "$${{ parameters.managementGroupId }}" - if ([string]::IsNullOrWhiteSpace($targetManagementGroupId)) { - $targetManagementGroupId = (Get-AzContext).Tenant.TenantId - } - # Clean up all deployments before each deployment to avoid quota issues try { Write-Host "Cleaning up existing deployments in management group..." -ForegroundColor Cyan - $allDeployments = Get-AzManagementGroupDeployment -ManagementGroupId $targetManagementGroupId -ErrorAction SilentlyContinue + $allDeployments = Get-AzManagementGroupDeployment -ManagementGroupId $intRootMgId -ErrorAction SilentlyContinue if ($allDeployments -and $allDeployments.Count -gt 0) { Write-Host "Found $($allDeployments.Count) deployment(s) to clean up" -ForegroundColor Yellow $batchSize = 200 @@ -207,7 +195,7 @@ steps: $batch = $allDeployments | Select-Object -Skip $i -First $batchSize Write-Host " Deleting batch of $($batch.Count) deployments..." -ForegroundColor Gray $batch | ForEach-Object -Parallel { - Remove-AzManagementGroupDeployment -ManagementGroupId $using:targetManagementGroupId -Name $_.DeploymentName -ErrorAction SilentlyContinue + Remove-AzManagementGroupDeployment -ManagementGroupId $using:intRootMgId -Name $_.DeploymentName -ErrorAction SilentlyContinue } -ThrottleLimit 100 } Write-Host "✓ All deployments cleaned up" -ForegroundColor Green @@ -219,7 +207,7 @@ steps: } Write-Host "Creating Management Group Deployment Stack: $deploymentName" -ForegroundColor Cyan - $result = New-AzManagementGroupDeploymentStack @stackParameters -ManagementGroupId $targetManagementGroupId -Location "$${{ parameters.location }}" + $result = New-AzManagementGroupDeploymentStack @stackParameters -ManagementGroupId $intRootMgId -Location "$${{ parameters.location }}" } "subscription" { if (-not [string]::IsNullOrWhiteSpace("$${{ parameters.subscriptionId }}")) { diff --git a/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml b/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml index 2a29d90..22b85b5 100644 --- a/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml +++ b/alz/github/actions/bicep/templates/actions/bicep-deploy/action.yaml @@ -14,9 +14,6 @@ inputs: templateParametersFilePath: description: 'The path to the parameters file' required: true - managementGroupId: - description: 'The root parent management group id' - required: true subscriptionId: description: 'The subscription id' required: true @@ -66,7 +63,8 @@ runs: } # Generate deployment stack name - $deploymentPrefix = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX + $intRootMgId = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX + $deploymentPrefix = $intRootMgId $deploymentNameBase = ($env:NAME).Replace(" ", "-") $deploymentNameMaxLength = 64 - $deploymentPrefix.Length - 1 if ($deploymentNameBase.Length -gt $deploymentNameMaxLength) { @@ -84,7 +82,7 @@ runs: Write-Host "Deployment Name: $deploymentName" -ForegroundColor DarkGray Write-Host "Template File Path: $env:TEMPLATE_FILE_PATH" -ForegroundColor DarkGray Write-Host "Template Parameters File Path: $env:TEMPLATE_PARAMETERS_FILE_PATH" -ForegroundColor DarkGray - Write-Host "Management Group Id: $env:MANAGEMENT_GROUP_ID" -ForegroundColor DarkGray + Write-Host "Management Group Id: $intRootMgId" -ForegroundColor DarkGray Write-Host "Subscription Id: $env:SUBSCRIPTION_ID" -ForegroundColor DarkGray Write-Host "Resource Group Name: $env:RESOURCE_GROUP_NAME" -ForegroundColor DarkGray Write-Host "Location: $env:LOCATION" -ForegroundColor DarkGray @@ -123,14 +121,9 @@ runs: try { switch ($deploymentType) { "managementGroup" { - $targetManagementGroupId = $env:MANAGEMENT_GROUP_ID - if ([string]::IsNullOrWhiteSpace($targetManagementGroupId)) { - $targetManagementGroupId = (Get-AzContext).Tenant.TenantId - } - Write-Host "Running Management Group What-If: $deploymentName" -ForegroundColor Cyan $whatIfParameters.Location = $env:LOCATION - $whatIfParameters.ManagementGroupId = $targetManagementGroupId + $whatIfParameters.ManagementGroupId = $intRootMgId $result = New-AzManagementGroupDeployment @whatIfParameters } "subscription" { @@ -209,15 +202,10 @@ runs: try { switch ($deploymentType) { "managementGroup" { - $targetManagementGroupId = $env:MANAGEMENT_GROUP_ID - if ([string]::IsNullOrWhiteSpace($targetManagementGroupId)) { - $targetManagementGroupId = (Get-AzContext).Tenant.TenantId - } - # Clean up all deployments before each deployment to avoid quota issues try { Write-Host "Cleaning up existing deployments in management group..." -ForegroundColor Cyan - $allDeployments = Get-AzManagementGroupDeployment -ManagementGroupId $targetManagementGroupId -ErrorAction SilentlyContinue + $allDeployments = Get-AzManagementGroupDeployment -ManagementGroupId $intRootMgId -ErrorAction SilentlyContinue if ($allDeployments -and $allDeployments.Count -gt 0) { Write-Host "Found $($allDeployments.Count) deployment(s) to clean up" -ForegroundColor Yellow $batchSize = 200 @@ -225,7 +213,7 @@ runs: $batch = $allDeployments | Select-Object -Skip $i -First $batchSize Write-Host " Deleting batch of $($batch.Count) deployments..." -ForegroundColor Gray $batch | ForEach-Object -Parallel { - Remove-AzManagementGroupDeployment -ManagementGroupId $using:targetManagementGroupId -Name $_.DeploymentName -ErrorAction SilentlyContinue + Remove-AzManagementGroupDeployment -ManagementGroupId $using:intRootMgId -Name $_.DeploymentName -ErrorAction SilentlyContinue } -ThrottleLimit 100 } Write-Host "✓ All deployments cleaned up" -ForegroundColor Green @@ -237,7 +225,7 @@ runs: } Write-Host "Creating Management Group Deployment Stack: $deploymentName" -ForegroundColor Cyan - $result = New-AzManagementGroupDeploymentStack @stackParameters -ManagementGroupId $targetManagementGroupId -Location $env:LOCATION + $result = New-AzManagementGroupDeploymentStack @stackParameters -ManagementGroupId $intRootMgId -Location $env:LOCATION } "subscription" { if (-not [string]::IsNullOrWhiteSpace($env:SUBSCRIPTION_ID)) { @@ -340,7 +328,6 @@ runs: DISPLAY_NAME: $${{ inputs.displayName }} TEMPLATE_FILE_PATH: $${{ inputs.templateFilePath }} TEMPLATE_PARAMETERS_FILE_PATH: $${{ inputs.templateParametersFilePath }} - MANAGEMENT_GROUP_ID: $${{ inputs.managementGroupId }} SUBSCRIPTION_ID: $${{ inputs.subscriptionId }} RESOURCE_GROUP_NAME: $${{ inputs.resourceGroupName }} LOCATION: $${{ inputs.location }} diff --git a/alz/github/actions/bicep/templates/workflows/cd-template.yaml b/alz/github/actions/bicep/templates/workflows/cd-template.yaml index a3b1ccf..302261f 100644 --- a/alz/github/actions/bicep/templates/workflows/cd-template.yaml +++ b/alz/github/actions/bicep/templates/workflows/cd-template.yaml @@ -65,7 +65,6 @@ jobs: displayName: '${script_file.displayName}' templateFilePath: '${script_file.templateFilePath}' templateParametersFilePath: '${script_file.templateParametersFilePath}' - managementGroupId: '${script_file.managementGroupIdVariable}' subscriptionId: '${script_file.subscriptionIdVariable}' resourceGroupName: '${script_file.resourceGroupNameVariable}' location: '$${{ env.LOCATION }}' @@ -123,7 +122,6 @@ jobs: displayName: '${script_file.displayName}' templateFilePath: '${script_file.templateFilePath}' templateParametersFilePath: '${script_file.templateParametersFilePath}' - managementGroupId: '${script_file.managementGroupIdVariable}' subscriptionId: '${script_file.subscriptionIdVariable}' resourceGroupName: '${script_file.resourceGroupNameVariable}' location: '$${{ env.LOCATION }}' diff --git a/alz/github/actions/bicep/templates/workflows/ci-template.yaml b/alz/github/actions/bicep/templates/workflows/ci-template.yaml index d497504..bc43865 100644 --- a/alz/github/actions/bicep/templates/workflows/ci-template.yaml +++ b/alz/github/actions/bicep/templates/workflows/ci-template.yaml @@ -85,7 +85,6 @@ jobs: displayName: '${script_file.displayName}' templateFilePath: '${script_file.templateFilePath}' templateParametersFilePath: '${script_file.templateParametersFilePath}' - managementGroupId: '${script_file.managementGroupIdVariable}' subscriptionId: '${script_file.subscriptionIdVariable}' resourceGroupName: '${script_file.resourceGroupNameVariable}' location: '$${{ env.LOCATION }}' diff --git a/alz/local/scripts-bicep/bicep-deploy.ps1 b/alz/local/scripts-bicep/bicep-deploy.ps1 index cae976f..b36c9e3 100644 --- a/alz/local/scripts-bicep/bicep-deploy.ps1 +++ b/alz/local/scripts-bicep/bicep-deploy.ps1 @@ -3,7 +3,6 @@ param( [string]$displayName, [string]$templateFilePath, [string]$templateParametersFilePath, - [string]$managementGroupId, [string]$subscriptionId, [string]$resourceGroupName, [string]$location, @@ -16,6 +15,8 @@ $templateRoot = Split-Path -Parent $scriptRoot $templateFilePath = Join-Path $templateRoot $templateFilePath $templateParametersFilePath = Join-Path $templateRoot $templateParametersFilePath +$intRootMgId = $env:MANAGEMENT_GROUP_ID_PREFIX + $env:INTERMEDIATE_ROOT_MANAGEMENT_GROUP_ID + $env:MANAGEMENT_GROUP_ID_POSTFIX + Write-Host "<---------------------------------------------------------------------------->" -ForegroundColor Blue Write-Host "Starting deployment stack for $displayName..." -ForegroundColor Blue Write-Host "<---------------------------------------------------------------------------->" -ForegroundColor Blue @@ -24,7 +25,7 @@ Write-Host "" Write-Host "Display Name: $displayName" -ForegroundColor DarkGray Write-Host "Template File Path: $templateFilePath" -ForegroundColor DarkGray Write-Host "Template Parameters File Path: $templateParametersFilePath" -ForegroundColor DarkGray -Write-Host "Management Group Id: $managementGroupId" -ForegroundColor DarkGray +Write-Host "Management Group Id: $intRootMgId" -ForegroundColor DarkGray Write-Host "Subscription Id: $subscriptionId" -ForegroundColor DarkGray Write-Host "Resource Group Name: $resourceGroupName" -ForegroundColor DarkGray Write-Host "Location: $location" -ForegroundColor DarkGray @@ -85,15 +86,10 @@ while ($retryCount -lt $retryMax) { try { switch ($deploymentType) { "managementGroup" { - $targetManagementGroupId = $managementGroupId - if ([string]::IsNullOrWhiteSpace($targetManagementGroupId)) { - $targetManagementGroupId = (Get-AzContext).Tenant.TenantId - } - # Clean up all deployments before each deployment to avoid quota issues try { Write-Host "Cleaning up existing deployments in management group..." -ForegroundColor Cyan - $allDeployments = Get-AzManagementGroupDeployment -ManagementGroupId $targetManagementGroupId -ErrorAction SilentlyContinue + $allDeployments = Get-AzManagementGroupDeployment -ManagementGroupId $intRootMgId -ErrorAction SilentlyContinue if ($allDeployments -and $allDeployments.Count -gt 0) { Write-Host "Found $($allDeployments.Count) deployment(s) to clean up" -ForegroundColor Yellow $batchSize = 200 @@ -101,7 +97,7 @@ while ($retryCount -lt $retryMax) { $batch = $allDeployments | Select-Object -Skip $i -First $batchSize Write-Host " Deleting batch of $($batch.Count) deployments..." -ForegroundColor Gray $batch | ForEach-Object -Parallel { - Remove-AzManagementGroupDeployment -ManagementGroupId $using:targetManagementGroupId -Name $_.DeploymentName -ErrorAction SilentlyContinue + Remove-AzManagementGroupDeployment -ManagementGroupId $using:intRootMgId -Name $_.DeploymentName -ErrorAction SilentlyContinue } -ThrottleLimit 100 } Write-Host "✓ All deployments cleaned up" -ForegroundColor Green @@ -112,7 +108,7 @@ while ($retryCount -lt $retryMax) { Write-Warning "Could not clean up deployments: $($_.Exception.Message)" } - $result = New-AzManagementGroupDeploymentStack @stackParameters -ManagementGroupId $targetManagementGroupId -Location $location -Verbose + $result = New-AzManagementGroupDeploymentStack @stackParameters -ManagementGroupId $intRootMgId -Location $location -Verbose } "subscription" { if (-not [string]::IsNullOrWhiteSpace($subscriptionId)) { diff --git a/alz/local/scripts-bicep/deploy-local.ps1 b/alz/local/scripts-bicep/deploy-local.ps1 index 63eaa8c..6c59cbf 100644 --- a/alz/local/scripts-bicep/deploy-local.ps1 +++ b/alz/local/scripts-bicep/deploy-local.ps1 @@ -25,7 +25,6 @@ if ($deployApproved -ne "yes") { -displayName "${script_file.displayName}" ` -templateFilePath "${script_file.templateFilePath}" ` -templateParametersFilePath "${script_file.templateParametersFilePath}" ` - -managementGroupId ${script_file.managementGroupIdVariable} ` -subscriptionId ${script_file.subscriptionIdVariable} ` -resourceGroupName ${script_file.resourceGroupNameVariable} ` -location $env:LOCATION ` diff --git a/modules/azure/role_assignments.tf b/modules/azure/role_assignments.tf index 16a5549..d0bbc87 100644 --- a/modules/azure/role_assignments.tf +++ b/modules/azure/role_assignments.tf @@ -10,8 +10,8 @@ locals { additional_role_assignments = { for assignment in flatten([ for key, value in var.role_assignments : [ for princial_key, principal_value in var.additional_role_assignment_principal_ids : { - composite_key = "${value.scope}-${value.custom_role_definition_key}-${princial_key}" - user_assigned_managed_identity_key = "${value.scope}-${value.custom_role_definition_key}-${princial_key}" + composite_key = "${value.scope}-${coalesce(value.custom_role_definition_key, value.built_in_role_definition_name)}-${princial_key}" + user_assigned_managed_identity_key = "${value.scope}-${coalesce(value.custom_role_definition_key, value.built_in_role_definition_name)}-${princial_key}" built_in_role_definition_name = value.built_in_role_definition_name custom_role_definition_key = value.custom_role_definition_key scope = value.scope @@ -30,7 +30,7 @@ locals { subscription_role_assignments = { for assignment in flatten([ for key, value in local.combined_role_assignments : [ for subscription_id, subscription in data.azurerm_subscription.alz : { - key = "${value.user_assigned_managed_identity_key}-${value.custom_role_definition_key}-${subscription_id}" + key = "${value.user_assigned_managed_identity_key}-${coalesce(value.custom_role_definition_key, value.built_in_role_definition_name)}-${subscription_id}" scope = subscription.id role_definition_id = value.built_in_role_definition_name == null ? "${subscription.id}${azurerm_role_definition.alz[value.custom_role_definition_key].role_definition_resource_id}" : null role_definition_name = value.built_in_role_definition_name diff --git a/modules/file_manipulation/locals.bicep.tf b/modules/file_manipulation/locals.bicep.tf index 7ebaaf3..c8e7465 100644 --- a/modules/file_manipulation/locals.bicep.tf +++ b/modules/file_manipulation/locals.bicep.tf @@ -49,7 +49,6 @@ locals { displayName = replace(replace(script_file.displayName, "{{unique_postfix}}", var.resource_names.unique_postfix), "{{time_stamp}}", var.resource_names.time_stamp_formatted) templateFilePath = script_file.templateFilePath templateParametersFilePath = script_file.templateParametersFilePath - managementGroupIdVariable = try(format(local.id_variable_template, script_file.managementGroupId), local.id_variable_template_empty) subscriptionIdVariable = try(format(local.id_variable_template, script_file.subscriptionId), local.id_variable_template_empty) resourceGroupNameVariable = try(format(local.id_variable_template, script_file.resourceGroupName), local.id_variable_template_empty) deploymentType = script_file.deploymentType