Every compliance audit eventually asks the same question: who has access to what? In Entra ID, the answer lives in enterprise application assignments, the mapping between users/groups and the applications they can access. Extracting this data should be straightforward. The Microsoft Graph PowerShell SDK makes it anything but.
We recently built an auditing workflow to extract all user and group assignments across enterprise apps, expand group memberships to individual users, and produce CSV reports for a compliance review. The journey through the Graph API's inconsistencies is worth documenting so nobody else has to rediscover the same pitfalls.
The Goal
For each enterprise app (service principal) in the tenant:
- List all direct user assignments with their assigned roles
- List all group assignments with their assigned roles
- For each assigned group, expand to individual group members
- Export everything to CSV for the compliance team
Simple enough on paper. The Graph API had other ideas.
Script 1: Get Enterprise App Assignments
The first script extracts direct assignments from each enterprise app:
# Get-EnterpriseAppAssignments.ps1
# Requires: Microsoft.Graph.Applications, Microsoft.Graph.DirectoryObjects
Connect-MgGraph -Scopes "Application.Read.All", "Directory.Read.All"
$servicePrincipals = Get-MgServicePrincipal -All -Filter "tags/any(t: t eq 'WindowsAzureActiveDirectoryIntegratedApp')"
$results = @()
foreach ($sp in $servicePrincipals) {
Write-Host "Processing: $($sp.DisplayName)" -ForegroundColor Cyan
$assignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -All
foreach ($assignment in $assignments) {
$results += [PSCustomObject]@{
ApplicationName = $sp.DisplayName
ApplicationId = $sp.AppId
PrincipalType = $assignment.PrincipalType
PrincipalName = $assignment.PrincipalDisplayName
PrincipalId = $assignment.PrincipalId
RoleId = $assignment.AppRoleId
RoleName = ($sp.AppRoles | Where-Object { $_.Id -eq $assignment.AppRoleId }).DisplayName
AssignedDate = $assignment.CreatedDateTime
}
}
}
$results | Export-Csv -Path "enterprise-app-assignments.csv" -NoTypeInformation
Write-Host "Exported $($results.Count) assignments to enterprise-app-assignments.csv" -ForegroundColor Green
This script works reliably. The Get-MgServicePrincipalAppRoleAssignment cmdlet returns both user and group assignments with their role mappings. The PrincipalType field distinguishes between "User" and "Group" assignments.
The filter tags/any(t: t eq 'WindowsAzureActiveDirectoryIntegratedApp') limits the query to enterprise apps that are actually integrated with your tenant, skipping the hundreds of Microsoft first-party service principals that exist in every tenant but are not relevant to an access audit.
Script 2: Expand Group Memberships
This is where it gets ugly. The second script takes the group assignments from Script 1 and expands them to individual users:
# Get-GroupMembers.ps1
# Requires: Microsoft.Graph.Groups
Connect-MgGraph -Scopes "Group.Read.All", "GroupMember.Read.All"
$assignments = Import-Csv -Path "enterprise-app-assignments.csv"
$groupAssignments = $assignments | Where-Object { $_.PrincipalType -eq "Group" }
$uniqueGroups = $groupAssignments | Select-Object -Property PrincipalId, PrincipalName -Unique
$memberResults = @()
foreach ($group in $uniqueGroups) {
Write-Host "Expanding group: $($group.PrincipalName)" -ForegroundColor Cyan
try {
# Method 1: Get-MgGroupTransitiveMember (includes nested groups)
$members = Get-MgGroupTransitiveMember -GroupId $group.PrincipalId -All
foreach ($member in $members) {
# The Graph API returns members as generic directory objects
# User details are in AdditionalProperties, NOT in top-level properties
$props = $member.AdditionalProperties
if ($props.'@odata.type' -eq '#microsoft.graph.user') {
$memberResults += [PSCustomObject]@{
GroupName = $group.PrincipalName
GroupId = $group.PrincipalId
UserDisplayName = $props.displayName
UserPrincipalName = $props.userPrincipalName
UserMail = $props.mail
UserAccountEnabled = $props.accountEnabled
}
}
}
}
catch {
Write-Warning "Failed to expand group $($group.PrincipalName): $($_.Exception.Message)"
# Fallback: Try Get-MgGroupMember (direct members only)
try {
$directMembers = Get-MgGroupMember -GroupId $group.PrincipalId -All
foreach ($member in $directMembers) {
$props = $member.AdditionalProperties
if ($props.'@odata.type' -eq '#microsoft.graph.user') {
$memberResults += [PSCustomObject]@{
GroupName = $group.PrincipalName
GroupId = $group.PrincipalId
UserDisplayName = $props.displayName
UserPrincipalName = $props.userPrincipalName
UserMail = $props.mail
UserAccountEnabled = $props.accountEnabled
}
}
}
}
catch {
Write-Warning "Both methods failed for $($group.PrincipalName). Manual review required."
}
}
}
$memberResults | Export-Csv -Path "group-members-expanded.csv" -NoTypeInformation
Write-Host "Exported $($memberResults.Count) group members to group-members-expanded.csv" -ForegroundColor Green
The Graph API Pitfalls
Pitfall 1: AdditionalProperties Is Where the Data Lives
When you call Get-MgGroupMember or Get-MgGroupTransitiveMember, the returned objects are typed as MicrosoftGraphDirectoryObject. The user-specific properties (displayName, userPrincipalName, mail) are not on the top-level object. They are buried in the AdditionalProperties dictionary.
This catches everyone the first time. Your instinct is to write $member.DisplayName — it returns null. The actual path is $member.AdditionalProperties.displayName (note the lowercase 'd' — case sensitive in the dictionary).
Pitfall 2: On-Premises Synced Groups Fail Silently
This was the most frustrating issue. Groups synced from on-premises Active Directory via Entra Connect sometimes fail when you try to enumerate their members via the Graph API. Get-MgGroupTransitiveMember returns an error, Get-MgGroupMember returns an empty result, and there is no clear indication of why.
The cause appears to be related to how Entra Connect syncs group membership data. For large groups or groups with complex nesting in on-premises AD, the Graph API sometimes cannot resolve the transitive membership chain.
The workaround is the two-method approach in the script above: try Get-MgGroupTransitiveMember first (which handles nested groups), fall back to Get-MgGroupMember (direct members only), and log failures for manual investigation.
For groups that fail both methods, the membership data needs to be pulled from on-premises Active Directory directly using the ActiveDirectory PowerShell module:
# Fallback for synced groups: query on-prem AD
Get-ADGroupMember -Identity "GroupName" -Recursive |
Select-Object Name, SamAccountName, ObjectClass
Pitfall 3: Pagination
Both Get-MgGroupMember and Get-MgGroupTransitiveMember paginate by default, returning 100 members per request. The -All parameter handles pagination automatically, but if you forget it, you silently get an incomplete result for any group with more than 100 members. The script will not error — it will just produce a CSV that is missing people.
Always use -All when enumerating group memberships.
Pitfall 4: Service Principal Members
Groups can contain service principals (application identities), not just users. The @odata.type filter in the script catches this by only processing members of type #microsoft.graph.user. Without this filter, service principal members would either cause errors (when trying to access userPrincipalName) or appear as blank rows in the CSV.
Producing the Final Report
With both CSVs generated, a simple merge script combines the data:
$assignments = Import-Csv "enterprise-app-assignments.csv"
$groupMembers = Import-Csv "group-members-expanded.csv"
# Direct user assignments
$directUsers = $assignments |
Where-Object { $_.PrincipalType -eq "User" } |
Select-Object ApplicationName, PrincipalName, RoleName, @{N='AccessType';E={'Direct'}}
# Group-based assignments (expanded)
$groupUsers = foreach ($gm in $groupMembers) {
$apps = $assignments |
Where-Object { $_.PrincipalId -eq $gm.GroupId }
foreach ($app in $apps) {
[PSCustomObject]@{
ApplicationName = $app.ApplicationName
PrincipalName = $gm.UserDisplayName
RoleName = $app.RoleName
AccessType = "Via Group: $($gm.GroupName)"
}
}
}
$finalReport = @($directUsers) + @($groupUsers) |
Sort-Object ApplicationName, PrincipalName
$finalReport | Export-Csv "access-audit-report.csv" -NoTypeInformation
The final CSV shows every user who has access to every enterprise app, whether that access is direct or via group membership, and which role they hold. The compliance team gets a flat table they can filter in Excel.
Running It Regularly
Schedule these scripts to run monthly. Enterprise app assignments change as people join, leave, and move roles. A quarterly compliance report that uses stale data from three months ago is not much use.
Azure Automation can run the scripts on a schedule using a managed identity with the appropriate Graph permissions — no stored credentials needed.
Need a comprehensive access audit across your Entra ID environment? Our free assessment can include an identity review that surfaces over-permissive access and stale assignments alongside your cost analysis.