source/Private/AzureResourceAnalysis.ps1
|
function Get-RJArmTemplateWhatIf { <# .SYNOPSIS Runs ARM WhatIf analysis on a template and returns resource changes. .PARAMETER ResourceGroupName The target resource group. .PARAMETER TemplatePath Path to the JSON ARM template file. .PARAMETER Parameters Template parameters hashtable. .OUTPUTS Array of change objects with ChangeType, RelativeResourceId, FullyQualifiedResourceId. Ignore entries are filtered out. #> [CmdletBinding()] [OutputType([System.Object[]])] param( [Parameter(Mandatory)] [string]$ResourceGroupName, [Parameter(Mandatory)] [string]$TemplatePath, [hashtable]$Parameters = @{} ) Write-Verbose "Running ARM WhatIf for template: $(Split-Path $TemplatePath -Leaf)" $result = Get-AzResourceGroupDeploymentWhatIfResult ` -ResourceGroupName $ResourceGroupName ` -TemplateFile $TemplatePath ` -TemplateParameterObject $Parameters ` -ErrorAction Stop # Filter out Ignore entries (Graph extension resources, disabled conditionals) $changes = $result.Changes | Where-Object { $_.ChangeType -ne 'Ignore' } return ,@($changes) } function Get-RJServicePrincipalAnalysis { <# .SYNOPSIS Checks whether the RealmJoin service principal exists in Entra ID. .OUTPUTS PSCustomObject with Exists, DisplayName, ObjectId, ChangeType. #> [CmdletBinding()] [OutputType([PSCustomObject])] param() $rjAzureResourcesAppId = Get-RJAzureResourcesAppId Write-Verbose "Checking RealmJoin service principal (AppId: $rjAzureResourcesAppId)..." $sp = Get-ServicePrincipalByAppId -AppId $rjAzureResourcesAppId if ($sp) { Write-Verbose "Service principal found: $($sp.DisplayName)" return [PSCustomObject]@{ Exists = $true DisplayName = $sp.DisplayName ObjectId = $sp.Id ChangeType = 'Exists' } } return [PSCustomObject]@{ Exists = $false DisplayName = $null ObjectId = $null ChangeType = 'Create' } } function Get-RJAutomationAccountPermissionsManifest { <# .SYNOPSIS Loads the AA managed identity permissions manifest from the bundled JSON file. .OUTPUTS Array of service objects with Name, Id, and AppRoleAssignments. #> [CmdletBinding()] [OutputType([System.Object[]])] param() $manifestPath = Get-ArmTemplatePath -RelativePath "azure-automation-account/RJAutomationAccountPermissionsManifest.json" Write-Verbose "Loading permissions manifest from: $manifestPath" $manifest = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json return ,@($manifest) } function Get-RJAutomationAccountPermissionsAnalysis { <# .SYNOPSIS Analyzes which API permissions need to be assigned to the AA managed identity. .PARAMETER ManagedIdentityPrincipalId The ObjectId of the existing AA managed identity. If null, all permissions are treated as new. .OUTPUTS Array of PSCustomObjects per service: ServiceName, ServiceAppId, PermissionsToAdd, ExistingPermissions, TotalDesired, ChangeType (CreateAll/AddPermissions/NoChanges). #> [CmdletBinding()] [OutputType([System.Object[]])] param( [AllowNull()] [string]$ManagedIdentityPrincipalId ) $manifest = Get-RJAutomationAccountPermissionsManifest # Get current assignments if AA already exists with a managed identity $currentAssignments = @() if ($ManagedIdentityPrincipalId) { $currentAssignments = Get-CurrentAppRoleAssignmentList -ServicePrincipalId $ManagedIdentityPrincipalId } $results = @() foreach ($service in $manifest) { $desiredPermissions = @($service.AppRoleAssignments) if (-not $ManagedIdentityPrincipalId) { # New AA — all permissions need to be created $results += [PSCustomObject]@{ ServiceName = $service.Name ServiceAppId = $service.Id PermissionsToAdd = $desiredPermissions ExistingPermissions = @() TotalDesired = $desiredPermissions.Count ChangeType = 'CreateAll' } continue } # Existing AA — resolve AppRole IDs to permission names for this service $serviceSp = Get-ServicePrincipalByAppId -AppId $service.Id if (-not $serviceSp) { throw "Service principal not found for '$($service.Name)' (AppId: $($service.Id)). Verify the AppId in RJAutomationAccountPermissionsManifest.json is correct." } # Build lookup: AppRoleId -> Permission Name for this service $roleIdToName = @{} foreach ($role in $serviceSp.AppRole) { $roleIdToName[$role.Id] = $role.Value } # Find current permission names assigned to this service $serviceAssignments = $currentAssignments | Where-Object { $_.ResourceId -eq $serviceSp.Id } $existingNames = @() foreach ($assignment in $serviceAssignments) { $name = $roleIdToName[$assignment.AppRoleId] if ($name) { $existingNames += $name } } # Diff: desired minus existing $toAdd = @($desiredPermissions | Where-Object { $_ -notin $existingNames }) $changeType = if ($toAdd.Count -eq 0) { 'NoChanges' } elseif ($existingNames.Count -eq 0) { 'CreateAll' } else { 'AddPermissions' } $results += [PSCustomObject]@{ ServiceName = $service.Name ServiceAppId = $service.Id PermissionsToAdd = $toAdd ExistingPermissions = $existingNames TotalDesired = $desiredPermissions.Count ChangeType = $changeType } } return ,@($results) } function Get-RJAzureResourceAnalysis { <# .SYNOPSIS Analyzes what an Azure resource deployment will do before executing it. .DESCRIPTION Uses ARM WhatIf for infrastructure templates and manual checks for Graph extension resources (service principal, permissions) to build a per-resource analysis showing what would be created vs what already exists. .PARAMETER ResourceGroupName The target resource group name. .PARAMETER DeployAutomationAccount Whether the Automation Account deployment is planned. .PARAMETER DeployLogAnalytics Whether the Log Analytics Workspace deployment is planned. .PARAMETER AutomationAccountName Optional. Custom Automation Account name (auto-generated if not provided). .PARAMETER WorkspaceName Optional. Custom Log Analytics Workspace name (auto-generated if not provided). .PARAMETER DeployAuditLogsDCR Whether to deploy the audit logs DCR. .PARAMETER DeployRunbookLogsDCR Whether to deploy the runbook logs DCR. .PARAMETER DeployOperationalLogsDCR Whether to deploy the operational logs DCR. .OUTPUTS PSCustomObject with ResourceGroupName, ResourceGroupExists, ServicePrincipalAnalysis, ResourceAnalysis array, and deployment parameters. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string]$ResourceGroupName, [bool]$DeployAutomationAccount = $false, [bool]$DeployLogAnalytics = $false, [string]$AutomationAccountName, [string]$WorkspaceName, [bool]$DeployAuditLogsDCR = $DefaultDeployAuditLogsDCR, [bool]$DeployRunbookLogsDCR = $DefaultDeployRunbookLogsDCR, [bool]$DeployOperationalLogsDCR = $DefaultDeployOperationalLogsDCR ) $analysis = [PSCustomObject]@{ ResourceGroupName = $ResourceGroupName ResourceGroupExists = $false DeployAutomationAccount = $DeployAutomationAccount DeployLogAnalytics = $DeployLogAnalytics AutomationAccountName = if ($AutomationAccountName) { $AutomationAccountName } else { "(auto-generated)" } WorkspaceName = if ($WorkspaceName) { $WorkspaceName } else { "(auto-generated)" } DeployAuditLogsDCR = $DeployAuditLogsDCR DeployRunbookLogsDCR = $DeployRunbookLogsDCR DeployOperationalLogsDCR = $DeployOperationalLogsDCR ServicePrincipalAnalysis = $null PermissionsAnalysis = $null ResourceAnalysis = @() } # Check resource group existence try { $null = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction Stop $analysis.ResourceGroupExists = $true } catch { $analysis.ResourceGroupExists = $false return $analysis } # Check RealmJoin service principal (not covered by ARM WhatIf) if ($DeployAutomationAccount -or $DeployLogAnalytics) { $analysis.ServicePrincipalAnalysis = Get-RJServicePrincipalAnalysis } # Use actual SP ObjectId or a dummy GUID for ARM WhatIf template parameter $spObjectId = if ($analysis.ServicePrincipalAnalysis -and $analysis.ServicePrincipalAnalysis.Exists) { $analysis.ServicePrincipalAnalysis.ObjectId } else { '00000000-0000-0000-0000-000000000000' } # ARM WhatIf for Automation Account template if ($DeployAutomationAccount) { $aaTemplatePath = Get-ArmTemplatePath -RelativePath "azure-automation-account/automationAccount.json" $aaParams = @{ rjServicePrincipalId = $spObjectId contributorRoleId = $ContributorRoleId } if ($AutomationAccountName) { $aaParams.automationAccountName = $AutomationAccountName } $aaChanges = Get-RJArmTemplateWhatIf -ResourceGroupName $ResourceGroupName ` -TemplatePath $aaTemplatePath -Parameters $aaParams foreach ($change in $aaChanges) { $resourceName = ($change.RelativeResourceId -split '/')[-1] # Modify is intentionally treated as Exists - ARM WhatIf's Modify signal is # unreliable for our templates (Azure/arm-template-whatif#310); only Create # is trustworthy enough to surface as an actionable change. $changeType = if ($change.ChangeType -eq 'Create') { 'Create' } else { 'Exists' } $analysis.ResourceAnalysis += [PSCustomObject]@{ ResourceType = $change.RelativeResourceId -replace '/[^/]+$', '' ResourceName = $resourceName ChangeType = $changeType FullResourceId = $change.FullyQualifiedResourceId Category = 'AutomationAccount' } } # Check if AA already exists to get its managed identity principal ID $aaResource = $analysis.ResourceAnalysis | Where-Object { $_.Category -eq 'AutomationAccount' -and $_.ResourceType -match 'automationAccounts$' } | Select-Object -First 1 $managedIdentityPrincipalId = $null # Resolve AA name: explicit param or from ARM WhatIf result $aaLookupName = if ($AutomationAccountName) { $AutomationAccountName } elseif ($aaResource) { $aaResource.ResourceName } else { $null } if ($aaResource -and $aaResource.ChangeType -eq 'Exists' -and $aaLookupName) { $aaAccount = Invoke-AzCommandWithNotFoundHandling -ScriptBlock { Get-AzAutomationAccount -ResourceGroupName $ResourceGroupName -Name $aaLookupName -ErrorAction Stop } if ($aaAccount -and $aaAccount.Identity -and $aaAccount.Identity.PrincipalId) { $managedIdentityPrincipalId = $aaAccount.Identity.PrincipalId Write-Verbose "Found managed identity principal ID: $managedIdentityPrincipalId" } } $analysis.PermissionsAnalysis = Get-RJAutomationAccountPermissionsAnalysis -ManagedIdentityPrincipalId $managedIdentityPrincipalId } # ARM WhatIf for Log Analytics Workspace template if ($DeployLogAnalytics) { $lawTemplatePath = Get-ArmTemplatePath -RelativePath "log-analytics-workspace/logAnalyticsWorkspace.json" $lawParams = @{ servicePrincipalId = $spObjectId deployAuditLogsDCR = $DeployAuditLogsDCR deployRunbookLogsDCR = $DeployRunbookLogsDCR deployOperationalLogsDCR = $DeployOperationalLogsDCR logAnalyticsReaderRoleId = $LogAnalyticsReaderRoleId monitoringMetricsPublisherRoleId = $MonitoringMetricsPublisherRoleId } if ($WorkspaceName) { $lawParams.workspaceName = $WorkspaceName } $lawChanges = Get-RJArmTemplateWhatIf -ResourceGroupName $ResourceGroupName ` -TemplatePath $lawTemplatePath -Parameters $lawParams foreach ($change in $lawChanges) { $resourceName = ($change.RelativeResourceId -split '/')[-1] # Modify is intentionally treated as Exists - ARM WhatIf's Modify signal is # unreliable for our templates (Azure/arm-template-whatif#310); only Create # is trustworthy enough to surface as an actionable change. $changeType = if ($change.ChangeType -eq 'Create') { 'Create' } else { 'Exists' } $analysis.ResourceAnalysis += [PSCustomObject]@{ ResourceType = $change.RelativeResourceId -replace '/[^/]+$', '' ResourceName = $resourceName ChangeType = $changeType FullResourceId = $change.FullyQualifiedResourceId Category = 'LogAnalytics' } } } return $analysis } function Show-RJAzureResourceAnalysis { <# .SYNOPSIS Displays the Azure resource deployment analysis in a formatted, color-coded output. .PARAMETER Analysis The analysis object from Get-RJAzureResourceAnalysis. #> [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject]$Analysis ) Write-Host "" Write-Host "Azure Resource Deployment Analysis:" -ForegroundColor Cyan Write-Host ("=" * 80) -ForegroundColor Gray # Resource group status if (-not $Analysis.ResourceGroupExists) { Write-Host "" Write-Host "Resource Group: $($Analysis.ResourceGroupName)" -ForegroundColor Cyan Write-Host ("-" * 60) -ForegroundColor Gray Write-Host " ✗ Resource group not found" -ForegroundColor Red Write-Host " The resource group must exist before deployment." -ForegroundColor Yellow Write-Host " Create it with: New-AzResourceGroup -Name '$($Analysis.ResourceGroupName)' -Location '<location>'" -ForegroundColor Gray Write-Host "" return } Write-Host "" Write-Host "Resource Group: $($Analysis.ResourceGroupName)" -ForegroundColor Cyan Write-Host ("-" * 60) -ForegroundColor Gray Write-Host " ✓ Resource group exists" -ForegroundColor Green # Service principal status (Graph extension — not in ARM WhatIf) if ($Analysis.ServicePrincipalAnalysis) { $sp = $Analysis.ServicePrincipalAnalysis $spDisplayName = if ($sp.Exists) { "($($sp.DisplayName))" } else { "" } Write-Host "" Write-Host "Service Principal: $(Get-RJAzureResourcesAppId) $spDisplayName" -ForegroundColor Cyan Write-Host ("-" * 60) -ForegroundColor Gray if ($sp.ChangeType -eq 'Create') { Write-Host "→ Service principal will be CREATED" -ForegroundColor Yellow } else { Write-Host " ✓ No changes required" -ForegroundColor Green } } # Group ARM WhatIf results by category if ($Analysis.DeployAutomationAccount) { $aaResources = $Analysis.ResourceAnalysis | Where-Object { $_.Category -eq 'AutomationAccount' } $aaDisplayName = if ($Analysis.AutomationAccountName -ne '(auto-generated)') { $Analysis.AutomationAccountName } else { "(auto-generated)" } Write-Host "" Write-Host "Automation Account: $aaDisplayName" -ForegroundColor Cyan Write-Host ("-" * 60) -ForegroundColor Gray if ($aaResources.Count -gt 0) { foreach ($resource in $aaResources) { Write-Host " 📋 Resource: $($resource.ResourceName)" -ForegroundColor White if ($resource.ChangeType -eq 'Create') { Write-Host " → Will be created" -ForegroundColor Yellow } else { Write-Host " ✓ No changes required" -ForegroundColor Green } Write-Host " Type: $($resource.ResourceType)" -ForegroundColor Gray if ($resource -ne $aaResources[-1]) { Write-Host "" } } } else { Write-Host " (no ARM WhatIf data available)" -ForegroundColor Gray } # Permissions analysis for AA managed identity if ($Analysis.PermissionsAnalysis) { Write-Host "" Write-Host "Automation Account Permissions:" -ForegroundColor Cyan Write-Host ("-" * 60) -ForegroundColor Gray foreach ($service in $Analysis.PermissionsAnalysis) { Write-Host " 📋 Service: $($service.ServiceName)" -ForegroundColor White switch ($service.ChangeType) { 'NoChanges' { Write-Host " ✓ No changes required" -ForegroundColor Green Write-Host " Current permissions ($($service.TotalDesired)): $($service.ExistingPermissions -join ', ')" -ForegroundColor Gray } 'CreateAll' { Write-Host " → Will assign all permissions (new managed identity)" -ForegroundColor Yellow foreach ($perm in $service.PermissionsToAdd) { Write-Host " + $perm" -ForegroundColor Green } } 'AddPermissions' { Write-Host " → Will add $($service.PermissionsToAdd.Count) permission(s)" -ForegroundColor Yellow foreach ($perm in $service.PermissionsToAdd) { Write-Host " + $perm" -ForegroundColor Green } if ($service.ExistingPermissions.Count -gt 0) { Write-Host " Existing ($($service.ExistingPermissions.Count)): $($service.ExistingPermissions -join ', ')" -ForegroundColor Gray } } } if ($service -ne $Analysis.PermissionsAnalysis[-1]) { Write-Host "" } } } } if ($Analysis.DeployLogAnalytics) { $laResources = $Analysis.ResourceAnalysis | Where-Object { $_.Category -eq 'LogAnalytics' } $laDisplayName = if ($Analysis.WorkspaceName -ne '(auto-generated)') { $Analysis.WorkspaceName } else { "(auto-generated)" } Write-Host "" Write-Host "Log Analytics Workspace: $laDisplayName" -ForegroundColor Cyan Write-Host ("-" * 60) -ForegroundColor Gray $dcrList = @() if ($Analysis.DeployAuditLogsDCR) { $dcrList += 'Audit' } if ($Analysis.DeployRunbookLogsDCR) { $dcrList += 'Runbook' } if ($Analysis.DeployOperationalLogsDCR) { $dcrList += 'Operational' } $dcrText = if ($dcrList.Count -gt 0) { $dcrList -join ', ' } else { "(none)" } Write-Host " DCRs: $dcrText" -ForegroundColor Gray if ($laResources.Count -gt 0) { Write-Host "" foreach ($resource in $laResources) { Write-Host " 📋 Resource: $($resource.ResourceName)" -ForegroundColor White if ($resource.ChangeType -eq 'Create') { Write-Host " → Will be created" -ForegroundColor Yellow } else { Write-Host " ✓ No changes required" -ForegroundColor Green } Write-Host " Type: $($resource.ResourceType)" -ForegroundColor Gray if ($resource -ne $laResources[-1]) { Write-Host "" } } } else { Write-Host " (no ARM WhatIf data available)" -ForegroundColor Gray } } # Summary $resourcesToCreate = $Analysis.ResourceAnalysis | Where-Object { $_.ChangeType -eq 'Create' } $createCount = @($resourcesToCreate).Count $existsCount = ($Analysis.ResourceAnalysis | Where-Object { $_.ChangeType -eq 'Exists' }).Count if ($Analysis.ServicePrincipalAnalysis) { if ($Analysis.ServicePrincipalAnalysis.ChangeType -eq 'Create') { $createCount++ } else { $existsCount++ } } # Count permission changes $permissionsToAddCount = 0 if ($Analysis.PermissionsAnalysis) { $permissionsToAddCount = ($Analysis.PermissionsAnalysis | ForEach-Object { $_.PermissionsToAdd.Count } | Measure-Object -Sum).Sum } Write-Host "" Write-Host ("=" * 80) -ForegroundColor Gray Write-Host "Summary: " -NoNewline -ForegroundColor Gray Write-Host "$createCount to create" -NoNewline -ForegroundColor Yellow Write-Host ", " -NoNewline -ForegroundColor Gray Write-Host "$existsCount already exist" -NoNewline -ForegroundColor Green if ($permissionsToAddCount -gt 0) { Write-Host ", " -NoNewline -ForegroundColor Gray Write-Host "$permissionsToAddCount permission(s) to assign" -ForegroundColor Yellow } else { Write-Host "" } if ($createCount -gt 0) { Write-Host "" Write-Host "Resources to create ($createCount):" -ForegroundColor Yellow if ($Analysis.ServicePrincipalAnalysis -and $Analysis.ServicePrincipalAnalysis.ChangeType -eq 'Create') { Write-Host " + Service principal ($(Get-RJAzureResourcesAppId))" -ForegroundColor Green } foreach ($resource in $resourcesToCreate) { Write-Host " + $($resource.ResourceName) ($($resource.ResourceType))" -ForegroundColor Green } } Write-Host "" } |