<# .SYNOPSIS azureblog.co.uk Access Map: who holds which Entra ID (PIM) roles and Azure RBAC roles, with group assignments expanded to their members. .DESCRIPTION - Entra ID roles (Microsoft Graph): PIM eligible + active (activated or permanent). - Azure RBAC (ARM): PIM eligible + active role assignments at management group, subscription, resource group and resource scope, across every subscription you can read. - Groups: transitive members (nested groups followed) plus PIM-for-Groups eligible members. Read-only. Nothing in the tenant is changed. It saves a JSON file (and a flat CSV) that you load into https://azureblog.co.uk/tools/access-map/ . The file is read in your browser and never uploaded. .REQUIREMENTS PowerShell 7+ (pwsh). Install-Module Microsoft.Graph.Authentication, Az.Accounts -Scope CurrentUser Graph consent: RoleManagement.Read.Directory, Directory.Read.All, GroupMember.Read.All, PrivilegedEligibilitySchedule.Read.AzureADGroup (optional, for PIM-for-Groups) Azure: Reader (or higher) on the subscriptions / management groups to report on. .EXAMPLE .\Get-AzureBlogAccessMap.ps1 .\Get-AzureBlogAccessMap.ps1 -TenantId -SubscriptionFilter 'Prod-*' -OutputFolder C:\Reports .\Get-AzureBlogAccessMap.ps1 -SkipGroupExpansion -SkipEntra #> #Requires -Version 7.0 [CmdletBinding()] param( [string]$TenantId, [string]$SubscriptionFilter = '*', # wildcard on subscription name [string]$OutputFolder = (Get-Location).Path, [switch]$SkipEntra, [switch]$SkipAzure, [switch]$SkipGroupExpansion, [switch]$NoOpen ) $ErrorActionPreference = 'Stop' $warnings = [System.Collections.Generic.List[string]]::new() $rows = [System.Collections.Generic.List[object]]::new() function Warn($m){ Write-Warning $m; $warnings.Add($m) } trap { $ii = $_.InvocationInfo Write-Host "`nFAILED at line $($ii.ScriptLineNumber): $($ii.Line.Trim())" -ForegroundColor Red Write-Host $_.Exception.Message -ForegroundColor Red break } function Get-Key($Table, $Key, $Default = $null) { if ($null -eq $Key -or $Key -eq '') { return $Default }; $v = $Table[$Key]; if ($null -eq $v) { $Default } else { $v } } #region ---------- helpers ---------- function Invoke-GraphAll([string]$Uri) { $out = @() while ($Uri) { $r = Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject if ($r.value) { $out += $r.value } $Uri = $r.'@odata.nextLink' } $out } function Invoke-ArmAll([string]$Path) { $out = @(); $uri = "https://management.azure.com$Path" while ($uri) { $resp = Invoke-AzRestMethod -Method GET -Uri $uri if ($resp.StatusCode -ge 400) { throw "ARM $($resp.StatusCode): $($resp.Content)" } $j = $resp.Content | ConvertFrom-Json if ($j.value) { $out += $j.value } $uri = $j.nextLink } $out } $principalCache = @{} function Resolve-Principals([string[]]$Ids) { $todo = @($Ids | Where-Object { $_ -and -not $principalCache.ContainsKey($_) } | Select-Object -Unique) for ($i = 0; $i -lt $todo.Count; $i += 1000) { $chunk = @($todo[$i..([Math]::Min($i + 999, $todo.Count - 1))]) $body = @{ ids = @($chunk); types = @('user','group','servicePrincipal') } | ConvertTo-Json $res = Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/directoryObjects/getByIds' -Body $body -OutputType PSObject foreach ($o in $res.value) { $type = switch -Wildcard ($o.'@odata.type') { '*user' {'User'} '*group' {'Group'} '*servicePrincipal' {'ServicePrincipal'} default {'Unknown'} } $principalCache[$o.id] = [pscustomobject]@{ id=$o.id; displayName=$o.displayName; upn=($o.userPrincipalName ?? $o.mail ?? $o.appId); type=$type } } } foreach ($id in $todo) { if (-not $principalCache.ContainsKey($id)) { $principalCache[$id] = [pscustomobject]@{ id=$id; displayName="(deleted/unknown) $id"; upn=''; type='Unknown' } } } } function Parse-ArmScope([string]$Scope) { $s = [ordered]@{ scopeType='Resource'; subscriptionId=$null; resourceGroup=$null; scopeName=$Scope } if ($Scope -eq '/') { $s.scopeType='Root'; $s.scopeName='Root (/)'; return $s } if ($Scope -match '^/providers/Microsoft\.Management/managementGroups/([^/]+)$') { $s.scopeType='ManagementGroup'; $s.scopeName=$Matches[1]; return $s } if ($Scope -match '^/subscriptions/([^/]+)(/resourceGroups/([^/]+))?(/providers/.+)?$') { $s.subscriptionId = $Matches[1] if ($Matches[3]) { $s.resourceGroup = $Matches[3] } if ($Matches[4]) { $s.scopeType='Resource'; $s.scopeName = ($Matches[4] -split '/')[-1] } elseif ($Matches[3]) { $s.scopeType='ResourceGroup'; $s.scopeName=$Matches[3] } else { $s.scopeType='Subscription'; $s.scopeName=$Matches[1] } } $s } #endregion #region ---------- connect ---------- Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan $graphScopes = 'RoleManagement.Read.Directory','Directory.Read.All','GroupMember.Read.All','PrivilegedEligibilitySchedule.Read.AzureADGroup' $cp = @{ Scopes = $graphScopes; NoWelcome = $true }; if ($TenantId) { $cp.TenantId = $TenantId } Connect-MgGraph @cp | Out-Null $ctx = Get-MgContext $tenantName = try { (Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/v1.0/organization?$select=displayName' -OutputType PSObject).value[0].displayName } catch { $ctx.TenantId } if (-not $SkipAzure) { Write-Host "Connecting to Azure..." -ForegroundColor Cyan $azCtx = Get-AzContext if (-not $azCtx -or ($TenantId -and $azCtx.Tenant.Id -ne $TenantId)) { $ap = @{}; if ($TenantId) { $ap.Tenant = $TenantId } Connect-AzAccount @ap | Out-Null } } #endregion #region ---------- Entra ID PIM roles ---------- if (-not $SkipEntra) { Write-Host "Reading Entra ID role definitions and PIM schedules..." -ForegroundColor Cyan $roleDefs = @{} Invoke-GraphAll 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$select=id,displayName,templateId' | ForEach-Object { $roleDefs[$_.id] = $_.displayName; if ($_.templateId) { $roleDefs[$_.templateId] = $_.displayName } } $elig = @(Invoke-GraphAll 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilityScheduleInstances') $act = @(Invoke-GraphAll 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances') Resolve-Principals (@($elig.principalId) + @($act.principalId)) $auCache = @{} function Get-EntraScope($dsid) { if (-not $dsid -or $dsid -eq '/') { return @{ t='Tenant'; n='Directory' } } if ($dsid -match '^/administrativeUnits/(.+)$') { $id = $Matches[1] if (-not $auCache[$id]) { $auCache[$id] = try { (Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/directory/administrativeUnits/$id`?`$select=displayName" -OutputType PSObject).displayName } catch { $id } } return @{ t='AdministrativeUnit'; n="AU: $($auCache[$id])" } } @{ t='App'; n="Object $dsid" } } foreach ($set in @(@{ items=$elig; state='Eligible' }, @{ items=$act; state='Active' })) { foreach ($i in $set.items) { $p = Get-Key $principalCache $i.principalId ([pscustomobject]@{ id=$i.principalId; displayName="(unknown) $($i.principalId)"; upn=''; type='Unknown' }); $sc = Get-EntraScope $i.directoryScopeId $atype = if ($set.state -eq 'Eligible') { if ($i.endDateTime) {'Time-bound'} else {'Permanent eligible'} } elseif ($i.assignmentType -eq 'Activated') {'Activated'} elseif ($i.endDateTime) {'Time-bound'} else {'Permanent'} $rows.Add([pscustomobject]@{ id=$i.id; plane='Entra'; principalId=$p.id; principalName=$p.displayName; upn=$p.upn; principalType=$p.type role=(Get-Key $roleDefs $i.roleDefinitionId $i.roleDefinitionId); state=$set.state; assignmentType=$atype scopeType=$sc.t; scopeName=$sc.n; subscription=$null; resourceGroup=$null; start=$i.startDateTime; end=$i.endDateTime }) } } Write-Host (" Entra: {0} eligible, {1} active" -f $elig.Count, $act.Count) } #endregion #region ---------- Azure RBAC (PIM eligible + active) ---------- $subCount = 0 if (-not $SkipAzure) { $subs = @(Get-AzSubscription -TenantId (Get-AzContext).Tenant.Id | Where-Object { $_.State -eq 'Enabled' -and $_.Name -like $SubscriptionFilter }) $subCount = $subs.Count $subNames = @{}; $subs | ForEach-Object { $subNames[$_.Id] = $_.Name } $seen = @{} $api = 'api-version=2020-10-01' $n = 0 foreach ($sub in $subs) { $n++; Write-Progress -Activity 'Scanning subscriptions' -Status $sub.Name -PercentComplete ($n / [math]::Max($subs.Count,1) * 100) $base = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization" try { $e = Invoke-ArmAll "$base/roleEligibilityScheduleInstances?$api" $a = Invoke-ArmAll "$base/roleAssignmentScheduleInstances?$api" } catch { Warn "Subscription '$($sub.Name)': could not read PIM data ($($_.Exception.Message.Split("`n")[0]))"; continue } $origins = @{} foreach ($set in @(@{ items=$e; state='Eligible' }, @{ items=$a; state='Active' })) { foreach ($i in $set.items) { $pr = $i.properties; $x = $pr.expandedProperties if ($pr.originRoleAssignmentId) { $origins[[string]$pr.originRoleAssignmentId] = $true } $key = "$($set.state)|$($pr.principalId)|$($pr.roleDefinitionId -replace '^.*/','')|$($pr.scope)".ToLower() if ($seen[$key]) { continue } $sc = Parse-ArmScope $pr.scope $seen[$key] = $true $atype = if ($set.state -eq 'Eligible') { if ($pr.endDateTime) {'Time-bound'} else {'Permanent eligible'} } elseif ($pr.assignmentType -eq 'Activated') {'Activated'} elseif ($pr.endDateTime) {'Time-bound'} else {'Permanent'} $ptype = switch ($x.principal.type) { 'User' {'User'} 'Group' {'Group'} 'ServicePrincipal' {'ServicePrincipal'} default { $x.principal.type } } $rows.Add([pscustomobject]@{ id=$i.id; plane='Azure'; principalId=$pr.principalId; principalName=($x.principal.displayName ?? $pr.principalId) upn=$x.principal.email; principalType=$ptype; role=$x.roleDefinition.displayName; state=$set.state; assignmentType=$atype scopeType=($sc.scopeType -replace 'Root','ManagementGroup'); scopeName=($x.scope.displayName ?? $sc.scopeName) subscription=(Get-Key $subNames $sc.subscriptionId $sc.subscriptionId); resourceGroup=$sc.resourceGroup start=$pr.startDateTime; end=$pr.endDateTime }) } } # Safety net: classic role assignments not surfaced by the PIM APIs try { $ra = Invoke-ArmAll "$base/roleAssignments?api-version=2022-04-01" $missing = $ra | Where-Object { -not $origins[$_.id] -and -not $seen["active|$($_.properties.principalId)|$($_.properties.roleDefinitionId -replace '^.*/','')|$($_.properties.scope)".ToLower()] } if ($missing) { $defs = @{}; Invoke-ArmAll "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/roleDefinitions?api-version=2022-04-01" | ForEach-Object { $defs[$_.name] = $_.properties.roleName } Resolve-Principals @($missing.properties.principalId) foreach ($m in $missing) { $pr = $m.properties; $sc = Parse-ArmScope $pr.scope; $p = Get-Key $principalCache $pr.principalId ([pscustomobject]@{ id=$pr.principalId; displayName="(unknown) $($pr.principalId)"; upn=''; type='Unknown' }) $seen["active|$($pr.principalId)|$($pr.roleDefinitionId -replace '^.*/','')|$($pr.scope)".ToLower()] = $true $rows.Add([pscustomobject]@{ id=$m.id; plane='Azure'; principalId=$p.id; principalName=$p.displayName; upn=$p.upn; principalType=$p.type role=(Get-Key $defs ($pr.roleDefinitionId -replace '^.*/','') 'Unknown role'); state='Active'; assignmentType='Permanent' scopeType=($sc.scopeType -replace 'Root','ManagementGroup'); scopeName=$sc.scopeName subscription=(Get-Key $subNames $sc.subscriptionId $sc.subscriptionId); resourceGroup=$sc.resourceGroup; start=$null; end=$null }) } } } catch { Warn "Subscription '$($sub.Name)': roleAssignments fallback failed ($($_.Exception.Message.Split("`n")[0]))" } } Write-Progress -Activity 'Scanning subscriptions' -Completed Write-Host (" Azure: {0} assignments across {1} subscriptions" -f ($rows | Where-Object plane -eq 'Azure').Count, $subCount) } #endregion #region ---------- group expansion ---------- $groupMembers = @{} if (-not $SkipGroupExpansion) { $groupIds = @($rows | Where-Object { $_.principalType -eq 'Group' -and $_.principalId } | Select-Object -ExpandProperty principalId -Unique) Write-Host "Expanding $($groupIds.Count) groups (transitive + PIM-for-Groups eligible members)..." -ForegroundColor Cyan $pimGroupsOk = $true foreach ($gid in $groupIds) { $list = @{} try { $direct = Invoke-GraphAll "https://graph.microsoft.com/v1.0/groups/$gid/members?`$select=id,displayName,userPrincipalName,appId" $directIds = @{}; $direct | ForEach-Object { $directIds[$_.id] = $true } $nestedGroupNames = ($direct | Where-Object { $_.'@odata.type' -like '*group' }).displayName -join ', ' foreach ($m in (Invoke-GraphAll "https://graph.microsoft.com/v1.0/groups/$gid/transitiveMembers?`$select=id,displayName,userPrincipalName,appId")) { $t = switch -Wildcard ($m.'@odata.type') { '*user' {'User'} '*servicePrincipal' {'ServicePrincipal'} '*device' {'Device'} default { $null } } if (-not $t -or $t -eq 'Device') { continue } # nested groups are represented by their members $list[$m.id] = [ordered]@{ id=$m.id; displayName=$m.displayName; upn=($m.userPrincipalName ?? $m.appId); type=$t depth=$(if ($directIds[$m.id]) {1} else {2}); via=$(if (-not $directIds[$m.id]) { $nestedGroupNames }); eligible=$false } } } catch { Warn "Group $gid`: could not read members ($($_.Exception.Message.Split("`n")[0]))" } if ($pimGroupsOk) { try { $pe = Invoke-GraphAll "https://graph.microsoft.com/v1.0/identityGovernance/privilegedAccess/group/eligibilityScheduleInstances?`$filter=groupId eq '$gid'" | Where-Object accessId -eq 'member' if ($pe) { Resolve-Principals @($pe.principalId) foreach ($x in $pe) { if (-not $x.principalId -or $list[$x.principalId]) { continue } $p = Get-Key $principalCache $x.principalId ([pscustomobject]@{ id=$x.principalId; displayName="(unknown) $($x.principalId)"; upn=''; type='Unknown' }) $list[$p.id] = [ordered]@{ id=$p.id; displayName=$p.displayName; upn=$p.upn; type=$p.type; depth=1; via=$null; eligible=$true } } } } catch { $pimGroupsOk = $false; Warn "PIM-for-Groups eligible members not readable (needs PrivilegedEligibilitySchedule.Read.AzureADGroup) - skipped." } } $groupMembers[$gid] = @($list.Values | Sort-Object { $_.displayName }) } } #endregion #region ---------- output ---------- $stamp = Get-Date -Format 'yyyyMMdd-HHmm' New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null $payload = [ordered]@{ schema = 'azureblog-access-map/1' tenantName = $tenantName; generated = (Get-Date).ToString('o'); subscriptionCount = $subCount assignments = $rows; groupMembers = $groupMembers; warnings = $warnings } $json = $payload | ConvertTo-Json -Depth 8 -Compress $jsonPath = Join-Path $OutputFolder "azureblog-access-map-$stamp.json" $json | Set-Content -Path $jsonPath -Encoding UTF8 # Flat CSV including one row per group member $flat = foreach ($r in $rows) { $r | Select-Object principalName,upn,principalType,@{n='viaGroup';e={''}},plane,role,state,assignmentType,scopeType,subscription,resourceGroup,scopeName,start,end if ($r.principalType -eq 'Group' -and $r.principalId -and $groupMembers[$r.principalId]) { foreach ($m in $groupMembers[$r.principalId]) { [pscustomobject]@{ principalName=$m.displayName; upn=$m.upn; principalType=$m.type viaGroup=($r.principalName + $(if ($m.eligible) {' (eligible member)'})); plane=$r.plane; role=$r.role state=$(if ($m.eligible) {'Eligible'} else {$r.state}); assignmentType=$r.assignmentType; scopeType=$r.scopeType subscription=$r.subscription; resourceGroup=$r.resourceGroup; scopeName=$r.scopeName; start=$r.start; end=$r.end } } } } $csvPath = Join-Path $OutputFolder "azureblog-access-map-$stamp.csv" $flat | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 Write-Host "`nDone." -ForegroundColor Green Write-Host " JSON : $jsonPath" Write-Host " CSV : $csvPath" if ($warnings.Count) { Write-Host " $($warnings.Count) warning(s) - see above." -ForegroundColor Yellow } Write-Host "`nNext: open https://azureblog.co.uk/tools/access-map/ and drop the JSON file onto the page." -ForegroundColor Cyan Write-Host " It's read in your browser and never uploaded." if (-not $NoOpen) { try { Start-Process 'https://azureblog.co.uk/tools/access-map/' } catch {} } #endregion