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-RJRunbookManifestUrl { <# .SYNOPSIS Returns the download URL of a manifest published by the runbooks repository. .PARAMETER Type Manifest type: Permissions or DirectoryRoles. .PARAMETER Branch Branch of the runbooks repository. Defaults to $DefaultRunbookBranch. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [ValidateSet('Permissions', 'DirectoryRoles')] [string]$Type, [string]$Branch = $DefaultRunbookBranch ) return $RunbookManifestUrlTemplate -f $Branch, $RunbookManifestFileNames[$Type] } function Get-RJRunbookManifest { <# .SYNOPSIS Loads an AA managed identity manifest published by the runbooks repository. .PARAMETER Type Permissions: array of service objects with Name, Id and AppRoleAssignments. DirectoryRoles: array of directory role display names. .PARAMETER Url Optional URL of a manifest to download instead of the one published by the runbooks repository. .PARAMETER Branch Branch of the runbooks repository to download the manifest from. Ignored when Url is given. .OUTPUTS Array of manifest entries, validated according to Type. Empty when the manifest lists nothing; permissions entries without app role assignments are left out. #> [CmdletBinding()] [OutputType([System.Object[]])] param( [Parameter(Mandatory)] [ValidateSet('Permissions', 'DirectoryRoles')] [string]$Type, [string]$Url, [string]$Branch = $DefaultRunbookBranch ) $label = if ($Type -eq 'Permissions') { 'permissions' } else { 'directory roles' } $source = if ($Url) { $Url } else { Get-RJRunbookManifestUrl -Type $Type -Branch $Branch } Write-Verbose "Downloading $label manifest from: $source" try { $json = (Invoke-WebRequest -Uri $source -UseBasicParsing -ErrorAction Stop).Content } catch { throw "Could not download the runbook $label manifest from '$source': $($_.Exception.Message)" } $parsed = $json | ConvertFrom-Json -ErrorAction Stop $manifest = @($parsed) if ($manifest.Count -eq 0) { Write-Information "The runbook $label manifest from '$source' is empty — nothing to assign." return ,@() } switch ($Type) { 'Permissions' { $guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' $servicesWithRoles = @() foreach ($service in $manifest) { if (-not $service.Name -or -not ($service.Id -match $guidPattern) -or $null -eq $service.AppRoleAssignments) { throw "The runbook $label manifest from '$source' has an invalid entry. Expected Name, Id (GUID) and an AppRoleAssignments array on every entry." } if (@($service.AppRoleAssignments).Count -eq 0) { Write-Information " $($service.Name): no app role assignments in the manifest — skipping." continue } if (@($service.AppRoleAssignments | Where-Object { $_ -isnot [string] }).Count -gt 0) { throw "The runbook $label manifest from '$source' has non-string app role assignments for '$($service.Name)'." } Write-Verbose " $($service.Name): $(@($service.AppRoleAssignments).Count) permission(s)" $servicesWithRoles += $service } $manifest = $servicesWithRoles } 'DirectoryRoles' { if (@($manifest | Where-Object { $_ -isnot [string] -or [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) { throw "The runbook $label manifest from '$source' has an invalid entry. Expected a JSON array of directory role display names." } $uniqueRoles = New-Object System.Collections.Generic.List[string] foreach ($roleName in $manifest) { if ($uniqueRoles -notcontains $roleName) { $uniqueRoles.Add($roleName) } } $manifest = @($uniqueRoles.ToArray()) Write-Verbose " $($manifest.Count) directory role(s)" } } 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. .PARAMETER PermissionsManifestUrl Optional URL of a permissions manifest to use instead of the published one. .PARAMETER RunbookBranch Branch of the runbooks repository to download the manifest from. .OUTPUTS Array of PSCustomObjects per service: ServiceName, ServiceAppId, PermissionsToAdd, ExistingPermissions, TotalDesired, ChangeType (CreateAll/AddPermissions/NoChanges). #> [CmdletBinding()] [OutputType([System.Object[]])] param( [AllowNull()] [string]$ManagedIdentityPrincipalId, [string]$PermissionsManifestUrl, [string]$RunbookBranch = $DefaultRunbookBranch ) $manifest = Get-RJRunbookManifest -Type Permissions -Url $PermissionsManifestUrl -Branch $RunbookBranch # 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)) in this tenant." } # 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-RJAutomationAccountDirectoryRolesAnalysis { <# .SYNOPSIS Analyzes which Entra ID directory roles need to be assigned to the AA managed identity. .PARAMETER ManagedIdentityPrincipalId The ObjectId of the existing AA managed identity. If null, all roles are treated as new. .PARAMETER DirectoryRolesManifestUrl Optional URL of a directory roles manifest to use instead of the published one. .PARAMETER RunbookBranch Branch of the runbooks repository to download the manifest from. .OUTPUTS PSCustomObject with RolesToAdd, ExistingRoles, TotalDesired, ChangeType (CreateAll/AddRoles/NoChanges). #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [AllowNull()] [string]$ManagedIdentityPrincipalId, [string]$DirectoryRolesManifestUrl, [string]$RunbookBranch = $DefaultRunbookBranch ) $roleNames = Get-RJRunbookManifest -Type DirectoryRoles -Url $DirectoryRolesManifestUrl -Branch $RunbookBranch if (@($roleNames).Count -eq 0) { return [PSCustomObject]@{ RolesToAdd = @() ExistingRoles = @() TotalDesired = 0 ChangeType = 'NoChanges' } } if (-not $ManagedIdentityPrincipalId) { return [PSCustomObject]@{ RolesToAdd = @($roleNames) ExistingRoles = @() TotalDesired = $roleNames.Count ChangeType = 'CreateAll' } } $diff = Get-RJDirectoryRoleAssignmentDiff -PrincipalId $ManagedIdentityPrincipalId -RoleNames $roleNames return [PSCustomObject]@{ RolesToAdd = @($diff.RolesToAdd | ForEach-Object { $_.Name }) ExistingRoles = @($diff.ExistingRoles) TotalDesired = $diff.TotalDesired ChangeType = $diff.ChangeType } } 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. .PARAMETER PermissionsManifestUrl Optional URL of an Automation Account permissions manifest to use instead of the published one. .PARAMETER DirectoryRolesManifestUrl Optional URL of an Automation Account directory roles manifest to use instead of the published one. .PARAMETER RunbookBranch Branch of the runbooks repository to download the Automation Account manifests from. .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, [string]$PermissionsManifestUrl, [string]$DirectoryRolesManifestUrl, [string]$RunbookBranch = $DefaultRunbookBranch ) $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 PermissionsManifestSource = if ($PermissionsManifestUrl) { $PermissionsManifestUrl } else { Get-RJRunbookManifestUrl -Type Permissions -Branch $RunbookBranch } DirectoryRolesAnalysis = $null DirectoryRolesManifestSource = if ($DirectoryRolesManifestUrl) { $DirectoryRolesManifestUrl } else { Get-RJRunbookManifestUrl -Type DirectoryRoles -Branch $RunbookBranch } 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 -PermissionsManifestUrl $PermissionsManifestUrl -RunbookBranch $RunbookBranch $analysis.DirectoryRolesAnalysis = Get-RJAutomationAccountDirectoryRolesAnalysis -ManagedIdentityPrincipalId $managedIdentityPrincipalId -DirectoryRolesManifestUrl $DirectoryRolesManifestUrl -RunbookBranch $RunbookBranch } # 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 $workspaceLocation = Get-RJLogAnalyticsWorkspaceLocation -ResourceGroupName $ResourceGroupName -WorkspaceName $WorkspaceName if ($workspaceLocation) { $lawParams.location = $workspaceLocation } } $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 ($null -ne $Analysis.PermissionsAnalysis) { Write-Host "" Write-Host "Automation Account Permissions:" -ForegroundColor Cyan Write-Host ("-" * 60) -ForegroundColor Gray Write-Host " Source: $($Analysis.PermissionsManifestSource)" -ForegroundColor Gray Write-Host "" if (@($Analysis.PermissionsAnalysis).Count -eq 0) { Write-Host " (none required by the manifest)" -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 "" } } } # Directory roles analysis for AA managed identity if ($Analysis.DirectoryRolesAnalysis) { $roles = $Analysis.DirectoryRolesAnalysis Write-Host "" Write-Host "Automation Account Directory Roles:" -ForegroundColor Cyan Write-Host ("-" * 60) -ForegroundColor Gray Write-Host " Source: $($Analysis.DirectoryRolesManifestSource)" -ForegroundColor Gray Write-Host "" switch ($roles.ChangeType) { 'NoChanges' { if ($roles.TotalDesired -eq 0) { Write-Host " (none required by the manifest)" -ForegroundColor Gray } else { Write-Host " ✓ No changes required" -ForegroundColor Green Write-Host " Current roles ($($roles.TotalDesired)): $($roles.ExistingRoles -join ', ')" -ForegroundColor Gray } } 'CreateAll' { Write-Host " → Will assign all directory roles (new managed identity)" -ForegroundColor Yellow foreach ($role in $roles.RolesToAdd) { Write-Host " + $role" -ForegroundColor Green } } 'AddRoles' { Write-Host " → Will add $($roles.RolesToAdd.Count) directory role(s)" -ForegroundColor Yellow foreach ($role in $roles.RolesToAdd) { Write-Host " + $role" -ForegroundColor Green } Write-Host " Existing ($($roles.ExistingRoles.Count)): $($roles.ExistingRoles -join ', ')" -ForegroundColor Gray } } } } 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 } # Count directory role changes $directoryRolesToAddCount = 0 if ($Analysis.DirectoryRolesAnalysis) { $directoryRolesToAddCount = @($Analysis.DirectoryRolesAnalysis.RolesToAdd).Count } 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" -NoNewline -ForegroundColor Yellow } if ($directoryRolesToAddCount -gt 0) { Write-Host ", " -NoNewline -ForegroundColor Gray Write-Host "$directoryRolesToAddCount directory role(s) to assign" -NoNewline -ForegroundColor Yellow } 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 "" } |