Skip to content

Export-AppPermissions

Exports Entra ID app permissions to an Excel report.

Description

Connects to Microsoft Graph and enumerates all service principals in the tenant, collecting both delegated (OAuth2) and application (app role) permissions. Results are exported to a formatted Excel workbook.

Parameters

-GraphCloud

The Microsoft Graph national cloud environment to connect to. Defaults to 'Global'. Accepted values: Global, USGov, USGovDoD, Germany, China.

Property Value
Type String
Required false
Default Global

-Type

Optional. Filters the permission type to export. Use 'app' for application permissions, 'delegate' for delegated permissions, or omit to export both.

Property Value
Type String
Required false

-Claim

Optional. Filters results by permission claim value. Supports wildcards. Examples: 'Mail.Read', 'Mail.', 'Mail'

Property Value
Type String
Required false

-Tenant

Optional. The tenant ID or domain to connect to. If omitted, uses the default tenant for the authenticated account.

Property Value
Type String
Required false

-Out

Output path for the Excel file. If omitted, saves to the current user's Downloads folder with an auto-generated filename. Example: 'C:\Reports\permissions.xlsx'

Property Value
Type String
Required false

Examples

EXAMPLE 1

.\Export-AppPermissions.ps1
Exports all app and delegated permissions for the default tenant.

EXAMPLE 2

.\Export-AppPermissions.ps1 -Type app -Claim 'Mail.*'
Exports only application permissions whose claim matches 'Mail.*'.

EXAMPLE 3

.\Export-AppPermissions.ps1 -Tenant contoso.onmicrosoft.com -Out C:\Reports\perms.xlsx
Exports all permissions for a specific tenant to a custom output path.

Requires -Modules Microsoft.Graph.Authentication, ImportExcel

Script

Download Export-AppPermissions.ps1

<#
.SYNOPSIS
    Exports Entra ID app permissions to an Excel report.

.DESCRIPTION
    Connects to Microsoft Graph and enumerates all service principals in the tenant,
    collecting both delegated (OAuth2) and application (app role) permissions.
    Results are exported to a formatted Excel workbook.

.PARAMETER GraphCloud
    The Microsoft Graph national cloud environment to connect to.
    Defaults to 'Global'. Accepted values: Global, USGov, USGovDoD, Germany, China.

.PARAMETER Type
    Optional. Filters the permission type to export. Use 'app' for application permissions,
    'delegate' for delegated permissions, or omit to export both.

.PARAMETER Claim
    Optional. Filters results by permission claim value. Supports wildcards.
    Examples: 'Mail.Read', 'Mail.*', 'Mail*'

.PARAMETER Tenant
    Optional. The tenant ID or domain to connect to. If omitted, uses the default tenant
    for the authenticated account.

.PARAMETER Out
    Output path for the Excel file. If omitted, saves to the current user's
    Downloads folder with an auto-generated filename.
    Example: 'C:\Reports\permissions.xlsx'

.EXAMPLE
    .\Export-AppPermissions.ps1
    Exports all app and delegated permissions for the default tenant.

.EXAMPLE
    .\Export-AppPermissions.ps1 -Type app -Claim 'Mail.*'
    Exports only application permissions whose claim matches 'Mail.*'.

.EXAMPLE
    .\Export-AppPermissions.ps1 -Tenant contoso.onmicrosoft.com -Out C:\Reports\perms.xlsx
    Exports all permissions for a specific tenant to a custom output path.
#>
#Requires -Modules Microsoft.Graph.Authentication, ImportExcel

param(
    [ValidateSet('Global', 'USGov', 'USGovDoD', 'Germany', 'China')]
    [string]$GraphCloud = 'Global',

    [ValidateSet('app', 'delegate')]
    [string]$Type, # Empty = both

    [string]$Claim, # Mail.Read, Mail.*, or Mail*
    [string]$Tenant,
    [string]$Out
)

