Public/Invoke-AACGovernanceAssessment.ps1
|
function Invoke-AACGovernanceAssessment { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Assesses governance across the tenant in one run - the management group hierarchy, access, workload identities, policy remediation, Azure limits, tenant settings, tags, locks and recent changes - as a tabbed workbook, and as a Markdown page for a wiki. .DESCRIPTION Runs, read-only, the governance commands and puts their results together (in the spirit of the Azure Governance Visualizer): Hierarchy Show-AACManagementGroupMap's model: each management group, its subscriptions, policy and role assignments Access Get-AACRoleAssignment: every role assignment, and the custom roles Identities Get-AACServicePrincipalInsight Policy Get-AACPolicyRemediation Limits Test-AACAzureLimit Settings Get-AACGovernanceSetting Tags Get-AACTagUsage Locks Get-AACResourceLock Changes Get-AACGovernanceChange (the last -Days) -Area runs only some. An area that fails is reported and the others still run. At the prompt: the totals and the findings that matter most (High and Medium, every area). -HtmlPath writes the workbook - Summary, Findings and a tab per area, the hierarchy as a tree; -PdfPath the same as a PDF; -CsvPath the findings; -MarkdownPath one Markdown page with the hierarchy as a Mermaid diagram and a table per area - for a GitHub or Azure DevOps wiki (-MarkdownFlavor), refreshed from a pipeline. -PassThru returns everything (AAC.GovernanceAssessment). Needs what the areas need: Reader on the scope (Reader on the tenant root group for its settings), and in Entra ID Directory.Read.All and Application.Read.All. -SkipEntra reads Azure only. .PARAMETER SubscriptionId Only these subscriptions (and the management groups above them). .PARAMETER ManagementGroupId Only the subscriptions under these management groups (at any depth). .PARAMETER Area Only these areas: Hierarchy, Access, Identities, Policy, Limits, Settings, Tags, Locks, Changes. .PARAMETER Days How far back the Changes area looks. Default 30. .PARAMETER RequiredTag Tags every resource should carry (the Tags area checks their coverage). .PARAMETER SkipEntra Don't read Entra ID (Access, Identities and Changes show principals by ID; Identities is skipped). .PARAMETER SkipPim Don't read PIM in the Access area. .PARAMETER CsvPath Write the findings to this CSV file. .PARAMETER HtmlPath Write the workbook as an interactive HTML report. .PARAMETER PdfPath Write the workbook as a PDF report. .PARAMETER MarkdownPath Write the assessment as a Markdown page. .PARAMETER MarkdownFlavor GitHub (the default: ```mermaid blocks) or AzureDevOps (::: mermaid blocks). .PARAMETER Title The reports' title. .PARAMETER PassThru Show the view and also return the assessment. .PARAMETER NoDisplay Return the assessment without showing the view. .PARAMETER NoPaging Show the whole view at once. .EXAMPLE Invoke-AACGovernanceAssessment -HtmlPath .\Governance.html The whole governance workbook for every subscription the account can see. .EXAMPLE Invoke-AACGovernanceAssessment -ManagementGroupId 'mg-landingzones' -MarkdownPath .\wiki\Governance.md -MarkdownFlavor AzureDevOps -NoDisplay The landing zones' governance as an Azure DevOps wiki page - from a pipeline. .EXAMPLE Invoke-AACGovernanceAssessment -Area Access, Limits, Settings -PdfPath .\Governance.pdf Three areas, as a PDF. .OUTPUTS AAC.GovernanceAssessment, with -PassThru or -NoDisplay #> [CmdletBinding()] [OutputType('AAC.GovernanceAssessment')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [string[]] $ManagementGroupId, [ValidateSet('Hierarchy', 'Access', 'Identities', 'Policy', 'Limits', 'Settings', 'Tags', 'Locks', 'Changes')] [string[]] $Area = @('Hierarchy', 'Access', 'Identities', 'Policy', 'Limits', 'Settings', 'Tags', 'Locks', 'Changes'), [ValidateRange(1, 365)] [int] $Days = 30, [string[]] $RequiredTag, [switch] $SkipEntra, [switch] $SkipPim, [string] $CsvPath, [string] $HtmlPath, [string] $PdfPath, [string] $MarkdownPath, [ValidateSet('GitHub', 'AzureDevOps')] [string] $MarkdownFlavor = 'GitHub', [string] $Title = 'Azure governance assessment', [switch] $PassThru, [switch] $NoDisplay, [switch] $NoPaging ) trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength $interactive = -not $NoDisplay -and -not $pipedOnward $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } $scopeArgs = @{} if ($SubscriptionId) { $scopeArgs.SubscriptionId = $SubscriptionId } if ($ManagementGroupId) { $scopeArgs.ManagementGroupId = $ManagementGroupId } $null = Get-AACAccessToken if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Governance assessment' -Color 'deepskyblue3_1' } # --- Each area: the command, quietly; a failure is noted and the others run --------------------------- $notices = [System.Collections.Generic.List[string]]::new() $results = [ordered]@{} $run = { param([string] $Name, [scriptblock] $Work) if ($Area -notcontains $Name) { return } try { $results[$Name] = @(& $Work) } catch { $notices.Add("The $($Name.ToLowerInvariant()) area couldn't be assessed: $($_.Exception.Message -replace '\s+', ' ')"); $results[$Name] = @() } } $hierarchy = @() if ($Area -contains 'Hierarchy') { try { $built = Invoke-AACProgress -ScriptBlock { Read-AACManagementGroupMap @scopeArgs } $hierarchy = @($built.Map.Hierarchy) $results['Hierarchy'] = $hierarchy } catch { $notices.Add("The hierarchy area couldn't be assessed: $($_.Exception.Message -replace '\s+', ' ')"); $results['Hierarchy'] = @() } } & $run 'Access' { Get-AACRoleAssignment @scopeArgs -IncludeCustomRole -SkipPim:$SkipPim -SkipEntra:$SkipEntra -NoDisplay } if ($SkipEntra -and $Area -contains 'Identities') { $notices.Add('The identities area needs Entra ID: skipped (-SkipEntra).') } elseif ($Area -contains 'Identities') { & $run 'Identities' { Get-AACServicePrincipalInsight @scopeArgs -NoDisplay } } & $run 'Policy' { Get-AACPolicyRemediation @scopeArgs -NoDisplay } & $run 'Limits' { Test-AACAzureLimit @scopeArgs -NoDisplay } & $run 'Settings' { Get-AACGovernanceSetting @scopeArgs -NoDisplay } & $run 'Tags' { $tagArgs = @{} + $scopeArgs; if ($RequiredTag) { $tagArgs.RequiredTag = $RequiredTag }; Get-AACTagUsage @tagArgs -NoDisplay } & $run 'Locks' { Get-AACResourceLock @scopeArgs -NoDisplay } & $run 'Changes' { Get-AACGovernanceChange @scopeArgs -Days $Days -SkipEntra:$SkipEntra -NoDisplay } $isType = { param($Row, [string] $Type) $Row -and $Row.PSObject.TypeNames -contains $Type } $access = @(if ($results.Contains('Access')) { $results['Access'] | Where-Object { & $isType $_ 'AAC.RoleAssignment' } }) $customRoles = @(if ($results.Contains('Access')) { $results['Access'] | Where-Object { & $isType $_ 'AAC.CustomRole' } }) $rowsOf = { param([string] $Name) @(if ($results.Contains($Name)) { $results[$Name] }) } # --- The findings that matter: High and Medium, every area ------------------------------------------------ $findings = [System.Collections.Generic.List[object]]::new() $add = { param([string] $AreaName, [string] $Severity, [string] $Item, [string] $Finding, [string] $Action = '') if ($Severity -notin 'Critical', 'High', 'Medium') { return } $f = [pscustomobject][ordered]@{ Severity = $Severity; Area = $AreaName; Item = $Item; Finding = $Finding; Action = $Action } $f.PSObject.TypeNames.Insert(0, 'AAC.GovernanceFinding') $findings.Add($f) } foreach ($r in $access) { & $add 'Access' $r.Severity "$($r.Role) for $($r.Principal) on $($r.Scope)" $r.Findings } foreach ($r in $customRoles) { if ($r.OwnerEquivalent) { & $add 'Access' 'High' "Custom role $($r.Role)" 'Equivalent to Owner: every action, including granting access.' 'Narrow its actions, or use built-in roles.' } } foreach ($r in (& $rowsOf 'Identities')) { & $add 'Identities' $r.Severity $r.ServicePrincipal $r.Findings } foreach ($r in (& $rowsOf 'Policy')) { & $add 'Policy' $r.Severity "$($r.Policy) ($($r.Assignment))" $r.Findings } foreach ($r in (& $rowsOf 'Limits')) { & $add 'Limits' $(@{ Critical = 'High'; Warning = 'Medium'; OK = 'Info' }[$r.Status]) "$($r.Limit) on $($r.Scope)" "$($r.Used) of $($r.Maximum) ($($r.Percent)%)" $r.Advice } foreach ($r in (& $rowsOf 'Settings')) { if ($r.Status -eq 'Warning') { & $add 'Settings' 'Medium' "$($r.Setting) ($($r.Scope))" $r.Value $r.Recommendation } } foreach ($r in (& $rowsOf 'Tags')) { & $add 'Tags' $r.Severity "Tag $($r.Tag)" $r.Findings } foreach ($r in (& $rowsOf 'Locks')) { & $add 'Locks' $r.Severity "$($r.Scope)" $r.Findings } foreach ($r in (& $rowsOf 'Changes')) { & $add 'Changes' $r.Severity "$($r.Kind) $($r.Name) ($($r.Change) by $($r.By))" $r.Note } $rank = Get-AACSeverityRank $findings = @($findings | Sort-Object -Property @{ Expression = { $rank.Rank[$_.Severity] } }, Area, Item) $high = @($findings | Where-Object Severity -In 'Critical', 'High').Count # --- Columns from the objects: badges for severity and status, numbers, dates, wide text ----------------- $statusTones = @{ Critical = 'bad'; Warning = 'warn'; OK = 'good'; Info = 'info'; Locked = 'good'; Unlocked = 'warn' } $wide = 'Findings', 'Finding', 'Recommendation', 'Advice', 'Detail', 'Note', 'Roles', 'MemberNames', 'SampleValues', 'Value', 'Action', 'AssignableScopes' $facets = 'Severity', 'Status', 'Area', 'Kind', 'PrincipalType', 'ScopeType', 'Limit', 'Assignment', 'Change', 'Level', 'RoleType', 'Effect', 'LastTask', 'Privileged', 'Unused', 'OwnerEquivalent', 'Subscription' $columnsFor = { param([object[]] $Rows, [string[]] $Skip = @()) $first = @($Rows | Where-Object { $_ }) | Select-Object -First 1 if (-not $first) { return @() } $i = 0 @(foreach ($p in $first.PSObject.Properties) { if ($Skip -contains $p.Name) { continue } $i++ $c = @{ Key = $p.Name; Label = ($p.Name -creplace '(?<=[a-z])(?=[A-Z])', ' '); Pdf = ($i -le 6) } if ($p.Name -eq 'Severity') { $c.Type = 'badge'; $c.Tones = $rank.Tone } elseif ($p.Name -eq 'Status') { $c.Type = 'badge'; $c.Tones = $statusTones } elseif ($p.Value -is [datetime] -or $p.Name -match '(On|When|Expiry)$') { $c.Type = 'date' } elseif ($p.Value -is [int] -or $p.Value -is [long] -or $p.Value -is [double]) { $c.Type = 'number' } elseif ($p.Name -match 'Id$') { $c.Type = 'mono' } elseif ($wide -contains $p.Name) { $c.Type = 'wide' } if ($facets -contains $p.Name) { $c.Facet = $true } $c }) } $areaTables = [ordered]@{ Hierarchy = @{ Id = 'hierarchy'; Title = 'Management groups'; Rows = $hierarchy; Note = 'Each management group: its parent and depth, the subscriptions directly in it and below, and the policy and role assignments made at it.' } Access = @{ Id = 'access'; Title = 'Role assignments'; Rows = $access; Note = 'Every Azure role assignment that reaches the scope (Get-AACRoleAssignment), riskiest first; the custom roles below.' } Identities = @{ Id = 'identities'; Title = 'Service principals'; Rows = (& $rowsOf 'Identities'); Note = 'Applications and managed identities with Azure roles, and their credentials (Get-AACServicePrincipalInsight).' } Policy = @{ Id = 'policy'; Title = 'Policy remediation'; Rows = (& $rowsOf 'Policy'); Note = 'DeployIfNotExists and Modify policies with resources they could fix, and their remediation tasks (Get-AACPolicyRemediation).' } Limits = @{ Id = 'limits'; Title = 'Azure limits'; Rows = (& $rowsOf 'Limits'); Note = 'What each scope uses of the Azure limits, the closest first (Test-AACAzureLimit).' } Settings = @{ Id = 'settings'; Title = 'Governance settings'; Rows = (& $rowsOf 'Settings'); Note = 'Management group hierarchy settings and diagnostics, subscriptions, Defender contacts, classic administrators, preview features, resource providers (Get-AACGovernanceSetting).' } Tags = @{ Id = 'tags'; Title = 'Tags'; Rows = (& $rowsOf 'Tags'); Note = 'Every tag name, its coverage and values, and what is inconsistent (Get-AACTagUsage).' } Locks = @{ Id = 'locks'; Title = 'Locks'; Rows = (& $rowsOf 'Locks'); Note = 'Every lock, and the critical resources no lock protects (Get-AACResourceLock).' } Changes = @{ Id = 'changes'; Title = 'Changes'; Rows = (& $rowsOf 'Changes'); Note = "Policy and access created or updated in the last $Days days, and who did it (Get-AACGovernanceChange)." } } $tables = [System.Collections.Generic.List[object]]::new() $tables.Add(@{ Id = 'findings'; Title = 'Findings'; Section = 'Findings'; Rows = $findings; Noun = 'findings'; GroupBy = @('Area', 'Severity'); ConsoleLimit = 30 Empty = 'No high or medium findings in any area.'; EmptyStatus = 'Success' Columns = @( @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Area'; Label = 'Area'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Item'; Label = 'Item'; Console = $true; Pdf = $true } @{ Key = 'Finding'; Label = 'Finding'; Type = 'wide'; Console = $true; Pdf = $true } @{ Key = 'Action'; Label = 'What to do'; Type = 'wide'; Pdf = $true } ) }) $tabs = [System.Collections.Generic.List[object]]::new() $tabs.Add(@{ Name = 'Findings'; Badge = $findings.Count; Tone = $(if ($high) { 'bad' } elseif ($findings.Count) { 'warn' } else { 'good' }); Note = 'The high and medium findings of every area, worst first.' }) foreach ($name in $areaTables.Keys) { if (-not $results.Contains($name)) { continue } $t = $areaTables[$name] $rows = @($t.Rows) $tables.Add(@{ Id = $t.Id; Title = $t.Title; Section = $name; Rows = $rows; Noun = 'rows'; NoConsole = $true; Columns = (& $columnsFor $rows @('Id', 'ScopeId', 'PrincipalId', 'RoleDefinitionId', 'AssignmentId', 'DefinitionId', 'ReferenceId', 'ResourceId', 'LimitName')) }) if ($name -eq 'Access' -and $customRoles.Count) { $tables.Add(@{ Id = 'customRoles'; Title = 'Custom roles'; Section = $name; Rows = $customRoles; Noun = 'roles'; NoConsole = $true; Columns = (& $columnsFor $customRoles @('Id')) }) } $areaHigh = @($findings | Where-Object { $_.Area -eq $name -and $_.Severity -in 'Critical', 'High' }).Count $areaAll = @($findings | Where-Object Area -EQ $name).Count $tabs.Add(@{ Name = $name; Badge = $rows.Count; Tone = $(if ($areaHigh) { 'bad' } elseif ($areaAll) { 'warn' } else { 'info' }); Note = $t.Note }) } # The hierarchy as a tree on the Summary tab. $tree = $null if ($hierarchy.Count) { $nodeOf = @{} foreach ($h in $hierarchy) { $nodeOf[$h.ManagementGroup] = @{ l = $(if (-not $h.Parent) { 't' } else { 'm' }); n = $h.ManagementGroup; d = $h.Name; c = @(@($h.SubscriptionsBelow, 'subscriptions'), @($h.PolicyAssignments, 'policy assignments'), @($h.RoleAssignments, 'role assignments')); k = [System.Collections.Generic.List[object]]::new() } } $roots = [System.Collections.Generic.List[object]]::new() foreach ($h in $hierarchy) { if ($h.Parent -and $nodeOf.Contains($h.Parent)) { $nodeOf[$h.Parent].k.Add($nodeOf[$h.ManagementGroup]) } else { $roots.Add($nodeOf[$h.ManagementGroup]) } } $tree = @{ Title = 'Management group hierarchy'; OpenTo = 'm'; Root = $(if ($roots.Count -eq 1) { $roots[0] } else { @{ l = 't'; n = 'Tenant'; d = ''; c = @(); k = $roots } }) } } $areasRun = @($results.Keys) $tiles = @( @{ Value = '{0:N0}' -f $high; Label = 'high findings'; Tone = $(if ($high) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Severity = 'High' } } @{ Value = '{0:N0}' -f ($findings.Count - $high); Label = 'medium findings'; Tone = $(if ($findings.Count - $high) { 'warn' } else { 'good' }); Table = 'findings'; Filters = @{ Severity = 'Medium' } } @{ Value = '{0:N0}' -f $hierarchy.Count; Label = 'management groups'; Tone = 'info' } @{ Value = '{0:N0}' -f $access.Count; Label = 'role assignments'; Tone = 'violet' } @{ Value = '{0:N0}' -f @(& $rowsOf 'Identities').Count; Label = 'workload identities'; Tone = 'neutral' } @{ Value = '{0:N0}' -f @(& $rowsOf 'Limits' | Where-Object Status -NE 'OK').Count; Label = 'limits near'; Tone = $(if (@(& $rowsOf 'Limits' | Where-Object Status -EQ 'Critical').Count) { 'bad' } else { 'warn' }) } ) $report = @{ Subtitle = 'Governance assessment: hierarchy, access, identities, policy, limits, settings, tags, locks and changes' Facts = [ordered]@{ Scope = $(if ($ManagementGroupId) { "management group $($ManagementGroupId -join ', ')" } elseif ($SubscriptionId) { "$($SubscriptionId.Count) subscription(s)" } else { 'every subscription the account can see' }); Areas = ($areasRun -join ', ') } Status = $(if ($high) { 'Failed' } elseif ($findings.Count) { 'Warning' } else { 'Success' }) Headline = "$($findings.Count) finding(s) across $($areasRun.Count) area(s): $high high$(if ($findings.Count) { " - most in $(@($findings | Group-Object Area | Sort-Object Count -Descending)[0].Name)" })" Tiles = $tiles Notices = @($notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } }) Charts = @( @{ Title = 'Findings by area'; Items = @($findings | Group-Object Area | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'findings'; Column = 'Area'; Tone = 'warn'; Console = $true } @{ Title = 'By severity'; Kind = 'donut'; CenterLabel = 'findings'; Items = @(foreach ($s in 'High', 'Medium') { $n = @($findings | Where-Object Severity -EQ $s).Count; if ($n) { @{ Label = $s; Value = $n; Tone = $rank.Tone[$s]; Filter = $s } } }); Table = 'findings'; Column = 'Severity' } ) Tabs = $tabs.ToArray() Tables = $tables.ToArray() Hint = '-Area runs some areas; -HtmlPath writes the workbook (a tab per area); -MarkdownPath a wiki page; -PassThru returns everything.' } if ($tree) { $report.Tree = $tree } if ($MarkdownPath) { $sections = [ordered]@{ Findings = @{ Note = 'The high and medium findings of every area, worst first.'; Rows = $findings; Columns = @('Severity', 'Area', 'Item', 'Finding', 'Action') } } $markdownColumns = @{ Access = @('Severity', 'Principal', 'PrincipalType', 'Role', 'ScopeType', 'Scope', 'Assignment'); Identities = @('Severity', 'ServicePrincipal', 'Kind', 'RoleAssignments', 'PrivilegedRoles', 'NextExpiry'); Policy = @('Severity', 'Assignment', 'Policy', 'NonCompliant', 'LastTask'); Limits = @('Status', 'Limit', 'ScopeType', 'Scope', 'Used', 'Maximum', 'Percent'); Settings = @('Status', 'Area', 'Scope', 'Setting', 'Value'); Tags = @('Severity', 'Tag', 'Resources', 'Coverage', 'Values'); Locks = @('Severity', 'Status', 'Level', 'ScopeType', 'Scope'); Changes = @('When', 'Change', 'Kind', 'Name', 'By'); Hierarchy = @('ManagementGroup', 'Parent', 'Depth', 'SubscriptionsBelow', 'PolicyAssignments', 'RoleAssignments') } foreach ($name in $areaTables.Keys) { if ($results.Contains($name) -and $name -ne 'Hierarchy') { $sections[$name] = @{ Note = $areaTables[$name].Note; Rows = @($areaTables[$name].Rows); Columns = $markdownColumns[$name] } } } if ($customRoles.Count) { $sections['Custom roles'] = @{ Rows = $customRoles; Columns = @('Role', 'Assignments', 'Unused', 'OwnerEquivalent', 'CanWriteRoleAssignments') } } Write-AACGovernanceMarkdown -Path (& $resolve $MarkdownPath) -Title $Title -Fact $report.Facts -Headline $report.Headline -Tile @($tiles | ForEach-Object { @{ Label = $_.Label; Value = $_.Value } }) -Hierarchy $hierarchy -Section $sections -Flavor $MarkdownFlavor if (-not $NoDisplay) { Write-AACMarkup "[grey58]Markdown: $([Spectre.Console.Markup]::Escape((& $resolve $MarkdownPath)))[/]" } } $assessment = [pscustomobject][ordered]@{ PSTypeName = 'AAC.GovernanceAssessment' Findings = $findings Hierarchy = $hierarchy RoleAssignments = $access CustomRoles = $customRoles ServicePrincipals = @(& $rowsOf 'Identities') PolicyRemediation = @(& $rowsOf 'Policy') Limits = @(& $rowsOf 'Limits') Settings = @(& $rowsOf 'Settings') Tags = @(& $rowsOf 'Tags') Locks = @(& $rowsOf 'Locks') Changes = @(& $rowsOf 'Changes') Notices = @($notices) } Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $findings -Noun 'finding' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) ` -ShowView:($interactive -and -not ($CsvPath -or $HtmlPath -or $PdfPath)) -NoPaging:$NoPaging -Object @($assessment) -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward) } |