Permission Audit (Multi-Tenant)
A comprehensive permission audit that supports aggregating data from multiple Entra ID tenants in a single run. This is the evolution of the single-tenant Permission Audit.
Requirements
Install-Module Microsoft.Graph -Scope CurrentUser
Install-Module Az -Scope CurrentUser
Install-Module ImportExcel -Scope CurrentUserScript
param(
[string[]]$TenantIds = @()
)
<#
.SYNOPSIS
Exports Entra ID role assignments, group memberships, and group permissions
into a single Excel workbook with multiple tabs.
.DESCRIPTION
This script performs a comprehensive permission audit and now supports
aggregating data from multiple Entra ID tenants in a single run.
The Excel workbook contains the following tabs:
- Tab 1: Azure RBAC Permissions - group permissions with RBAC and directory roles
- Tab 2: Users_With_Entra_Roles - users with Entra ID role assignments
- Tab 3: User_Group_Memberships - all user group memberships
- Tab 4: Group_Permissions - permissions assigned to groups
The script processes all Azure subscriptions in every provided tenant to
ensure complete RBAC coverage.
.PARAMETER TenantIds
Optional array of Entra tenant IDs (GUID). If omitted, the tenant selected
during the initial Microsoft Graph sign-in is used. Provide multiple IDs to
collect memberships and permissions from multiple tenants.
.REQUIREMENTS
- Microsoft.Graph PowerShell module
- Az PowerShell module
- ImportExcel PowerShell module
Install-Module Microsoft.Graph -Scope CurrentUser
Install-Module Az -Scope CurrentUser
Install-Module ImportExcel -Scope CurrentUser
#>
if (-not $TenantIds -or $TenantIds.Count -eq 0) {
Write-Host "No TenantIds supplied. Attempting to auto-discover tenants from Azure subscriptions..." -ForegroundColor Cyan
try {
# Ensure we have an Azure connection to list subscriptions
if (-not (Get-AzContext -ErrorAction SilentlyContinue)) {
Write-Host "Logging into Azure for discovery..." -ForegroundColor Gray
Connect-AzAccount -UseDeviceAuthentication -ErrorAction Stop | Out-Null
}
$azSubs = Get-AzSubscription
$discoveredIds = $azSubs.TenantId | Select-Object -Unique
if ($discoveredIds) {
$tenantTargets = $discoveredIds | ForEach-Object { [PSCustomObject]@{ TenantId = $_ } }
Write-Host "Discovered $($tenantTargets.Count) tenant(s) from subscriptions: $($tenantTargets.TenantId -join ', ')" -ForegroundColor Green
} else {
Write-Host "No Azure subscriptions found. Defaulting to single-tenant Graph connection (interactive)." -ForegroundColor Yellow
$tenantTargets = @([PSCustomObject]@{ TenantId = $null })
}
} catch {
Write-Host "Auto-discovery via Azure failed ($($_.Exception.Message)). Defaulting to single-tenant Graph connection." -ForegroundColor Yellow
$tenantTargets = @([PSCustomObject]@{ TenantId = $null })
}
} else {
$tenantTargets = $TenantIds |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Unique |
ForEach-Object {
[PSCustomObject]@{ TenantId = $_ }
}
}
$entraRoles = @()
$userGroupMemberships = @()
$groupPermissions = @()
$tenantSummaries = @()
$totalSubscriptionCount = 0
$tenantNumber = 0
# Connect to Azure once for all tenants
Write-Host "`nConnecting to Azure for subscription access..." -ForegroundColor Cyan
$azureGloballyConnected = $false
if (-not (Get-AzContext -ErrorAction SilentlyContinue)) {
try {
Connect-AzAccount -UseDeviceAuthentication -ErrorAction Stop | Out-Null
Write-Host "Successfully connected to Azure." -ForegroundColor Green
$azureGloballyConnected = $true
} catch {
Write-Host "Failed to connect to Azure: $_" -ForegroundColor Yellow
Write-Host "Will attempt per-tenant Azure connections..." -ForegroundColor Yellow
}
} else {
Write-Host "Using existing Azure connection." -ForegroundColor Green
$azureGloballyConnected = $true
}
foreach ($tenant in $tenantTargets) {
$tenantNumber++
Write-Host "`n==============================" -ForegroundColor Cyan
Write-Host "Processing tenant $tenantNumber/$($tenantTargets.Count)" -ForegroundColor Cyan
Write-Host "==============================" -ForegroundColor Cyan
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
try {
if ($tenant.TenantId) {
Connect-MgGraph -TenantId $tenant.TenantId -Scopes "Directory.Read.All","RoleManagement.Read.All","Group.Read.All" -ErrorAction Stop | Out-Null
} else {
Connect-MgGraph -Scopes "Directory.Read.All","RoleManagement.Read.All","Group.Read.All" -ErrorAction Stop | Out-Null
}
Write-Host "Successfully connected to Microsoft Graph." -ForegroundColor Green
} catch {
Write-Host "Failed to connect to Microsoft Graph: $_" -ForegroundColor Red
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
continue
}
$mgContext = Get-MgContext
$currentTenantId = $mgContext.TenantId
# Try to get the friendly tenant name from organization details
$currentTenantName = $null
try {
$org = Get-MgOrganization -ErrorAction SilentlyContinue | Select-Object -First 1
if ($org -and $org.DisplayName) {
$currentTenantName = $org.DisplayName
}
} catch {
# Ignore errors fetching organization
}
if ([string]::IsNullOrWhiteSpace($currentTenantName)) {
$currentTenantName = if ($mgContext.TenantDisplayName) { $mgContext.TenantDisplayName } else { $currentTenantId }
}
Write-Host "Tenant context: $currentTenantName ($currentTenantId)" -ForegroundColor Gray
$tenantRolesBase = $entraRoles.Count
$tenantMembershipBase = $userGroupMemberships.Count
$tenantPermissionsBase = $groupPermissions.Count
# =======================
# 1. Collect Entra ID Role Assignments (Users Only)
# =======================
Write-Host "`nCollecting Entra ID role assignments..." -ForegroundColor Cyan
$allDirectoryRoles = Get-MgDirectoryRole -All
foreach ($role in $allDirectoryRoles) {
Write-Host " Processing role: $($role.DisplayName)" -ForegroundColor Gray
$members = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All
foreach ($member in $members) {
if ($member.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.user') {
try {
$userDetails = Get-MgUser -UserId $member.Id -Property UserPrincipalName,DisplayName -ErrorAction SilentlyContinue
if ($userDetails) {
$entraRoles += [PSCustomObject]@{
TenantId = $currentTenantId
TenantName = $currentTenantName
UserPrincipalName = $userDetails.UserPrincipalName
DisplayName = $userDetails.DisplayName
Role = $role.DisplayName
RoleId = $role.Id
}
continue
}
} catch {
# Ignore and use fallback
}
$entraRoles += [PSCustomObject]@{
TenantId = $currentTenantId
TenantName = $currentTenantName
UserPrincipalName = if ($member.UserPrincipalName) { $member.UserPrincipalName } else { "Unknown" }
DisplayName = if ($member.DisplayName) { $member.DisplayName } else { "Unknown" }
Role = $role.DisplayName
RoleId = $role.Id
}
}
}
}
$tenantRoleCount = $entraRoles.Count - $tenantRolesBase
Write-Host "Found $tenantRoleCount user role assignments in tenant $currentTenantName." -ForegroundColor Green
# =======================
# 2. Collect Group Memberships (All Users)
# =======================
Write-Host "`nCollecting group memberships..." -ForegroundColor Cyan
$allUsers = Get-MgUser -All -Property Id,UserPrincipalName,DisplayName
$userCount = 0
foreach ($user in $allUsers) {
$userCount++
if ($userCount % 50 -eq 0) {
Write-Host " Processed $userCount users..." -ForegroundColor Gray
}
$groups = Get-MgUserMemberOf -UserId $user.Id -All
foreach ($group in $groups) {
try {
$groupDetails = Get-MgGroup -GroupId $group.Id -Property DisplayName,Id,GroupTypes -ErrorAction SilentlyContinue
if ($groupDetails) {
$userGroupMemberships += [PSCustomObject]@{
TenantId = $currentTenantId
TenantName = $currentTenantName
UserPrincipalName = $user.UserPrincipalName
UserDisplayName = $user.DisplayName
GroupName = $groupDetails.DisplayName
GroupId = $groupDetails.Id
GroupType = if ($groupDetails.GroupTypes -contains "Unified") { "Microsoft 365" } else { "Security" }
}
continue
}
} catch {
# Ignore and use fallback
}
$userGroupMemberships += [PSCustomObject]@{
TenantId = $currentTenantId
TenantName = $currentTenantName
UserPrincipalName = $user.UserPrincipalName
UserDisplayName = $user.DisplayName
GroupName = if ($group.DisplayName) { $group.DisplayName } else { "Deleted/Inaccessible Group" }
GroupId = $group.Id
GroupType = "Unknown"
}
}
}
$tenantMembershipCount = $userGroupMemberships.Count - $tenantMembershipBase
Write-Host "Found $tenantMembershipCount group memberships in tenant $currentTenantName." -ForegroundColor Green
# =======================
# 3. Collect Group Permissions (Entra ID Roles)
# =======================
Write-Host "`nCollecting group permissions (tenant roles)..." -ForegroundColor Cyan
foreach ($role in $allDirectoryRoles) {
$roleMembers = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All
foreach ($member in $roleMembers) {
if ($member.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group') {
try {
$groupDetails = Get-MgGroup -GroupId $member.Id -Property DisplayName,Id -ErrorAction SilentlyContinue
if ($groupDetails) {
$groupPermissions += [PSCustomObject]@{
TenantId = $currentTenantId
TenantName = $currentTenantName
GroupName = $groupDetails.DisplayName
GroupId = $groupDetails.Id
PermissionType = "Entra ID Role"
PermissionName = $role.DisplayName
PermissionId = $role.Id
Scope = "Tenant"
SubscriptionId = "N/A"
SubscriptionName = "N/A"
}
continue
}
} catch {
# Ignore and use fallback
}
$groupPermissions += [PSCustomObject]@{
TenantId = $currentTenantId
TenantName = $currentTenantName
GroupName = if ($member.DisplayName) { $member.DisplayName } else { "Deleted/Inaccessible Group" }
GroupId = $member.Id
PermissionType = "Entra ID Role"
PermissionName = $role.DisplayName
PermissionId = $role.Id
Scope = "Tenant"
SubscriptionId = "N/A"
SubscriptionName = "N/A"
}
}
}
}
Write-Host " Captured Entra ID role assignments for groups in tenant $currentTenantName." -ForegroundColor Gray
# =======================
# 4. Process Azure Subscriptions for this Tenant
# =======================
$tenantSubscriptionCount = 0
if ($azureGloballyConnected) {
Write-Host "`nGetting Azure subscriptions for tenant $currentTenantName..." -ForegroundColor Cyan
try {
$subscriptions = Get-AzSubscription -TenantId $currentTenantId -ErrorAction Stop
} catch {
Write-Host "Unable to list subscriptions for tenant ${currentTenantName}: $_" -ForegroundColor Yellow
$subscriptions = @()
}
$tenantSubscriptionCount = $subscriptions.Count
$totalSubscriptionCount += $tenantSubscriptionCount
Write-Host "Found $tenantSubscriptionCount subscription(s) in tenant $currentTenantName." -ForegroundColor Green
if ($tenantSubscriptionCount -gt 0) {
Write-Host "`nCollecting RBAC role assignments for groups across all subscriptions..." -ForegroundColor Cyan
$subscriptionIndex = 0
foreach ($subscription in $subscriptions) {
$subscriptionIndex++
Write-Host " Processing subscription $($subscriptionIndex)/$($tenantSubscriptionCount): $($subscription.Name) ($($subscription.Id))" -ForegroundColor Gray
Set-AzContext -SubscriptionId $subscription.Id -TenantId $currentTenantId | Out-Null
$rbacAssignments = Get-AzRoleAssignment -ErrorAction SilentlyContinue
foreach ($assignment in $rbacAssignments) {
if ($assignment.ObjectType -eq "Group") {
$groupDisplayName = "Unknown"
try {
$groupDetails = Get-MgGroup -GroupId $assignment.ObjectId -Property DisplayName,Id -ErrorAction SilentlyContinue
if ($groupDetails -and $groupDetails.DisplayName) {
$groupDisplayName = $groupDetails.DisplayName
} elseif ($assignment.DisplayName) {
$groupDisplayName = $assignment.DisplayName
}
} catch {
if ($assignment.DisplayName) {
$groupDisplayName = $assignment.DisplayName
}
}
if ($assignment.ObjectId -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
$groupPermissions += [PSCustomObject]@{
TenantId = $currentTenantId
TenantName = $currentTenantName
GroupName = $groupDisplayName
GroupId = $assignment.ObjectId
PermissionType = "RBAC Role"
PermissionName = $assignment.RoleDefinitionName
PermissionId = $assignment.RoleDefinitionId
Scope = $assignment.Scope
SubscriptionId = $subscription.Id
SubscriptionName = $subscription.Name
}
}
}
}
}
}
}
$tenantSummary = [PSCustomObject]@{
TenantName = $currentTenantName
TenantId = $currentTenantId
UsersWithRoles = $entraRoles.Count - $tenantRolesBase
GroupMemberships = $userGroupMemberships.Count - $tenantMembershipBase
GroupPermissions = $groupPermissions.Count - $tenantPermissionsBase
SubscriptionsProcessed = $tenantSubscriptionCount
}
$tenantSummaries += $tenantSummary
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
Write-Host "`nPreparing data for export..." -ForegroundColor Cyan
$usersWithRoles = $entraRoles | Sort-Object TenantName, UserPrincipalName, Role
$groupMemberships = $userGroupMemberships | Sort-Object TenantName, UserPrincipalName, GroupName
$groupPerms = $groupPermissions | Sort-Object TenantName, GroupName, PermissionType, PermissionName, Scope
# Sheet 4: User memberships in RBAC-enabled groups
$rbacGroupPermissions = $groupPermissions | Where-Object { $_.PermissionType -eq "RBAC Role" }
$rbacGroupsById = @{}
foreach ($perm in $rbacGroupPermissions) {
if ($perm.GroupId -and -not $rbacGroupsById.ContainsKey($perm.GroupId)) {
$rbacGroupsById[$perm.GroupId] = @()
}
if ($perm.GroupId) {
$rbacGroupsById[$perm.GroupId] += $perm
}
}
$userRbacGroupMemberships = foreach ($membership in $userGroupMemberships) {
if ($membership.GroupId -and $rbacGroupsById.ContainsKey($membership.GroupId)) {
foreach ($perm in $rbacGroupsById[$membership.GroupId]) {
[PSCustomObject]@{
TenantId = $membership.TenantId
TenantName = $membership.TenantName
UserPrincipalName = $membership.UserPrincipalName
UserDisplayName = $membership.UserDisplayName
GroupName = $membership.GroupName
GroupId = $membership.GroupId
RBACRoleName = $perm.PermissionName
RBACRoleId = $perm.PermissionId
Scope = $perm.Scope
SubscriptionId = $perm.SubscriptionId
SubscriptionName = $perm.SubscriptionName
}
}
}
}
$userRbacGroupMemberships = @($userRbacGroupMemberships | Sort-Object TenantName, UserPrincipalName, GroupName, RBACRoleName, Scope)
$dateStamp = Get-Date -Format "yyyy-MM-dd"
$basePath = Get-Location
# Helper function to get unique filename
function Get-UniqueFileName {
param([string]$BaseName, [string]$Extension = "xlsx")
$fileName = Join-Path -Path $basePath -ChildPath "$BaseName.$Extension"
$counter = 1
while (Test-Path $fileName) {
$fileName = Join-Path -Path $basePath -ChildPath "${BaseName}_${counter}.$Extension"
$counter++
}
return $fileName
}
Write-Host "`nExporting to Excel (single workbook with multiple tabs)..." -ForegroundColor Cyan
# Single workbook with all tabs
$excelFile = Get-UniqueFileName -BaseName "Azure_Permissions_Report_$dateStamp"
# Tab 1: Azure RBAC Permissions
if ($groupPerms.Count -gt 0) {
$groupPerms |
Select-Object TenantName,TenantId,GroupName,GroupId,PermissionType,PermissionName,PermissionId,Scope,SubscriptionName,SubscriptionId |
Export-Excel -Path $excelFile -WorksheetName "Azure RBAC Permissions" -AutoSize -BoldTopRow -AutoFilter
Write-Host " ✓ Tab 1: Azure RBAC Permissions - $($groupPerms.Count) permissions" -ForegroundColor Gray
} else {
Write-Host " ⚠ Tab 1: Azure RBAC Permissions - No data" -ForegroundColor Yellow
@() | Export-Excel -Path $excelFile -WorksheetName "Azure RBAC Permissions" -AutoSize -BoldTopRow -AutoFilter
}
# Tab 2: Users with Entra Roles
if ($usersWithRoles.Count -gt 0) {
$usersWithRoles |
Select-Object UserPrincipalName,DisplayName,Role,RoleId |
Export-Excel -Path $excelFile -WorksheetName "Users_With_Entra_Roles" -AutoSize -BoldTopRow -AutoFilter
Write-Host " ✓ Tab 2: Users_With_Entra_Roles - $($usersWithRoles.Count) users" -ForegroundColor Gray
} else {
Write-Host " ⚠ Tab 2: Users_With_Entra_Roles - No data" -ForegroundColor Yellow
@() | Export-Excel -Path $excelFile -WorksheetName "Users_With_Entra_Roles" -AutoSize -BoldTopRow -AutoFilter
}
# Tab 3: User Group Memberships
if ($groupMemberships.Count -gt 0) {
$groupMemberships |
Select-Object UserPrincipalName,UserDisplayName,GroupName,GroupId,GroupType |
Export-Excel -Path $excelFile -WorksheetName "User_Group_Memberships" -AutoSize -BoldTopRow -AutoFilter
Write-Host " ✓ Tab 3: User_Group_Memberships - $($groupMemberships.Count) memberships" -ForegroundColor Gray
} else {
Write-Host " ⚠ Tab 3: User_Group_Memberships - No data" -ForegroundColor Yellow
@() | Export-Excel -Path $excelFile -WorksheetName "User_Group_Memberships" -AutoSize -BoldTopRow -AutoFilter
}
# Tab 4: Group Permissions
if ($groupPerms.Count -gt 0) {
$groupPerms |
Select-Object GroupName,GroupId,PermissionType,PermissionName,Scope |
Export-Excel -Path $excelFile -WorksheetName "Group_Permissions" -AutoSize -BoldTopRow -AutoFilter
Write-Host " ✓ Tab 4: Group_Permissions - $($groupPerms.Count) permissions" -ForegroundColor Gray
} else {
Write-Host " ⚠ Tab 4: Group_Permissions - No data" -ForegroundColor Yellow
@() | Export-Excel -Path $excelFile -WorksheetName "Group_Permissions" -AutoSize -BoldTopRow -AutoFilter
}
Write-Host "`n" + ("="*60) -ForegroundColor Cyan
Write-Host "✅ Export complete! Created 1 Excel workbook:" -ForegroundColor Green
Write-Host " File: $excelFile" -ForegroundColor White
Write-Host " Tabs: Azure RBAC Permissions, Users_With_Entra_Roles, User_Group_Memberships, Group_Permissions" -ForegroundColor White
Write-Host "`nSummary by tenant:" -ForegroundColor Cyan
foreach ($summary in $tenantSummaries) {
Write-Host " - $($summary.TenantName) ($($summary.TenantId))" -ForegroundColor White
Write-Host " Users with Entra ID roles : $($summary.UsersWithRoles)" -ForegroundColor White
Write-Host " Group memberships : $($summary.GroupMemberships)" -ForegroundColor White
Write-Host " Group permissions : $($summary.GroupPermissions)" -ForegroundColor White
Write-Host " Subscriptions processed : $($summary.SubscriptionsProcessed)" -ForegroundColor White
}
Write-Host "`nGrand totals:" -ForegroundColor Cyan
Write-Host " - Users with Entra ID roles: $($usersWithRoles.Count)" -ForegroundColor White
Write-Host " - Group memberships: $($groupMemberships.Count)" -ForegroundColor White
Write-Host " - Group permissions: $($groupPerms.Count)" -ForegroundColor White
Write-Host " - Subscriptions processed: $totalSubscriptionCount" -ForegroundColor White
Write-Host ("="*60) -ForegroundColor CyanOutput
The Excel workbook contains:
- Azure RBAC Permissions — Group permissions with RBAC and directory roles
- Users_With_Entra_Roles — Users with Entra ID role assignments
- User_Group_Memberships — All user group memberships
- Group_Permissions — Permissions assigned to groups
Each row includes TenantId and TenantName columns for multi-tenant visibility.
How it works
- If no tenant IDs are provided, it auto-discovers tenants from your Azure subscriptions
- For each tenant, it connects to Microsoft Graph and collects:
- Entra ID role assignments (users only)
- All user group memberships
- Group permissions (Entra ID roles assigned to groups)
- Processes all Azure subscriptions per tenant to collect RBAC role assignments for groups
- Exports everything to a single Excel workbook with multiple tabs
Last updated on