function Log { param([string]$Msg,[ConsoleColor]$Color='White') Write-Host "[$(Get-Date -Format 'HH:mm')] $Msg" -ForegroundColor $Color }

$scopes = @(
    'Organization.Read.All'
    'Directory.Read.All'
    'Application.Read.All'
    'User.Read.All'
    'DelegatedPermissionGrant.Read.All'
)
Log "Connecting to Graph..."
if ($Tenant) { Connect-MgGraph -Scopes $scopes -Environment $GraphCloud -NoWelcome -ErrorAction Stop -TenantId $Tenant }
else { Connect-MgGraph -Scopes $scopes -Environment $GraphCloud -NoWelcome -ErrorAction Stop }
$Org = Get-MgOrganization
Log "Connected to Graph: $($Org.DisplayName)" Green

if (!$Out) {
    $friendlyName = "AppPermissions"
    $Out = Join-Path $env:USERPROFILE "Downloads\$($Org.DisplayName)-$friendlyName-$(Get-Date -Format 'yyyy-MM-dd_HH-mm').xlsx"
}
elseif ($Out -notlike '*.xlsx') { $Out += '.xlsx' }


$doDelegate = (-not $Type) -or ($Type -eq 'delegate')
$doApp = (-not $Type) -or ($Type -eq 'app')

Log "Fetching service principals..."

$servicePrincipals = Get-MgServicePrincipal -All -ErrorAction Stop

if (-not $servicePrincipals) {
    Log "No service principals found." Yellow
    return
}

Log "Processing $($servicePrincipals.Count) service principal(s)..."

# Simple caches
$resourceSpCache = @{}
$userCache = @{}
$rows = New-Object System.Collections.Generic.List[object]

function Get-ResourceServicePrincipal {
    param(
        [Parameter(Mandatory)]
        [string]$Id
    )

    if ($resourceSpCache.ContainsKey($Id)) {
        return $resourceSpCache[$Id]
    }

    $sp = Get-MgServicePrincipal -ServicePrincipalId $Id -ErrorAction SilentlyContinue
    if ($sp) {
        $resourceSpCache[$Id] = $sp
    }
    return $sp
}

function Get-UserNameFromId {
    param(
        [Parameter(Mandatory)]
        [string]$Id
    )

    if ($userCache.ContainsKey($Id)) {
        return $userCache[$Id]
    }

    $u = Get-MgUser -UserId $Id -ErrorAction SilentlyContinue
    $name = $null

    if ($u) {
        $name = $u.UserPrincipalName
        if (-not $name) { $name = $u.DisplayName }
    }

    if (-not $name) { $name = $Id }

    $userCache[$Id] = $name
    return $name
}

