Skip to Content
Microsoft 365Entra IDPermission Audit

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 CurrentUser

Script

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 Cyan

Output

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

  1. If no tenant IDs are provided, it auto-discovers tenants from your Azure subscriptions
  2. 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)
  3. Processes all Azure subscriptions per tenant to collect RBAC role assignments for groups
  4. Exports everything to a single Excel workbook with multiple tabs
Last updated on