public/Get-MsecAzureRoleAssignment.ps1
|
function Get-MsecAzureRoleAssignment { <# .SYNOPSIS Azure RBAC role assignments across every accessible subscription, with role and principal names resolved - who has what, where. .DESCRIPTION Combines three sources, and keeping them apart is the point: assignments Resource Graph, through your Az context. One query for the whole estate rather than a Set-AzContext loop that mutates the caller's context - 2415 assignments in one request on a real tenant, against 400 from Get-AzRoleAssignment in one subscription. role names ARM REST, also your Az context. No Graph involved. principals Microsoft Graph, through the msec app session. THIS IS WHY THE TWO IDENTITIES STAY SEPARATE. Get-AzRoleAssignment resolves principal names by calling Graph ITSELF, using whatever identity holds the Az context. That works for a person - who has directory read by default - and silently returns blank names for a service principal without Graph permissions. So a pipeline that ran the same code as a human would quietly produce a report full of GUIDs. Splitting the lookups means the ARM identity needs no directory access at all, and the answer is the same in a pipeline as it is on a laptop. PRINCIPALS ARE RESOLVED IN BULK, up to 1000 ids per call, through /directoryObjects/getByIds. The obvious implementation is one Graph call per assignment, which on this tenant would be 2415 round trips. A PRINCIPAL GRAPH CANNOT NAME IS STILL AN ASSIGNMENT. Deleted users and service principals leave their role assignments behind - that is the finding, not an error - so those rows come back with IsResolved = $false and the raw id rather than being dropped. .PARAMETER Subscription Limit to these subscriptions, by name or id. Omit for everything the Az context sees. .PARAMETER ScopeLevel Limit to assignments made at these scopes - ManagementGroup, Subscription, ResourceGroup, Resource. A Contributor at subscription scope is a very different finding from one on a single storage account. .PARAMETER PrincipalType Limit to User, Group or ServicePrincipal. .EXAMPLE Connect-AzAccount Connect-Msec -KeyVaultName kv-msec -TenantId <guid> -ClientId <guid> Get-MsecAzureRoleAssignment | Format-Table PrincipalName, RoleName, ScopeLevel, ScopeName .EXAMPLE # The findings worth chasing: broad rights held high up. Get-MsecAzureRoleAssignment -ScopeLevel Subscription, ManagementGroup | Where-Object RoleName -in 'Owner', 'Contributor', 'User Access Administrator' | Sort-Object RoleName, PrincipalName .EXAMPLE # Assignments left behind by principals that no longer exist. Get-MsecAzureRoleAssignment | Where-Object { -not $_.IsResolved } .OUTPUTS PSCustomObject per assignment, PSTypeName 'MsecAzureRoleAssignment'. .NOTES Needs BOTH an Az context (Reader on the subscriptions) and an msec session (Directory.Read.All, or User/Group/Application.Read.All) - they do different halves of the job. Without the msec session the assignments still come back, with names unresolved and a warning. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Alias('SubscriptionId')] [string[]] $Subscription, [ValidateSet('ManagementGroup', 'Subscription', 'ResourceGroup', 'Resource', 'Root')] [string[]] $ScopeLevel, [ValidateSet('User', 'Group', 'ServicePrincipal')] [string[]] $PrincipalType ) $query = @{ ResourceType = 'Authorization'; Name = 'RoleAssignments' } if ($Subscription) { $query['Subscription'] = $Subscription } $assignments = @(Search-MsecAzureResourceGraph @query) if (-not $assignments.Count) { return } if ($ScopeLevel) { $assignments = @($assignments | Where-Object { $_.ScopeLevel -in $ScopeLevel }) } if ($PrincipalType) { $assignments = @($assignments | Where-Object { $_.PrincipalType -in $PrincipalType }) } if (-not $assignments.Count) { return } # ---- role names, via ARM ------------------------------------------------------------------ # # Cached per role: an estate has thousands of assignments and a few dozen distinct roles. # Through ARM REST rather than Get-AzRoleDefinition, which lives in Az.Resources - NOT one # of this module's dependencies. Using it would work on a developer's machine, where # Az.Resources is usually loaded, and fail on a clean agent that installed only msec and # what msec declares. Invoke-AzRestMethod is in Az.Accounts, which is a dependency. # # Tenant scope, so a role defined anywhere resolves without knowing which subscription to # ask - built-in roles are tenant-wide and custom ones are still readable from here. $roleName = @{} foreach ($guid in @($assignments.RoleDefinitionGuid | Sort-Object -Unique)) { if (-not $guid) { continue } try { $response = Invoke-AzRestMethod -Method GET ` -Path "/providers/Microsoft.Authorization/roleDefinitions/$guid`?api-version=2022-04-01" ` -ErrorAction Stop if ($response.StatusCode -eq 200) { $roleName[$guid] = ($response.Content | ConvertFrom-Json).properties.roleName } else { Write-Verbose "Role definition $guid returned HTTP $($response.StatusCode); the row will carry the id." } } catch { Write-Verbose "Could not resolve role definition $guid : $($_.Exception.Message)" } } # ---- principals, via Graph ---------------------------------------------------------------- $principal = @{} $canResolve = $null -ne $script:MsecSession if (-not $canResolve) { # Loud, because a report full of GUIDs looks like a data problem rather than a missing # connection - and that is exactly how Get-AzRoleAssignment fails in a pipeline. Write-Warning 'No msec session, so principal names cannot be resolved - every row will carry an id and IsResolved = $false. Run Connect-Msec as well as Connect-AzAccount.' } else { $ids = @($assignments.PrincipalId | Where-Object { $_ } | Sort-Object -Unique) # getByIds takes up to 1000 per call. One call per assignment would be 2415 round trips # on a tenant this size. for ($i = 0; $i -lt $ids.Count; $i += 1000) { $batch = @($ids[$i..([Math]::Min($i + 999, $ids.Count - 1))]) try { $response = Invoke-MsecGraphRequest -Method POST -Path '/v1.0/directoryObjects/getByIds' ` -Body @{ ids = $batch } foreach ($object in @($response.value)) { $principal[[string] $object.id] = $object } } catch { Write-Warning "Could not resolve a batch of $($batch.Count) principal(s); those rows will carry ids rather than names: $($_.Exception.Message)" } } } foreach ($assignment in $assignments) { $object = $principal[[string] $assignment.PrincipalId] [PSCustomObject]@{ PSTypeName = 'MsecAzureRoleAssignment' PrincipalName = $(if ($object) { $object.displayName } else { $null }) PrincipalUserPrincipalName = $(if ($object) { $object.userPrincipalName } else { $null }) PrincipalType = $assignment.PrincipalType RoleName = $(if ($roleName.ContainsKey([string] $assignment.RoleDefinitionGuid)) { $roleName[[string] $assignment.RoleDefinitionGuid] } else { $null }) ScopeLevel = $assignment.ScopeLevel ScopeName = $assignment.ScopeName Scope = $assignment.Scope SubscriptionName = $assignment.SubscriptionName # False for a principal Graph could not name - a deleted user or service principal # whose assignment outlived it. That is the finding, not an error. IsResolved = [bool] $object PrincipalId = $assignment.PrincipalId RoleDefinitionGuid = $assignment.RoleDefinitionGuid CreatedOn = $assignment.CreatedOn SubscriptionId = $assignment.SubscriptionId Id = $assignment.Id } } } |