foreach ($sp in $servicePrincipals) {

    # Delegated permissions
    if ($doDelegate) {

        $grants = Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $sp.Id -All -ErrorAction SilentlyContinue

        if ($grants) {
            $permIndex = @{}  # key: resourceId|scope

            foreach ($grant in $grants) {
                if (-not $grant.Scope) { continue }

                $scopesInGrant = $grant.Scope -split ' ' | Where-Object { $_ -and $_.Trim() }

                $resourceSp = Get-ResourceServicePrincipal -Id $grant.ResourceId
                if (-not $resourceSp) { continue }

                foreach ($scopeValue in $scopesInGrant) {

                    if ($Claim -and ($scopeValue -notlike $Claim)) {
                        continue
                    }

                    $permissionDef = $null
                    if ($resourceSp.Oauth2PermissionScopes) {
                        $permissionDef = $resourceSp.Oauth2PermissionScopes |
                        Where-Object { $_.Value -eq $scopeValue }
                    }

                    if ($permissionDef) {
                        $permissionDisplayName = $permissionDef.UserConsentDisplayName
                        if (-not $permissionDisplayName) { $permissionDisplayName = $permissionDef.AdminConsentDisplayName }
                    }

                    $resourceKey = "$($grant.ResourceId)|$scopeValue"

                    if (-not $permIndex.ContainsKey($resourceKey)) {
                        $row = [PSCustomObject]@{
                            DisplayName    = $sp.DisplayName
                            Type           = 'Delegated'
                            Claim          = $scopeValue
                            Description    = $permissionDisplayName
                            ResourceName   = $resourceSp.DisplayName
                            AdminConsented = $false
                            UserConsented  = $false
                            ConsentedUsers = ''
                            AppId          = $sp.AppId
                            AppObjectId    = $sp.Id
                            ResourceAppId  = $resourceSp.AppId
                        }
                        $permIndex[$resourceKey] = $row
                    }

                    $current = $permIndex[$resourceKey]

                    if ($grant.ConsentType -eq 'AllPrincipals') {
                        $current.AdminConsented = $true
                    }
                    elseif ($grant.ConsentType -eq 'Principal') {
                        $current.UserConsented = $true
                        if ($grant.PrincipalId) {
                            $userName = Get-UserNameFromId -Id $grant.PrincipalId
                            if ($userName) {
                                if ([string]::IsNullOrWhiteSpace($current.ConsentedUsers)) {
                                    $current.ConsentedUsers = $userName
                                }
                                else {
                                    $existing = $current.ConsentedUsers -split ';' | ForEach-Object { $_.Trim() }
                                    if ($existing -notcontains $userName) {
                                        $current.ConsentedUsers += ";$userName"
                                    }
                                }
                            }
                        }
                    }
                }
            }

            foreach ($entry in $permIndex.GetEnumerator()) {
                $row = $entry.Value
                $rows.Add($row)
            }
        }
    }

    # Application permissions
    if ($doApp) {

        $appAssignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -All -ErrorAction SilentlyContinue

        if ($appAssignments) {
            foreach ($assignment in $appAssignments) {
                if ($null -ne $assignment.DeletedDateTime) { continue }

                $resourceSp = Get-ResourceServicePrincipal -Id $assignment.ResourceId
                if (-not $resourceSp) { continue }

                $appRole = $null
                if ($resourceSp.AppRoles) {
                    $appRole = $resourceSp.AppRoles |
                    Where-Object { $_.Id -eq $assignment.AppRoleId }
                }

                $claimValue = 'Unknown'
                $permissionDisplay = '[UnknownAppRole]'
                if ($appRole) {
                    $claimValue = $appRole.Value
                    $permissionDisplay = $appRole.DisplayName
                }

                if ($Claim -and $claimValue -and ($claimValue -notlike $Claim)) {
                    continue
                }

                $row = [PSCustomObject]@{
                    DisplayName    = $sp.DisplayName
                    Type           = 'Application'
                    Claim          = $claimValue
                    Description    = $permissionDisplay
                    ResourceName   = $resourceSp.DisplayName
                    AdminConsented = $true
                    UserConsented  = $false
                    ConsentedUsers = ''
                    AppId          = $sp.AppId
                    AppObjectId    = $sp.Id
                    ResourceAppId  = $resourceSp.AppId
                }

                $rows.Add($row)
            }
        }
    }
}

if (-not $rows -or $rows.Count -eq 0) {
    if ($Claim) {
        Log "No permissions found matching claim '$Claim'." Yellow
    }
    else {
        Low "No permissions found." Yellow
    }
    return
}

$sheetName = switch ($Type) {
    'app' { 'AppPermissions' }
    'delegate' { 'DelegatedPermissions' }
    default { 'AllPermissions' }
}
$tableName = $sheetName

$rows |
Export-Excel -Path $Out `
    -AutoSize `
    -FreezeTopRow `
    -WorksheetName $sheetName `
    -TableName $tableName `
    -TableStyle Medium2

Log "Exported report: $Out" Green

$answer = Read-Host "Open the report now? [Y/n]"
if ($answer -eq '' -or $answer -match '^y') {
    Start-Process $Out
}