Private/ConvertTo-AACGovernanceChange.ps1
|
function ConvertTo-AACGovernanceChange { <# .SYNOPSIS Builds Get-AACGovernanceChange's timeline (AAC.GovernanceChange): the policy and access changes of the last days - created and updated policy definitions, initiatives and assignments, custom roles and role assignments - with who made them. .DESCRIPTION No Azure calls. -Read is Invoke-AACGraphBatch's result (rows with kind, id, name, scope, createdOn, createdBy, updatedOn, updatedBy, detail fields); -Directory Get-AACDirectoryObject's for the authors and role assignments' principals ($null: not read). -Since is the start of the window; -InScope tells whether a scope is in scope. A row per change (created, or updated when updated after created): when, what changed, its name and scope, who - resolved to a name - and Detail (a role assignment's role and principal; an assignment's enforcement). Notable changes get a severity: Medium a privileged role (Owner, User Access Administrator, RBAC Administrator, Contributor) assigned; a custom role changed; a policy assignment set to DoNotEnforce Info everything else Returns @{ Changes (newest first); Notices; Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [hashtable] $Read, [hashtable] $Directory, [Parameter(Mandatory)] [datetime] $Since, [scriptblock] $InScope = { param([string] $Scope) $true } ) $get = ${function:Get-AACMember} $text = { param($Object, [string] $Path) $v = & $get $Object $Path; if ($null -eq $v) { '' } else { [string]$v } } $rowsOf = { param([string] $Name) @(if ($Read.Rows -and $Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) } $date = { param([string] $Value) $d = [datetime]::MinValue if ($Value -and [datetime]::TryParse($Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$d)) { $d } else { $null } } $notices = [System.Collections.Generic.List[string]]::new() $kinds = [ordered]@{ policyDefinitions = 'Policy definition'; policySets = 'Initiative'; policyAssignments = 'Policy assignment'; roleDefinitions = 'Custom role'; roleAssignments = 'Role assignment' } foreach ($key in @($Read.Errors.Keys | Sort-Object)) { $notices.Add("The $(if ($kinds.Contains($key)) { $kinds[$key].ToLowerInvariant() + ' changes' } else { $key }) couldn't be read: $($Read.Errors[$key] -replace '\s+', ' ')") } $entraRead = $null -ne $Directory -and -not $Directory.Error if ($null -ne $Directory -and $Directory.Error) { $notices.Add("The authors couldn't be looked up in Entra ID ($($Directory.Error)): they are shown by ID.") } $who = { param([string] $Id) if (-not $Id) { return '' } if ($Id -notmatch '^[0-9a-fA-F-]{36}$') { return $Id } if ($entraRead -and $Directory.Objects.Contains($Id.ToLowerInvariant())) { $o = $Directory.Objects[$Id.ToLowerInvariant()]; return $(if (& $text $o 'userPrincipalName') { & $text $o 'userPrincipalName' } else { & $text $o 'displayName' }) } if ($entraRead) { return "$Id (deleted)" } $Id } $scopeText = { param([string] $Scope) if (-not $Scope -or $Scope -eq '/') { 'root' } elseif ($Scope -match '/managementgroups/([^/]+)') { "management group $($Matches[1])" } elseif ($Scope -match '^/subscriptions/([^/]+)/resourcegroups/([^/]+)') { "resource group $($Matches[2])" } elseif ($Scope -match '^/subscriptions/([^/]+)') { "subscription $($Matches[1])" } else { $Scope } } $privileged = @('8e3af657-a8ff-443c-a75c-2fe8c4bcb635', '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168', 'b24988ac-6180-42a0-ab88-20f7382dd24c') $roleName = @{} foreach ($row in (& $rowsOf 'roleNames')) { $roleName[((& $text $row 'id') -split '/')[-1].ToLowerInvariant()] = & $text $row 'roleName' } $changes = [System.Collections.Generic.List[object]]::new() foreach ($key in $kinds.Keys) { foreach ($row in (& $rowsOf $key)) { $scope = & $text $row 'scope' if (-not (& $InScope $scope)) { continue } $created = & $date (& $text $row 'createdOn') $updated = & $date (& $text $row 'updatedOn') $events = @() if ($created -and $created -ge $Since) { $events += @{ Change = 'Created'; When = $created; By = (& $text $row 'createdBy') } } if ($updated -and $updated -ge $Since -and (-not $created -or ($updated - $created).TotalMinutes -gt 1)) { $events += @{ Change = 'Updated'; When = $updated; By = (& $text $row 'updatedBy') } } foreach ($e in $events) { $detail = ''; $severity = 'Info'; $note = '' switch ($key) { 'roleAssignments' { $guid = ((& $text $row 'roleId') -split '/')[-1].ToLowerInvariant() $role = if ($roleName.Contains($guid)) { $roleName[$guid] } else { $guid } $detail = "$role to $(& $who (& $text $row 'principalId')) ($(& $text $row 'principalType'))" if ($privileged -contains $guid) { $severity = 'Medium'; $note = "Privileged access granted: $role." } } 'roleDefinitions' { $severity = 'Medium'; $note = 'A custom role was created or changed: check what it now allows.' } 'policyAssignments' { $enforcement = & $text $row 'enforcement' $detail = "Enforcement: $(if ($enforcement) { $enforcement } else { 'Default' })" if ($enforcement -eq 'DoNotEnforce') { $severity = 'Medium'; $note = 'The assignment does not enforce (DoNotEnforce): its deny and deploy effects are off.' } } } $item = [pscustomobject][ordered]@{ Severity = $severity When = $e.When Change = $e.Change Kind = $kinds[$key] Name = $(if (& $text $row 'name') { & $text $row 'name' } else { ((& $text $row 'id') -split '/')[-1] }) Scope = (& $scopeText $scope) By = (& $who $e.By) Detail = $detail Note = $note Id = (& $text $row 'id') } $item.PSObject.TypeNames.Insert(0, 'AAC.GovernanceChange') $changes.Add($item) } } } $sorted = @($changes | Sort-Object When -Descending) @{ Changes = $sorted Notices = @($notices) Stats = @{ Changes = $sorted.Count Notable = @($sorted | Where-Object Severity -EQ 'Medium').Count Authors = @($sorted | ForEach-Object { $_.By } | Where-Object { $_ } | Select-Object -Unique).Count Privileged = @($sorted | Where-Object { $_.Note -like 'Privileged access granted*' }).Count } } } |