Back to Blog
Azure
6 min read

Auditing Enterprise App Access in Entra ID: The PowerShell Scripts That Actually Work

Entra IDPowerShellMicrosoft GraphSecurityAuditing

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:

  1. List all direct user assignments with their assigned roles
  2. List all group assignments with their assigned roles
  3. For each assigned group, expand to individual group members
  4. 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.

Need help with your Azure environment?

Get in touch for a free consultation.

Get in Touch