Private/ConvertTo-AACGovernanceSetting.ps1
|
function ConvertTo-AACGovernanceSetting { <# .SYNOPSIS Builds Get-AACGovernanceSetting's list (AAC.GovernanceSetting) from the tenant's management group settings and each subscription's details, Defender security contacts, classic administrators, preview features and resource providers. .DESCRIPTION No Azure calls. -Hierarchy the root management group's settings/default (properties: defaultManagementGroup, requireAuthorizationForGroupCreation), or $null -HierarchyError why it couldn't be read -Subscription Resolve-AACScope's subscription rows, with quotaId and spendingLimit when Resource Graph had them -GroupDiagnostics management group name -> @{ Items } (its diagnostic settings) or @{ Error } -PerSubscription subscription ID -> @{ Contacts; Classic; Features; Providers } (each the items, or $null when not read) and Errors (name -> why) Rows: Area, Scope, Setting, Value, Status (Warning, Info, OK) and Recommendation. What is checked: Management groups who may create one (Warning when anyone can); where new subscriptions land (Warning when the root) Diagnostics each management group's activity log exported (Warning when nowhere) Subscription offer (quota ID), state, spending limit Defender contacts an email for security alerts (Warning when none), alert notifications and their minimum severity, notifications to the Owner role Classic admins co-administrators left (Warning: the role is retired) Preview features each registered preview feature (Info) Resource providers each provider registered in some subscriptions but not all (Info) Returns @{ Settings; Notices; Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( $Hierarchy, [string] $HierarchyError = '', [string] $TenantId = '', [object[]] $Subscription = @(), [hashtable] $PerSubscription = @{}, [System.Collections.IDictionary] $GroupDiagnostics = @{} ) $get = ${function:Get-AACMember} $text = { param($Object, [string] $Path) $v = & $get $Object $Path; if ($null -eq $v) { '' } else { [string]$v } } $list = { param($Item) @(if ($Item -is [System.Collections.IEnumerable] -and $Item -isnot [string] -and $Item -isnot [System.Collections.IDictionary]) { $Item } elseif ($null -ne $Item) { , $Item }) | Where-Object { $null -ne $_ -and '' -ne $_ } } $notices = [System.Collections.Generic.List[string]]::new() $settings = [System.Collections.Generic.List[object]]::new() $add = { param([string] $Area, [string] $Scope, [string] $Setting, [string] $Value, [string] $Status, [string] $Recommendation = '', [string] $ScopeId = '') $item = [pscustomobject][ordered]@{ Status = $Status; Area = $Area; Scope = $Scope; Setting = $Setting; Value = $Value; Recommendation = $Recommendation; ScopeId = $ScopeId } $item.PSObject.TypeNames.Insert(0, 'AAC.GovernanceSetting') $settings.Add($item) } # --- Management group hierarchy settings --------------------------------------------------------------------------------- if ($null -ne $Hierarchy) { $p = & $get $Hierarchy 'properties' $require = & $text $p 'requireAuthorizationForGroupCreation' $default = & $text $p 'defaultManagementGroup' $rootId = "/providers/Microsoft.Management/managementGroups/$TenantId" if ($require -eq 'True') { & $add 'Management groups' 'Tenant root group' 'Who can create management groups' 'Only those with write permission on the root' 'OK' '' $rootId } else { & $add 'Management groups' 'Tenant root group' 'Who can create management groups' 'Any user in the tenant' 'Warning' 'Require authorization for management group creation (Management groups > Settings), so only those with write permission on the root group can add one.' $rootId } $defaultName = ($default -split '/')[-1] if (-not $default -or ($TenantId -and $defaultName -eq $TenantId)) { & $add 'Management groups' 'Tenant root group' 'Default management group for new subscriptions' 'The tenant root group' 'Warning' 'Set a default management group (a sandbox or onboarding group with guardrail policies), so new subscriptions don''t land in the root with only the root''s policies.' $rootId } else { & $add 'Management groups' 'Tenant root group' 'Default management group for new subscriptions' $defaultName 'OK' '' $rootId } } elseif ($HierarchyError) { $notices.Add("The management group hierarchy settings couldn't be read ($HierarchyError): reading them needs Reader on the tenant root group.") } # --- Management groups' activity log ------------------------------------------------------------------------------------- $destinations = [ordered]@{ workspaceId = 'Log Analytics'; storageAccountId = 'storage'; eventHubAuthorizationRuleId = 'Event Hubs' } foreach ($g in @($GroupDiagnostics.Keys)) { $entry = $GroupDiagnostics[$g] $gid = "/providers/Microsoft.Management/managementGroups/$g" if ($entry.Contains('Error')) { $notices.Add("The diagnostic settings of management group $g couldn't be read: $($entry.Error)"); continue } $exports = @(foreach ($d in @(& $list $entry.Items)) { $p = & $get $d 'properties' $enabled = @(@(& $list (& $get $p 'logs')) | Where-Object { (& $text $_ 'enabled') -eq 'True' } | ForEach-Object { & $text $_ 'category' }) if (-not $enabled.Count) { continue } $to = @(foreach ($key in $destinations.Keys) { $target = & $text $p $key; if ($target) { "$($destinations[$key]) $(($target -split '/')[-1])" } }) "$($to -join ', ') ($($enabled -join ', '))" }) if ($exports.Count) { & $add 'Diagnostics' "management group $g" 'Activity log exported' ($exports -join '; ') 'OK' '' $gid } else { & $add 'Diagnostics' "management group $g" 'Activity log exported' 'Nowhere' 'Warning' "Export the management group's activity log (administrative and policy events) to a Log Analytics workspace: it records who changed policy and access at this level, and is kept only 90 days otherwise." $gid } } # --- Per subscription ---------------------------------------------------------------------------------------------------- $providerSubs = @{} $subscriptionCount = 0 foreach ($s in @($Subscription)) { $id = ([string]$s['subscriptionId']).ToLowerInvariant() $name = [string]$s['name'] $sid = "/subscriptions/$id" $subscriptionCount++ $quota = & $text $s 'quotaId' if ($quota) { $offer = switch -Regex ($quota) { '^MSDN|^MSDNDevTest|Visual' { 'Visual Studio / MSDN (dev and test)' } 'FreeTrial' { 'Free trial' } 'EnterpriseAgreement' { 'Enterprise Agreement' } 'PayAsYouGo' { 'Pay-as-you-go' } 'CSP' { 'Cloud Solution Provider' } 'Sponsored' { 'Sponsored' } default { $quota } } & $add 'Subscription' $name 'Offer' $offer $(if ($quota -match 'FreeTrial|MSDN') { 'Info' } else { 'OK' }) $(if ($quota -match 'FreeTrial|MSDN') { 'A dev/test or trial offer has no SLA - keep production workloads off it.' } else { '' }) $sid } $state = & $text $s 'state' if ($state -and $state -ne 'Enabled') { & $add 'Subscription' $name 'State' $state 'Warning' 'A subscription that is not Enabled stops its resources - reactivate or cancel it deliberately.' $sid } if ((& $text $s 'spendingLimit') -eq 'On') { & $add 'Subscription' $name 'Spending limit' 'On' 'Info' 'Resources stop when the limit is reached.' $sid } $data = if ($PerSubscription.Contains($id)) { $PerSubscription[$id] } else { @{} } $errors = if ($data.Contains('Errors')) { $data.Errors } else { @{} } # Defender security contacts. if ($data.Contains('Contacts') -and $null -ne $data.Contacts) { $contacts = @(& $list $data.Contacts) $emails = @($contacts | ForEach-Object { & $text (& $get $_ 'properties') 'emails'; & $text (& $get $_ 'properties') 'email' } | Where-Object { $_ } | ForEach-Object { $_ -split '[;,]' } | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Unique) if (-not $emails.Count) { & $add 'Defender contacts' $name 'Security contact email' 'None' 'Warning' 'Add a security contact email (Defender for Cloud > Environment settings > Email notifications), so high-severity alerts reach someone.' $sid } else { & $add 'Defender contacts' $name 'Security contact email' ($emails -join ', ') 'OK' '' $sid } foreach ($c in $contacts) { $p = & $get $c 'properties' $alertState = & $text $p 'alertNotifications.state' if ($alertState) { $minimal = & $text $p 'alertNotifications.minimalSeverity' & $add 'Defender contacts' $name 'Alert notifications' $(if ($alertState -eq 'On') { "On (from $minimal severity)" } else { 'Off' }) $(if ($alertState -eq 'On') { 'OK' } else { 'Warning' }) $(if ($alertState -eq 'On') { '' } else { 'Turn alert notifications on, so security alerts are emailed.' }) $sid } $roleState = & $text $p 'notificationsByRole.state' if ($roleState) { $roles = @(& $list (& $get $p 'notificationsByRole.roles')) $toOwners = $roleState -eq 'On' -and $roles -contains 'Owner' & $add 'Defender contacts' $name 'Notifications to roles' $(if ($roleState -eq 'On') { $roles -join ', ' } else { 'Off' }) $(if ($toOwners) { 'OK' } else { 'Info' }) $(if ($toOwners) { '' } else { 'Notify the Owner role too, so the subscription''s owners hear about alerts.' }) $sid } } } elseif ($errors.Contains('Contacts')) { $notices.Add("Defender security contacts couldn't be read for $($name): $($errors.Contacts)") } # Classic administrators. if ($data.Contains('Classic') -and $null -ne $data.Classic) { $co = @(@(& $list $data.Classic) | Where-Object { (& $text (& $get $_ 'properties') 'role') -match 'CoAdministrator' }) if ($co.Count) { & $add 'Classic administrators' $name 'Co-administrators' (@($co | ForEach-Object { & $text (& $get $_ 'properties') 'emailAddress' }) -join ', ') 'Warning' 'Classic administrators are retired: give these people an Azure role (Owner or narrower) and remove them as co-administrators.' $sid } } # Preview features. if ($data.Contains('Features') -and $null -ne $data.Features) { foreach ($f in @(& $list $data.Features)) { if ((& $text (& $get $f 'properties') 'state') -ne 'Registered') { continue } & $add 'Preview features' $name (& $text $f 'name') 'Registered' 'Info' 'A preview feature has no SLA and can change: check it is still needed, especially in production.' $sid } } elseif ($errors.Contains('Features')) { $notices.Add("Preview features couldn't be read for $($name): $($errors.Features)") } # Resource providers: registered here? if ($data.Contains('Providers') -and $null -ne $data.Providers) { foreach ($p in @(& $list $data.Providers)) { $ns = & $text $p 'namespace' if (-not $ns) { continue } if (-not $providerSubs.Contains($ns)) { $providerSubs[$ns] = [System.Collections.Generic.List[string]]::new() } if ((& $text $p 'registrationState') -eq 'Registered') { $providerSubs[$ns].Add($name) } } } elseif ($errors.Contains('Providers')) { $notices.Add("Resource providers couldn't be read for $($name): $($errors.Providers)") } } $readProviders = @($Subscription | Where-Object { $i = ([string]$_['subscriptionId']).ToLowerInvariant(); $PerSubscription.Contains($i) -and $PerSubscription[$i].Contains('Providers') -and $null -ne $PerSubscription[$i].Providers }).Count foreach ($ns in @($providerSubs.Keys | Sort-Object)) { $n = $providerSubs[$ns].Count if ($n -eq 0 -or $n -eq $readProviders) { continue } & $add 'Resource providers' "$n of $readProviders subscription(s)" $ns "Registered in: $((@($providerSubs[$ns] | Sort-Object)) -join ', ')" 'Info' 'Registered in some subscriptions only: deployments that need it fail elsewhere until it is registered (or a policy blocks it on purpose).' } $order = @{ Warning = 0; Info = 1; OK = 2 } $areas = @{ 'Management groups' = 0; Diagnostics = 0.5; Subscription = 1; 'Defender contacts' = 2; 'Classic administrators' = 3; 'Preview features' = 4; 'Resource providers' = 5 } $sorted = @($settings | Sort-Object -Property @{ Expression = { $order[$_.Status] } }, @{ Expression = { $areas[$_.Area] } }, Scope, Setting) @{ Settings = $sorted Notices = @($notices) Stats = @{ Settings = $sorted.Count; Warning = @($sorted | Where-Object Status -EQ 'Warning').Count; Subscriptions = $subscriptionCount } } } |