diff --git a/.github/tests/scripts/create-subscriptions.ps1 b/.github/tests/scripts/create-subscriptions.ps1 new file mode 100644 index 0000000..4f1929e --- /dev/null +++ b/.github/tests/scripts/create-subscriptions.ps1 @@ -0,0 +1,245 @@ +[CmdletBinding()] +param( + [string]$billingScope, + [string]$subscriptionNamePrefix = "accelerator-bootstrap-modules", + [string[]]$subscriptionTypes = @("connectivity", "management", "identity", "security", "bootstrap"), + [string[]]$resourceProviders = @("Microsoft.Security"), + [int]$maxRetries = 5, + [int]$throttleLimit = 2, + [int]$resourceProviderThrottleLimit = 10, + [switch]$planOnly +) + +# Get current Azure account information +$accountInfo = az account show --output json | ConvertFrom-Json + +# Look up tenant name from Graph API domains +$domains = az rest --method get --url "https://graph.microsoft.com/v1.0/domains" --output json | ConvertFrom-Json +$defaultDomain = $domains.value | Where-Object { $_.isDefault -eq $true } +$tenantName = if ($defaultDomain.id) { $defaultDomain.id } else { "(unknown)" } + +Write-Host "" +Write-Host "=== Azure Connection Information ===" -ForegroundColor Cyan +Write-Host "Tenant ID: $($accountInfo.tenantId)" -ForegroundColor Yellow +Write-Host "Tenant Name: $tenantName" -ForegroundColor Yellow +Write-Host "Account: $($accountInfo.user.name)" -ForegroundColor Yellow +Write-Host "Subscription: $($accountInfo.name)" -ForegroundColor Yellow +Write-Host "====================================" -ForegroundColor Cyan +Write-Host "" + +$confirmation = Read-Host "Do you want to continue with this account? (y/n)" +if ($confirmation -ine 'y') { + Write-Host "Operation cancelled by user." -ForegroundColor Red + exit 0 +} + +Write-Host "" + +$tests = ./.github/tests/scripts/generate-matrix.ps1 + +# Get all existing aliases once using REST API with paging (more efficient than checking each one individually) +Write-Host "Fetching existing subscription aliases..." -ForegroundColor Cyan +$existingAliasNames = @() +$aliasUrl = "https://management.azure.com/providers/Microsoft.Subscription/aliases?api-version=2021-10-01" + +do { + $response = az rest --method get --url "`"$aliasUrl`"" | ConvertFrom-Json + if ($response.value) { + $existingAliasNames += $response.value | ForEach-Object { $_.name } + } + $aliasUrl = $response.nextLink +} while ($aliasUrl) + +Write-Host "Fetched $($existingAliasNames.Count) existing aliases." -ForegroundColor Green + +# Build list of subscriptions to create +$subscriptionsToCreate = @() +$existingSubscriptions = @() +$skippedTests = @() + +foreach ($test in $tests) { + # Only create subscriptions for tests that deploy Azure resources + if ($test.deployAzureResources -ne "true") { + $skippedTests += $test.Name + continue + } + + foreach ($subscriptionType in $subscriptionTypes) { + $subscriptionName = "$subscriptionNamePrefix-$($test.ShortNamePrefix)-$subscriptionType" + + if ($existingAliasNames -notcontains $subscriptionName) { + $subscriptionsToCreate += $subscriptionName + } else { + $existingSubscriptions += $subscriptionName + } + } +} + +# Display skipped tests +if ($skippedTests.Count -gt 0) { + Write-Host "" + Write-Host "=== Tests Skipped (deployAzureResources=false) ===" -ForegroundColor Cyan + foreach ($test in $skippedTests) { + Write-Host " - $test" -ForegroundColor Gray + } +} + +# Display existing subscriptions +if ($existingSubscriptions.Count -gt 0) { + Write-Host "" + Write-Host "=== Existing Subscription Aliases (will be skipped) ===" -ForegroundColor Cyan + foreach ($sub in $existingSubscriptions) { + Write-Host " - $sub" -ForegroundColor Gray + } +} + +# Display subscriptions to create +Write-Host "" +if ($subscriptionsToCreate.Count -eq 0) { + Write-Host "No new subscriptions to create. All aliases already exist." -ForegroundColor Green +} + +if ($subscriptionsToCreate.Count -gt 0) { + Write-Host "=== Subscriptions to Create ===" -ForegroundColor Cyan + foreach ($sub in $subscriptionsToCreate) { + Write-Host " - $sub" -ForegroundColor Yellow + } + Write-Host "" + Write-Host "Total: $($subscriptionsToCreate.Count) subscription(s) to create" -ForegroundColor Cyan + Write-Host "" +} + +if ($planOnly) { + Write-Host "Plan only mode - no subscriptions will be created." -ForegroundColor Magenta + return +} + +if ($subscriptionsToCreate.Count -gt 0) { + # Prompt for confirmation before creating + $createConfirmation = Read-Host "Do you want to create these $($subscriptionsToCreate.Count) subscription(s)? (y/n)" + if ($createConfirmation -ine 'y') { + Write-Host "Operation cancelled by user." -ForegroundColor Red + return + } + + Write-Host "" + + # Create a thread-safe hashtable to track rate limiting across parallel tasks + $rateLimitState = [hashtable]::Synchronized(@{ + WaitUntil = [DateTime]::MinValue + }) + + # Create the subscriptions in parallel with retry logic + Write-Host "Creating subscriptions (throttle: $throttleLimit)..." -ForegroundColor Cyan + + $results = $subscriptionsToCreate | ForEach-Object -Parallel { + $subscriptionName = $_ + $scope = $using:billingScope + $retries = $using:maxRetries + $state = $using:rateLimitState + $VerbosePreference = $using:VerbosePreference + $retryCount = 0 + $success = $false + + while (-not $success -and $retryCount -lt $retries) { + # Check if we're in a rate limit wait period + $waitUntil = $state.WaitUntil + if ($waitUntil -gt [DateTime]::Now) { + $waitSeconds = [math]::Ceiling(($waitUntil - [DateTime]::Now).TotalSeconds) + Write-Host "Rate limit active. $subscriptionName waiting $waitSeconds seconds..." -ForegroundColor Yellow + Start-Sleep -Seconds $waitSeconds + } + + Write-Host "Creating subscription: $subscriptionName (Attempt $($retryCount + 1) of $retries)" -ForegroundColor Yellow + $result = az account alias create --name "$subscriptionName" --billing-scope "$scope" --display-name "$subscriptionName" --workload "Production" 2>&1 + + if ($LASTEXITCODE -eq 0) { + $success = $true + Write-Host "Successfully created: $subscriptionName" -ForegroundColor Green + } else { + $errorMessage = $result | Out-String + if ($errorMessage -match "TooManyRequests.*Retry in (\d{2}):(\d{2}):(\d{2})") { + $hours = [int]$Matches[1] + $minutes = [int]$Matches[2] + $seconds = [int]$Matches[3] + $waitSeconds = ($hours * 3600) + ($minutes * 60) + $seconds + (1 * 60) # Add 60 second buffer + Write-Verbose $errorMessage + + # Set the shared rate limit wait time + $newWaitUntil = [DateTime]::Now.AddSeconds($waitSeconds) + if ($newWaitUntil -gt $state.WaitUntil) { + $state.WaitUntil = $newWaitUntil + Write-Host "Rate limit hit! All tasks will wait until $($newWaitUntil.ToString('HH:mm:ss'))" -ForegroundColor Red + } + + Write-Host "Rate limited for $subscriptionName. Waiting $waitSeconds seconds before retry..." -ForegroundColor Yellow + Start-Sleep -Seconds $waitSeconds + $retryCount++ + } else { + Write-Host "Failed to create $subscriptionName : $errorMessage" -ForegroundColor Red + break + } + } + } + + [PSCustomObject]@{ + Name = $subscriptionName + Success = $success + } + } -ThrottleLimit $throttleLimit + + $successCount = ($results | Where-Object { $_.Success }).Count + $failCount = ($results | Where-Object { -not $_.Success }).Count + + Write-Host "" + Write-Host "Subscription creation complete." -ForegroundColor Green + Write-Host " Successful: $successCount" -ForegroundColor Green + if ($failCount -gt 0) { + Write-Host " Failed: $failCount" -ForegroundColor Red + } +} + +# Register resource providers for all subscriptions +if ($resourceProviders.Count -gt 0 -and -not $planOnly) { + Write-Host "" + Write-Host "=== Registering Resource Providers ===" -ForegroundColor Cyan + Write-Host "Providers: $($resourceProviders -join ', ')" -ForegroundColor Yellow + + $allSubscriptionNames = $subscriptionsToCreate + $existingSubscriptions + + # Get subscription IDs for all aliases and register providers + $allSubscriptionNames | ForEach-Object -Parallel { + $subscriptionName = $_ + $providers = $using:resourceProviders + $VerbosePreference = $using:VerbosePreference + + # Get the subscription ID from the alias + $aliasInfo = az account alias show --name "$subscriptionName" --output json 2>$null | ConvertFrom-Json + + if ($aliasInfo -and $aliasInfo.properties.subscriptionId) { + $subscriptionId = $aliasInfo.properties.subscriptionId + + foreach ($provider in $providers) { + # Check if provider is already registered + $providerState = az provider show --namespace $provider --subscription $subscriptionId --query "registrationState" --output tsv 2>$null + + if ($providerState -ine "Registered") { + Write-Host "Registering $provider for $subscriptionName ($subscriptionId)..." -ForegroundColor Yellow + az provider register --namespace $provider --subscription $subscriptionId --output none --wait + if ($LASTEXITCODE -eq 0) { + Write-Host "Registration succeeded: $provider for $subscriptionName" -ForegroundColor Green + } else { + Write-Host "Failed to register: $provider for $subscriptionName" -ForegroundColor Red + } + } else { + Write-Host "Already registered: $provider for $subscriptionName" -ForegroundColor Gray + } + } + } else { + Write-Host "Could not get subscription ID for alias: $subscriptionName" -ForegroundColor Red + } + } -ThrottleLimit $resourceProviderThrottleLimit + + Write-Host "" + Write-Host "Resource provider registration complete." -ForegroundColor Green +} diff --git a/.github/tests/scripts/generate-matrix.ps1 b/.github/tests/scripts/generate-matrix.ps1 index ab8d2e7..de6c9d6 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") @@ -80,7 +80,7 @@ $combinations = [ordered]@{ operatingSystem = @("ubuntu", "windows", "macos") starterModule = @("test") regions = @("multi") - terraformVersion = @("1.5.0") + terraformVersion = @("1.6.0") deployAzureResources = @("false") } local_single_region_tests = [ordered]@{ @@ -164,7 +164,8 @@ function Get-MatrixRecursively { $combination.Name = $name.Trim("-") $combination.Hash = Get-Hash $name - $combination.ShortName = "r" + $combination.Hash.Substring(0, 5).ToLower() + "r" + $runNumber + $combination.ShortNamePrefix = "r" + $combination.Hash.Substring(0, 5).ToLower() + $combination.ShortName = $combination.ShortNamePrefix + "r" + $runNumber $calculatedCombinations += $combination diff --git a/.github/workflows/end-to-end-test.yml b/.github/workflows/end-to-end-test.yml index 3b6ebad..9b21ca0 100644 --- a/.github/workflows/end-to-end-test.yml +++ b/.github/workflows/end-to-end-test.yml @@ -132,6 +132,53 @@ jobs: terraform_wrapper: false if: ${{ matrix.terraformVersion != 'latest' }} + - name: Azure login + uses: azure/login@v2 + with: + client-id: ${{ vars.ARM_CLIENT_ID }} + tenant-id: ${{ vars.ARM_TENANT_ID }} + subscription-id: ${{ vars.ARM_SUBSCRIPTION_ID }} + + - name: Get Subscriptions + run: | + $deployAzureResources = "${{ matrix.deployAzureResources }}" + if($deployAzureResources -eq "false") { + Write-Host "Skipping subscription retrieval as deployAzureResources is set to false." + "ARM_SUBSCRIPTION_ID=${{ vars.ARM_SUBSCRIPTION_ID }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_BOOTSTRAP=${{ vars.ARM_SUBSCRIPTION_ID }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_MANAGEMENT=${{ vars.ARM_SUBSCRIPTION_ID }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_CONNECTIVITY=${{ vars.ARM_SUBSCRIPTION_ID }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_IDENTITY=${{ vars.ARM_SUBSCRIPTION_ID }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_SECURITY=${{ vars.ARM_SUBSCRIPTION_ID }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + exit 0 + } + + $shortNamePrefix = "${{ matrix.ShortNamePrefix }}" + + $subscriptionIDBootstrap = az account show --subscription "accelerator-bootstrap-modules-$shortNamePrefix-bootstrap" | ConvertFrom-Json | Select-Object -ExpandProperty id + $subscriptionIDManagement = az account show --subscription "accelerator-bootstrap-modules-$shortNamePrefix-management" | ConvertFrom-Json | Select-Object -ExpandProperty id + $subscriptionIDConnectivity = az account show --subscription "accelerator-bootstrap-modules-$shortNamePrefix-connectivity" | ConvertFrom-Json | Select-Object -ExpandProperty id + $subscriptionIDIdentity = az account show --subscription "accelerator-bootstrap-modules-$shortNamePrefix-identity" | ConvertFrom-Json | Select-Object -ExpandProperty id + $subscriptionIDSecurity = az account show --subscription "accelerator-bootstrap-modules-$shortNamePrefix-security" | ConvertFrom-Json | Select-Object -ExpandProperty id + + "ARM_SUBSCRIPTION_ID=$subscriptionIDBootstrap" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_BOOTSTRAP=$subscriptionIDBootstrap" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_MANAGEMENT=$subscriptionIDManagement" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_CONNECTIVITY=$subscriptionIDConnectivity" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_IDENTITY=$subscriptionIDIdentity" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "SUBSCRIPTION_ID_SECURITY=$subscriptionIDSecurity" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + shell: pwsh + + - name: Install the Accelerator PowerShell Module + run: | + Write-Host "Installing the Accelerator PowerShell Module" + ${{ env.POWERSHELL_MODULE_FOLDER }}/actions_bootstrap_for_e2e_tests.ps1 | Out-String | Write-Verbose + Invoke-Build -File ${{ env.POWERSHELL_MODULE_FOLDER }}/src/ALZ.build.ps1 BuildAndInstallOnly | Out-String | Write-Verbose + Write-Host "Installed Accelerator Module" + + shell: pwsh + - name: Setup ALZ Module Inputs run: | @@ -144,10 +191,11 @@ jobs: $regions = "${{ matrix.regions }}" $starterModule = "${{ matrix.starterModule }}" $shortName = "${{ matrix.ShortName }}" + $shortNamePrefix = "${{ matrix.ShortNamePrefix }}" $deployAzureResources = "${{ matrix.deployAzureResources }}" $locations_with_aci_zone_support = @( - "uksouth", + # "uksouth", "northeurope", "eastus2", "westeurope", @@ -234,7 +282,6 @@ jobs: } else { $Inputs["starter_locations"] = @($location) } - $Inputs["bootstrap_subscription_id"] = "" $Inputs["service_name"] = "alz" $Inputs["environment_name"] = $uniqueId $Inputs["postfix_number"] = "1" @@ -277,19 +324,18 @@ jobs: $Inputs["apply_approvers"] = @() # Target a nested parent MG for public to test that scenario - $rootParentManagementGroupId = $selfHostedAgents -eq "none" ? "${{ vars.NESTED_ROOT_PARENT_MANAGEMENT_GROUP_ID }}" : "" + $rootParentManagementGroupId = $selfHostedAgents -eq "none" ? "${{ vars.NESTED_ROOT_PARENT_MANAGEMENT_GROUP_ID }}" : "${{ vars.ARM_TENANT_ID}}" + "ROOT_PARENT_MANAGEMENT_GROUP_ID=$rootParentManagementGroupId" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append $Inputs["root_parent_management_group_id"] = $rootParentManagementGroupId - $subscriptionIDManagement = $selfHostedAgents -eq "none" ? "${{ vars.SUBSCRIPTION_ID_NESTED_MANAGEMENT }}" : "${{ vars.SUBSCRIPTION_ID_ROOT_MANAGEMENT }}" - $subscriptionIDConnectivity = $selfHostedAgents -eq "none" ? "${{ vars.SUBSCRIPTION_ID_NESTED_CONNECTIVITY }}" : "${{ vars.SUBSCRIPTION_ID_ROOT_CONNECTIVITY }}" - $subscriptionIDIdentity = $selfHostedAgents -eq "none" ? "${{ vars.SUBSCRIPTION_ID_NESTED_IDENTITY }}" : "${{ vars.SUBSCRIPTION_ID_ROOT_IDENTITY }}" - $subscriptionIDSecurity = $selfHostedAgents -eq "none" ? "${{ vars.SUBSCRIPTION_ID_NESTED_SECURITY }}" : "${{ vars.SUBSCRIPTION_ID_ROOT_SECURITY }}" + # Subscription IDs + $Inputs["bootstrap_subscription_id"] = $env:SUBSCRIPTION_ID_BOOTSTRAP $Inputs["subscription_ids"] = @{ - management = $subscriptionIDManagement - connectivity = $subscriptionIDConnectivity - identity = $subscriptionIDIdentity - security = $subscriptionIDSecurity + management = $env:SUBSCRIPTION_ID_MANAGEMENT + connectivity = $env:SUBSCRIPTION_ID_CONNECTIVITY + identity = $env:SUBSCRIPTION_ID_IDENTITY + security = $env:SUBSCRIPTION_ID_SECURITY } # Test specific inputs @@ -297,6 +343,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 +370,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"] = "test-$uniqueId" + $Inputs["management_group_int_root_name"] = "Test $uniqueId" $Inputs["management_group_id_prefix"] = "" $Inputs["management_group_id_postfix"] = "" $Inputs["management_group_name_prefix"] = "" @@ -319,21 +381,47 @@ 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: Clean Up Pre-Run + run: | + $deployAzureResources = "${{ matrix.deployAzureResources }}" + if($deployAzureResources -eq "false") { + Write-Host "Skipping cleanup as deployAzureResources is set to false." + exit 0 + } + + $uniqueId = $env:UNIQUE_ID + $shortNamePrefix = "${{ matrix.ShortNamePrefix }}" + + Invoke-Build -File ${{ env.POWERSHELL_MODULE_FOLDER }}/src/ALZ.build.ps1 Install | Out-String | Write-Verbose + + Remove-PlatformLandingZone ` + -ManagementGroups "$shortNamePrefix" ` + -Subscriptions $env:SUBSCRIPTION_ID_MANAGEMENT, $env:SUBSCRIPTION_ID_CONNECTIVITY, $env:SUBSCRIPTION_ID_IDENTITY, $env:SUBSCRIPTION_ID_SECURITY ` + -AdditionalSubscriptions $env:SUBSCRIPTION_ID_BOOTSTRAP ` + -SubscriptionsTargetManagementGroup $env:ROOT_PARENT_MANAGEMENT_GROUP_ID ` + -ForceSubscriptionPlacement ` + -DeleteTargetManagementGroups ` + -AllowNoManagementGroupMatch ` + -BypassConfirmation ` + -BypassConfirmationTimeoutSeconds 0 + + shell: pwsh - name: Run ALZ PowerShell run: | + Invoke-Build -File ${{ env.POWERSHELL_MODULE_FOLDER }}/src/ALZ.build.ps1 Install | Out-String | Write-Verbose + # Get Inputs $versionControlSystem = "${{ matrix.versionControlSystem }}" $infrastructureAsCode = "${{ matrix.infrastructureAsCode }}" - # Install the Module - Write-Host "Installing the Accelerator PowerShell Module" - ${{ env.POWERSHELL_MODULE_FOLDER }}/actions_bootstrap_for_e2e_tests.ps1 | Out-String | Write-Verbose - Invoke-Build -File ${{ env.POWERSHELL_MODULE_FOLDER }}/src/ALZ.build.ps1 BuildAndInstallOnly | Out-String | Write-Verbose - Write-Host "Installed Accelerator Module" - # Run the Module in a retry loop $retryCount = 0 $maximumRetries = 10 @@ -391,7 +479,6 @@ jobs: shell: pwsh env: ARM_TENANT_ID: ${{ vars.ARM_TENANT_ID }} - ARM_SUBSCRIPTION_ID: ${{ vars.ARM_SUBSCRIPTION_ID }} ARM_CLIENT_ID: ${{ vars.ARM_CLIENT_ID }} ARM_USE_OIDC: true @@ -460,21 +547,49 @@ jobs: - name: Run Terraform Destroy to Clean Up if: ${{ always() && ((inputs.skip_destroy != '' && inputs.skip_destroy || 'no') == 'no') }} run: | + Invoke-Build -File ${{ env.POWERSHELL_MODULE_FOLDER }}/src/ALZ.build.ps1 Install | Out-String | Write-Verbose # Get Inputs $versionControlSystem = "${{ matrix.versionControlSystem }}" - Write-Host "Installing the Accelerator PowerShell Module" - ${{ env.POWERSHELL_MODULE_FOLDER }}/actions_bootstrap_for_e2e_tests.ps1 | Out-String | Write-Verbose - Invoke-Build -File ${{ env.POWERSHELL_MODULE_FOLDER }}/src/ALZ.build.ps1 BuildAndInstallOnly | Out-String | Write-Verbose - Write-Host "Installed Accelerator Module" - # Run destroy ${{ env.BOOTSTRAP_MODULE_FOLDER }}/.github/tests/scripts/destroy.ps1 -versionControlSystem $versionControlSystem shell: pwsh env: ARM_TENANT_ID: ${{ vars.ARM_TENANT_ID }} - ARM_SUBSCRIPTION_ID: ${{ vars.ARM_SUBSCRIPTION_ID }} ARM_CLIENT_ID: ${{ vars.ARM_CLIENT_ID }} ARM_USE_OIDC: true + + - name: Azure login for a new access token + uses: azure/login@v2 + with: + client-id: ${{ vars.ARM_CLIENT_ID }} + tenant-id: ${{ vars.ARM_TENANT_ID }} + subscription-id: ${{ vars.ARM_SUBSCRIPTION_ID }} + + - name: Clean Up Post-Run + run: | + $deployAzureResources = "${{ matrix.deployAzureResources }}" + if($deployAzureResources -eq "false") { + Write-Host "Skipping cleanup as deployAzureResources is set to false." + exit 0 + } + + $uniqueId = $env:UNIQUE_ID + + Invoke-Build -File ${{ env.POWERSHELL_MODULE_FOLDER }}/src/ALZ.build.ps1 Install | Out-String | Write-Verbose + + Remove-PlatformLandingZone ` + -ManagementGroups "test-$uniqueId" ` + -Subscriptions $env:SUBSCRIPTION_ID_MANAGEMENT, $env:SUBSCRIPTION_ID_CONNECTIVITY, $env:SUBSCRIPTION_ID_IDENTITY, $env:SUBSCRIPTION_ID_SECURITY ` + -AdditionalSubscriptions $env:SUBSCRIPTION_ID_BOOTSTRAP ` + -SubscriptionsTargetManagementGroup $env:ROOT_PARENT_MANAGEMENT_GROUP_ID ` + -ForceSubscriptionPlacement ` + -DeleteTargetManagementGroups ` + -AllowNoManagementGroupMatch ` + -BypassConfirmation ` + -BypassConfirmationTimeoutSeconds 0 + + shell: pwsh + if: always() diff --git a/alz/azuredevops/main.tf b/alz/azuredevops/main.tf index 85db8b3..4881972 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" { @@ -120,4 +124,5 @@ module "file_manipulation" { agent_pool_or_runner_configuration = local.agent_pool_or_runner_configuration pipeline_files_directory_path = local.pipeline_files_directory_path pipeline_template_files_directory_path = local.pipeline_template_files_directory_path + terraform_architecture_file_path = var.terraform_architecture_file_path } \ No newline at end of file diff --git a/alz/azuredevops/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..c773121 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,14 @@ variable "bicep_tenant_role_assignment_role_definition_name" { type = string default = "Landing Zone Management Owner" } + +variable "terraform_architecture_file_path" { + description = <<-EOT + **(Required)** Relative path to the Terraform architecture definition JSON file within the module folder. + + This file defines the structure and components of the Terraform deployment architecture. + Used for dynamic file manipulation based on architecture specifics. + EOT + type = string + default = "lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" +} diff --git a/alz/github/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..2249a74 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" { @@ -122,4 +126,5 @@ module "file_manipulation" { pipeline_files_directory_path = local.pipeline_files_directory_path pipeline_template_files_directory_path = local.pipeline_template_files_directory_path concurrency_value = local.resource_names.storage_container + terraform_architecture_file_path = var.terraform_architecture_file_path } diff --git a/alz/github/variables.tf b/alz/github/variables.tf index 8567c8c..ef90d39 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,14 @@ variable "bicep_tenant_role_assignment_role_definition_name" { type = string default = "Landing Zone Management Owner" } + +variable "terraform_architecture_file_path" { + description = <<-EOT + **(Required)** Relative path to the Terraform architecture definition JSON file within the module folder. + + This file defines the structure and components of the Terraform deployment architecture. + Used for dynamic file manipulation based on architecture specifics. + EOT + type = string + default = "lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" +} diff --git a/alz/local/main.tf b/alz/local/main.tf index 27e9104..79f7ec2 100644 --- a/alz/local/main.tf +++ b/alz/local/main.tf @@ -33,8 +33,8 @@ 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 - additional_role_assignment_principal_ids = var.grant_permissions_to_current_user ? { current_user = data.azurerm_client_config.current.object_id } : {} + 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 = { 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 storage_account_blob_versioning_enabled = var.storage_account_blob_versioning_enabled @@ -42,6 +42,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 "file_manipulation" { @@ -59,6 +63,7 @@ module "file_manipulation" { pipeline_target_folder_name = local.script_target_folder_name bicep_parameters_file_path = var.bicep_parameters_file_path pipeline_files_directory_path = local.script_source_folder_path + terraform_architecture_file_path = var.terraform_architecture_file_path } resource "local_file" "alz" { diff --git a/alz/local/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..fd1152c 100644 --- a/alz/local/variables.tf +++ b/alz/local/variables.tf @@ -188,17 +188,6 @@ variable "postfix_number" { default = 1 } -variable "grant_permissions_to_current_user" { - description = <<-EOT - **(Optional, default: `true`)** Whether to grant permissions to the current Azure CLI user. - - When true, assigns permissions to the currently authenticated user in addition to the managed identities. - Useful for local development and testing. - EOT - type = bool - default = true -} - variable "additional_files" { description = <<-EOT **(Optional, default: `[]`)** Additional files to include in the deployment. @@ -358,11 +347,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 +359,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 +375,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 +389,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 +529,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 +615,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 +718,14 @@ variable "bicep_tenant_role_assignment_role_definition_name" { description = "The name of the Azure role definition to assign at the tenant level for Bicep deployments. This role grants the managed identity permissions to manage Azure Landing Zones resources across the tenant. Common values: 'Landing Zone Management Owner', 'Owner', or a custom role name." default = "Landing Zone Management Owner" } + +variable "terraform_architecture_file_path" { + description = <<-EOT + **(Required)** Relative path to the Terraform architecture definition JSON file within the module folder. + + This file defines the structure and components of the Terraform deployment architecture. + Used for dynamic file manipulation based on architecture specifics. + EOT + type = string + default = "lib/architecture_definitions/alz_custom.alz_architecture_definition.yaml" +} diff --git a/modules/azure/data.tf b/modules/azure/data.tf index d998357..a8c0f35 100644 --- a/modules/azure/data.tf +++ b/modules/azure/data.tf @@ -1,3 +1,5 @@ +data "azurerm_client_config" "alz" {} + data "azurerm_subscription" "alz" { for_each = local.subscription_ids subscription_id = each.key 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..8dfc7e3 100644 --- a/modules/azure/role_assignments.tf +++ b/modules/azure/role_assignments.tf @@ -1,59 +1,46 @@ 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 } } - 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}" - 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 - custom_role_definition_key = assignment.custom_role_definition_key - scope = assignment.scope - principal_id = assignment.principal_id - } } - - combined_role_assignments = merge(local.role_assignments, local.additional_role_assignments) - subscription_role_assignments = { for assignment in flatten([ - for key, value in local.combined_role_assignments : [ + for key, value in local.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 + for key, value in local.role_assignments : key => { + 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..cd70d7f 100644 --- a/modules/azure/storage.tf +++ b/modules/azure/storage.tf @@ -61,29 +61,13 @@ resource "azapi_resource" "storage_account_container" { resource "azurerm_role_assignment" "alz_storage_container" { for_each = var.create_storage_account ? var.user_assigned_managed_identities : {} scope = azapi_resource.storage_account_container[0].id - role_definition_name = "Storage Blob Data Owner" + role_definition_name = "Storage Blob Data Contributor" principal_id = azurerm_user_assigned_identity.alz[each.key].principal_id } resource "azurerm_role_assignment" "alz_storage_container_additional" { for_each = var.create_storage_account ? var.additional_role_assignment_principal_ids : {} scope = azapi_resource.storage_account_container[0].id - 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" + role_definition_name = "Storage Blob Data Contributor" 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.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..f0971fb 100644 --- a/modules/file_manipulation/variables.tf +++ b/modules/file_manipulation/variables.tf @@ -229,4 +229,14 @@ 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 +}