Private/ConvertTo-AACRoleAssignment.ps1
|
function ConvertTo-AACRoleAssignment { <# .SYNOPSIS Builds Get-AACRoleAssignment's inventory (AAC.RoleAssignment and AAC.CustomRole) from the Resource Graph rows of Get-AACRoleAssignmentQuery, the PIM schedule instances, the Entra ID principals and the groups' members. .DESCRIPTION No Azure calls. -Read Invoke-AACGraphBatch's result -Scope Resolve-AACScope's (an empty GraphScope means everything the account can see) -Pim @{ Eligible; Instances; Read (bool); Errors } - the subscriptions' roleEligibilityScheduleInstances and roleAssignmentScheduleInstances -Directory Get-AACDirectoryObject's result for the principals and creators ($null: Entra ID not read) -Members group ID -> @{ Count; Names } (transitive), or @{ Error }; $null: not expanded An assignment is in scope when it is on a subscription in scope (or below it), on a management group above one, or at the root. Each assignment (AAC.RoleAssignment) has the principal and what it is - user, guest, group, application (or another tenant's), system- or user-assigned managed identity, or deleted - the role, whether it is built-in or custom, privileged, and able to write role assignments; the scope, its type and how many subscriptions it reaches; whether it is permanent, activated or eligible in PIM; the group's members; who created it and when; and its findings: High a deleted principal with a privileged role; a guest with a privileged role; an application or managed identity as Owner on a subscription or above; a custom role equivalent to Owner Medium a deleted principal or role; Owner or User Access Administrator given to a user directly (not a group); an application as Owner on a resource group or resource Low standing privileged access a person could hold eligible in PIM Custom roles (AAC.CustomRole): their assignments, whether a policy uses them, unused, equivalent to Owner, able to write role assignments, assignable scopes. Returns @{ Assignments; CustomRoles; Notices; Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [hashtable] $Read, [Parameter(Mandatory)] [hashtable] $Scope, [hashtable] $Pim, [hashtable] $Directory, [hashtable] $Members, [string] $TenantId = '' ) $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 $_ } } $rowsOf = { param([string] $Name) @(if ($Read.Rows -and $Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) } $leaf = { param([string] $Id) ($Id -split '/')[-1].ToLowerInvariant() } $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() $what = @{ roleAssignments = 'role assignments'; roleDefinitions = 'role definitions'; managementGroups = 'management groups'; userIdentities = 'user-assigned identities'; policyRoles = 'roles policies use' } foreach ($key in @($Read.Errors.Keys | Sort-Object)) { $notices.Add("The $(if ($what.Contains($key)) { $what[$key] } else { $key }) couldn't be read: $($Read.Errors[$key] -replace '\s+', ' ')") } # --- Roles: what each can do ------------------------------------------------------------------------------------------------ $builtInPrivileged = @{ '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' = 'Owner'; 'b24988ac-6180-42a0-ab88-20f7382dd24c' = 'Contributor'; '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9' = 'User Access Administrator'; 'f58310d9-a9f6-439a-9e8d-f62e7b41a168' = 'Role Based Access Control Administrator' } $grantsAccess = @('18d7d88d-d35e-4fb5-a5c3-7773c20a72d9', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635') $allows = { param($Permissions, [string] $Action) foreach ($block in @(& $list $Permissions)) { $yes = @(@(& $list (& $get $block 'actions')) | Where-Object { $Action -like [string]$_ }).Count $no = @(@(& $list (& $get $block 'notActions')) | Where-Object { $Action -like [string]$_ }).Count if ($yes -and -not $no) { return $true } } $false } $roles = @{} foreach ($row in (& $rowsOf 'roleDefinitions')) { $guid = & $text $row 'name' if (-not $guid) { $guid = & $leaf (& $text $row 'id') } $permissions = & $get $row 'permissions' $wildcard = @(@(& $list $permissions) | ForEach-Object { @(& $list (& $get $_ 'actions')) } | Where-Object { [string]$_ -eq '*' }).Count -gt 0 $canWrite = & $allows $permissions 'Microsoft.Authorization/roleAssignments/write' $custom = (& $text $row 'roleType') -eq 'CustomRole' $roles[$guid] = @{ Name = & $text $row 'roleName' Custom = $custom CanWrite = $canWrite OwnerEquivalent = $custom -and $wildcard -and $canWrite Privileged = $builtInPrivileged.Contains($guid) -or $canWrite -or ($custom -and $wildcard) Scopes = @(& $list (& $get $row 'assignableScopes')) Actions = @(@(& $list $permissions) | ForEach-Object { @(& $list (& $get $_ 'actions')) }).Count Id = & $text $row 'id' } } # --- Scope: subscriptions, management groups --------------------------------------------------------------------------------- $inScope = @{} $groupSubs = @{} foreach ($s in @($Scope.Subscriptions)) { $id = ([string]$s['subscriptionId']).ToLowerInvariant() $inScope[$id] = $true foreach ($m in @($s['chain'])) { if ($m -isnot [System.Collections.IDictionary]) { continue } $g = ([string]$m['name']).ToLowerInvariant() if (-not $groupSubs.Contains($g)) { $groupSubs[$g] = 0 } $groupSubs[$g]++ } } $unscoped = -not @($Scope['GraphScope'] | Where-Object { $_ }).Count $groupName = @{} foreach ($row in (& $rowsOf 'managementGroups')) { $groupName[(& $text $row 'name')] = $(if (& $text $row 'displayName') { & $text $row 'displayName' } else { & $text $row 'name' }) } $subName = { param([string] $Id) if ($Scope.Names -and $Scope.Names.Contains($Id)) { $Scope.Names[$Id] } else { $Id } } $describe = { param([string] $ScopeId) $s = $ScopeId.ToLowerInvariant() if ($s -eq '/') { return @{ Type = 'Root'; Name = 'Root (/)'; In = $true; Subscription = ''; Reach = "Inherited by $(@($Scope.Subscriptions).Count) subscription(s)" } } if ($s -match '^/providers/microsoft\.management/managementgroups/([^/]+)$') { $g = $Matches[1] $n = if ($groupSubs.Contains($g)) { $groupSubs[$g] } else { 0 } return @{ Type = 'Management group'; Name = $(if ($groupName.Contains($g)) { $groupName[$g] } else { $g }); In = ($unscoped -or $n -gt 0); Subscription = ''; Reach = "Inherited by $n subscription(s)" } } if ($s -match '^/subscriptions/([^/]+)(/resourcegroups/([^/]+))?(/providers/.+/([^/]+))?$') { $sub = $Matches[1] $type = if ($Matches[5]) { 'Resource' } elseif ($Matches[3]) { 'Resource group' } else { 'Subscription' } $name = switch ($type) { 'Resource' { "$($Matches[5]) ($($Matches[3]))" } 'Resource group' { "$($Matches[3]) ($(& $subName $sub))" } default { & $subName $sub } } return @{ Type = $type; Name = $name; In = $inScope.Contains($sub); Subscription = (& $subName $sub); Reach = 'Direct' } } @{ Type = 'Other'; Name = $ScopeId; In = $false; Subscription = ''; Reach = '' } } # --- Principals ------------------------------------------------------------------------------------------------------------- $userAssigned = @{} foreach ($row in (& $rowsOf 'userIdentities')) { $p = & $text $row 'principalId'; if ($p) { $userAssigned[$p] = $true } } $entraRead = $null -ne $Directory -and -not $Directory.Error if ($null -ne $Directory -and $Directory.Error) { $notices.Add("The principals couldn't be looked up in Entra ID ($($Directory.Error)): they are shown by ID, and deleted ones can't be told apart.") } $principal = { param([string] $Id, [string] $ArmType) $o = if ($entraRead -and $Directory.Objects.Contains($Id)) { $Directory.Objects[$Id] } else { $null } if ($o) { # '@odata.type' has a dot in its name: read it directly, not as a path. $odata = [string]$(if ($o -is [System.Collections.IDictionary] -and $o.Contains('@odata.type')) { $o['@odata.type'] }) $name = if (& $text $o 'displayName') { & $text $o 'displayName' } else { $Id } $upn = & $text $o 'userPrincipalName' if ($odata -like '*user') { return @{ Type = $(if ((& $text $o 'userType') -eq 'Guest') { 'Guest' } else { 'User' }); Name = $name; Upn = $upn; Kind = 'User' } } if ($odata -like '*group') { return @{ Type = 'Group'; Name = $name; Upn = ''; Kind = 'Group' } } if ((& $text $o 'servicePrincipalType') -eq 'ManagedIdentity') { return @{ Type = $(if ($userAssigned.Contains($Id)) { 'Managed identity (user-assigned)' } else { 'Managed identity (system-assigned)' }); Name = $name; Upn = ''; Kind = 'Workload' } } $owner = & $text $o 'appOwnerOrganizationId' return @{ Type = $(if ($TenantId -and $owner -and $owner -ne $TenantId) { 'Application (external)' } else { 'Application' }); Name = $name; Upn = ''; Kind = 'Workload' } } if ($entraRead) { return @{ Type = 'Deleted'; Name = "Identity not found ($(if ($ArmType) { $ArmType } else { 'unknown type' }))"; Upn = ''; Kind = 'Deleted' } } $kind = switch ($ArmType) { 'User' { 'User' } 'Group' { 'Group' } 'ForeignGroup' { 'Group' } 'ServicePrincipal' { 'Workload' } default { 'Unknown' } } @{ Type = $(switch ($ArmType) { 'ServicePrincipal' { 'Service principal' } 'ForeignGroup' { 'Group (foreign)' } '' { 'Unknown' } default { $ArmType } }); Name = $Id; Upn = ''; Kind = $kind } } $nameOf = { param([string] $Id) if (-not $Id) { '' } elseif ($entraRead -and $Directory.Objects.Contains($Id)) { $o = $Directory.Objects[$Id]; $(if (& $text $o 'userPrincipalName') { & $text $o 'userPrincipalName' } else { & $text $o 'displayName' }) } else { $Id } } # --- PIM: activated and eligible ------------------------------------------------------------------------------------------------ $pimRead = $null -ne $Pim -and $Pim['Read'] $activated = @{} foreach ($i in @(if ($Pim) { & $list $Pim['Instances'] })) { $p = & $get $i 'properties' if ((& $text $p 'assignmentType') -eq 'Activated') { $origin = & $text $p 'originRoleAssignmentId'; if ($origin) { $activated[$origin.ToLowerInvariant()] = $true; $activated[(& $leaf $origin)] = $true } } } if ($Pim -and $Pim['Errors']) { foreach ($e in @(& $list $Pim['Errors'])) { $notices.Add("PIM couldn't be read for $e") } } # --- The assignments ---------------------------------------------------------------------------------------------------------- $usage = @{} $rank = (Get-AACSeverityRank).Rank $assignments = [System.Collections.Generic.List[object]]::new() $build = { param([string] $AssignmentKind, [string] $PrincipalId, [string] $ArmType, [string] $RoleId, [string] $ScopeId, [hashtable] $More) $d = & $describe $ScopeId if (-not $d.In) { return } $guid = & $leaf $RoleId $role = if ($roles.Contains($guid)) { $roles[$guid] } else { $null } $who = & $principal $PrincipalId $ArmType $findings = [System.Collections.Generic.List[object]]::new() $privileged = $role -and $role.Privileged $ownerLike = $role -and ($guid -eq '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' -or $role.OwnerEquivalent) $high = $d.Type -in 'Root', 'Management group', 'Subscription' if ($who.Kind -eq 'Deleted') { $findings.Add(@{ S = $(if ($privileged) { 'High' } else { 'Medium' }); T = 'Orphaned: the principal no longer exists in Entra ID - remove the assignment.' }) } if (-not $role -and (& $rowsOf 'roleDefinitions').Count) { $findings.Add(@{ S = 'Medium'; T = 'Orphaned: the role definition no longer exists - remove the assignment.' }) } if ($who.Type -eq 'Guest' -and $privileged) { $findings.Add(@{ S = 'High'; T = "A guest holds $($role.Name) - external accounts with privileged access are a common way in." }) } if ($who.Kind -eq 'Workload' -and $ownerLike) { $findings.Add(@{ S = $(if ($high) { 'High' } else { 'Medium' }); T = "An application or managed identity is $($role.Name) on a $($d.Type.ToLowerInvariant()) - it can give itself and others any access; use a narrower role." }) } if ($role -and $role.OwnerEquivalent) { $findings.Add(@{ S = 'High'; T = "The custom role $($role.Name) is equivalent to Owner (every action, including writing role assignments)." }) } if ($who.Kind -eq 'User' -and $grantsAccess -contains $guid -and $AssignmentKind -ne 'Eligible (PIM)') { $findings.Add(@{ S = 'Medium'; T = "$($role.Name) given to a user directly - give it to a group, so access is reviewed in one place." }) } if ($pimRead -and $who.Kind -eq 'User' -and $privileged -and $AssignmentKind -eq 'Permanent') { $findings.Add(@{ S = 'Low'; T = 'Standing privileged access: make it eligible in PIM, activated when needed.' }) } $worst = @($findings | Sort-Object { $rank[$_.S] } | Select-Object -First 1) $member = if ($null -ne $Members -and $who.Kind -eq 'Group' -and $Members.Contains($PrincipalId)) { $Members[$PrincipalId] } else { $null } $item = [pscustomobject][ordered]@{ Severity = $(if ($worst.Count) { $worst[0].S } else { 'Info' }) Principal = $who.Name PrincipalType = $who.Type SignInName = $who.Upn Role = $(if ($role) { $role.Name } else { "Deleted role ($guid)" }) RoleType = $(if (-not $role) { 'Unknown' } elseif ($role.Custom) { 'Custom' } else { 'Built-in' }) Privileged = [bool]$privileged CanWriteRoleAssignments = [bool]($role -and $role.CanWrite) Assignment = $AssignmentKind ScopeType = $d.Type Scope = $d.Name Reach = $d.Reach Subscription = $d.Subscription Members = $(if ($member -and -not $member.Contains('Error')) { $member.Count } else { $null }) MemberNames = $(if ($member -and -not $member.Contains('Error')) { $member.Names } else { '' }) Condition = [bool]$More['Condition'] Findings = (@($findings | ForEach-Object { $_.T }) -join ' ') CreatedOn = $More['CreatedOn'] CreatedBy = [string]$More['CreatedBy'] EndsOn = $More['EndsOn'] PrincipalId = $PrincipalId RoleDefinitionId = $RoleId ScopeId = $ScopeId Id = [string]$More['Id'] } $item.PSObject.TypeNames.Insert(0, 'AAC.RoleAssignment') $assignments.Add($item) } foreach ($row in (& $rowsOf 'roleAssignments')) { $guid = & $leaf (& $text $row 'roleId') if (-not $usage.Contains($guid)) { $usage[$guid] = 0 } $usage[$guid]++ $id = & $text $row 'id' $kind = if ($activated.Contains($id) -or $activated.Contains((& $leaf $id))) { 'Activated (PIM)' } else { 'Permanent' } & $build $kind (& $text $row 'principalId') (& $text $row 'principalType') (& $text $row 'roleId') (& $text $row 'scope') @{ Condition = [bool](& $text $row 'condition'); CreatedOn = (& $date (& $text $row 'createdOn')); CreatedBy = (& $nameOf (& $text $row 'createdBy')); Id = $id } } $seen = @{} foreach ($e in @(if ($Pim) { & $list $Pim['Eligible'] })) { $p = & $get $e 'properties' if ((& $text $p 'memberType') -and (& $text $p 'memberType') -ne 'Direct') { continue } $key = "$((& $text $p 'principalId').ToLowerInvariant())|$((& $text $p 'roleDefinitionId').ToLowerInvariant())|$((& $text $p 'scope').ToLowerInvariant())" if ($seen.Contains($key)) { continue } $seen[$key] = $true & $build 'Eligible (PIM)' (& $text $p 'principalId').ToLowerInvariant() (& $text $p 'principalType') (& $text $p 'roleDefinitionId').ToLowerInvariant() (& $text $p 'scope').ToLowerInvariant() @{ Condition = [bool](& $text $p 'condition'); CreatedOn = (& $date (& $text $p 'createdOn')); EndsOn = (& $date (& $text $p 'endDateTime')); Id = (& $text $e 'id') } } if ($null -ne $Members) { foreach ($g in @($Members.Keys | Where-Object { $Members[$_].Contains('Error') })) { $notices.Add("The members of group $g couldn't be read: $($Members[$g].Error)"); break } } # --- Custom roles ------------------------------------------------------------------------------------------------------------------ $byPolicy = @{} foreach ($row in (& $rowsOf 'policyRoles')) { $byPolicy[(& $leaf (& $text $row 'role'))] = $true } $customRoles = @(foreach ($guid in @($roles.Keys | Where-Object { $roles[$_].Custom })) { $r = $roles[$guid] $count = if ($usage.Contains($guid)) { $usage[$guid] } else { 0 } $eligibleCount = @($assignments | Where-Object { $_.Assignment -eq 'Eligible (PIM)' -and (& $leaf $_.RoleDefinitionId) -eq $guid }).Count $o = [pscustomobject][ordered]@{ Role = $r.Name Assignments = $count + $eligibleCount UsedByPolicy = $byPolicy.Contains($guid) Unused = ($count + $eligibleCount) -eq 0 -and -not $byPolicy.Contains($guid) OwnerEquivalent = $r.OwnerEquivalent CanWriteRoleAssignments = $r.CanWrite Actions = $r.Actions AssignableScopes = ($r.Scopes -join ', ') Id = $r.Id } $o.PSObject.TypeNames.Insert(0, 'AAC.CustomRole') $o }) $customRoles = @($customRoles | Sort-Object -Property @{ Expression = 'OwnerEquivalent'; Descending = $true }, @{ Expression = 'Unused'; Descending = $true }, Role) $order = @{ Root = 0; 'Management group' = 1; Subscription = 2; 'Resource group' = 3; Resource = 4; Other = 5 } $sorted = @($assignments | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, @{ Expression = { $order[$_.ScopeType] } }, Scope, Role, Principal) @{ Assignments = $sorted CustomRoles = $customRoles Notices = @($notices) Stats = @{ Assignments = $sorted.Count Privileged = @($sorted | Where-Object Privileged).Count Eligible = @($sorted | Where-Object Assignment -EQ 'Eligible (PIM)').Count Orphaned = @($sorted | Where-Object { $_.PrincipalType -eq 'Deleted' -or $_.RoleType -eq 'Unknown' }).Count Findings = @($sorted | Where-Object Findings).Count High = @($sorted | Where-Object Severity -EQ 'High').Count UnusedRoles = @($customRoles | Where-Object Unused).Count OwnerRoles = @($customRoles | Where-Object OwnerEquivalent).Count } } } |