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..606a1ba 100644 --- a/.github/workflows/end-to-end-test.yml +++ b/.github/workflows/end-to-end-test.yml @@ -297,6 +297,21 @@ 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 }}/templates/$starterModule/lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" + $architectureFile = Get-Content -Path $architectureFilePath -Raw + $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", "parent_id: test-$uniqueId") + $architectureFile | Out-File -FilePath $architectureFilePath -Encoding utf8 -Force + Write-Host "Modified Architecture File Content:" + Write-Host $architectureFile + } + # Bicep Classic if($infrastructureAsCode -eq "bicep-classic") { $Inputs["Prefix"] = $uniqueId @@ -309,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"] = "" @@ -319,6 +335,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 diff --git a/alz/azuredevops/main.tf b/alz/azuredevops/main.tf index 85db8b3..da68bd3 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,10 @@ 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 + move_subscriptions_to_target_management_group = var.iac_type != "bicep-classic" } module "azure_devops" { @@ -99,25 +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 + 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/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/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/azuredevops/variables.tf b/alz/azuredevops/variables.tf index 09f3aab..3767c53 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,58 +635,13 @@ 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 = { actions = [ - "*/read", "Microsoft.Resources/deployments/whatIf/action", "Microsoft.Resources/deployments/validate/action", - "Microsoft.Resources/subscriptions/operationResults/read", - "Microsoft.Management/operationResults/*/read" - ] - 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 +775,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 = { + built_in_role_definition_name = "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 = { + built_in_role_definition_name = "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 +861,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 +978,25 @@ 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" +} + +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/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/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: 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/github/main.tf b/alz/github/main.tf index 478b6f4..bcda742 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,10 @@ 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 + move_subscriptions_to_target_management_group = var.iac_type != "bicep-classic" } module "github" { @@ -100,26 +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 + 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 8567c8c..484ceaa 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,58 +687,13 @@ 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 = { actions = [ - "*/read", "Microsoft.Resources/deployments/whatIf/action", "Microsoft.Resources/deployments/validate/action", - "Microsoft.Resources/subscriptions/operationResults/read", - "Microsoft.Management/operationResults/*/read" - ] - 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 +827,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 = { + built_in_role_definition_name = "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 = { + built_in_role_definition_name = "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 +913,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 +1030,26 @@ 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" +} + +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 27e9104..ba7a9ea 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,23 +42,29 @@ 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 + move_subscriptions_to_target_management_group = var.iac_type != "bicep-classic" } 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 + 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/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/alz/local/variables.tf b/alz/local/variables.tf index e714b93..f91125c 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,58 +400,13 @@ 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 = { actions = [ - "*/read", "Microsoft.Resources/deployments/whatIf/action", "Microsoft.Resources/deployments/validate/action", - "Microsoft.Resources/subscriptions/operationResults/read", - "Microsoft.Management/operationResults/*/read" - ] - 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 +540,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 = { + built_in_role_definition_name = "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 = { + built_in_role_definition_name = "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 +626,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 +729,26 @@ 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" +} + +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/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..d0bbc87 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 @@ -9,14 +10,16 @@ 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 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,33 +30,37 @@ 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}-${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 + principal_id = value.principal_id } ] 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 = { 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[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 } 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..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 = 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 { 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/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 80d7087..c44c84d 100644 --- a/modules/azure/variables.tf +++ b/modules/azure/variables.tf @@ -271,6 +271,47 @@ 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. + + 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 +597,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.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 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..90b3571 --- /dev/null +++ b/modules/file_manipulation/locals.intermediate_root_management_group.tf @@ -0,0 +1,42 @@ +# 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_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 + 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}", "") + } +} + +# 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_intermediate_root_management_group_updated = local.is_terraform_iac_type ? merge( + local.terraform_intermediate_root_management_group, + { + exists = true + } + ) : null + terraform_architecture_file_content_final = local.is_terraform_iac_type ? merge( + local.terraform_architecture, + { + management_groups = concat( + [local.terraform_intermediate_root_management_group_updated], + local.terraform_management_groups_non_root + ) + } + ) : null + + terraform_architecture_files = local.is_terraform_iac_type ? { + (var.terraform_architecture_file_path) = { + content = local.terraform_architecture_file_is_yaml ? yamlencode(local.terraform_architecture_file_content_final) : jsonencode(local.terraform_architecture_file_content_final) + } + } : {} +} diff --git a/modules/file_manipulation/outputs.tf b/modules/file_manipulation/outputs.tf index 3d78f36..aed13d5 100644 --- a/modules/file_manipulation/outputs.tf +++ b/modules/file_manipulation/outputs.tf @@ -1,9 +1,19 @@ output "repository_files" { description = "Map of repository files with their content" - value = local.repository_files + value = merge(local.repository_files, local.terraform_architecture_files) } output "template_repository_files" { description = "Map of template repository files with their content" value = local.template_repository_files } + +output "intermediate_root_management_group_id" { + description = "The ID of the intermediate root management group from the Terraform architecture" + value = local.intermediate_root_management_group.id +} + +output "intermediate_root_management_group_display_name" { + description = "The display name of the intermediate root management group from the Terraform architecture" + value = local.intermediate_root_management_group.display_name +} diff --git a/modules/file_manipulation/variables.tf b/modules/file_manipulation/variables.tf index b9634f0..ec011c4 100644 --- a/modules/file_manipulation/variables.tf +++ b/modules/file_manipulation/variables.tf @@ -229,4 +229,24 @@ variable "concurrency_value" { EOT type = string default = null -} \ No newline at end of file +} + +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 +} + +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 +}