Public/Get-TntServicePrincipalPermissionReport.ps1

function Get-TntServicePrincipalPermissionReport {
    <#
    .SYNOPSIS
        Generates a report of delegated permission grants for Entra ID service principals and user accounts.
 
    .DESCRIPTION
        This function connects to Microsoft Graph using an app registration and generates detailed reports about
        delegated permissions granted to service principals and users. It provides security insights and
        compliance information to help identify potential risks and over-privileged applications.
 
    .PARAMETER TenantId
        The Azure AD Tenant ID (GUID) to connect to.
 
    .PARAMETER ClientId
        The Application (Client) ID of the app registration created for security reporting.
 
    .PARAMETER ClientSecret
        The client secret for the app registration. Use this for automated scenarios.
 
    .PARAMETER CertificateThumbprint
        The thumbprint of the certificate to use for authentication instead of client secret.
 
    .PARAMETER ExcludeUserConsents
        Switch to exclude individual user consent grants from the report. By default, user consents are included.
 
    .PARAMETER FilterByRiskLevel
        Filter results by risk level. Valid values are Low, Medium, High, Critical.
 
    .PARAMETER ExcludeInactiveApps
        Switch to exclude applications that haven't been used recently. By default, inactive apps are included.
 
    .PARAMETER MaxResults
        Maximum number of service principals to process. Useful for large tenants.
 
    .EXAMPLE
        Get-TntServicePrincipalPermissionReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret
 
        Generates a delegated permissions report using client secret authentication.
 
    .EXAMPLE
        Get-TntServicePrincipalPermissionReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret -FilterByRiskLevel Critical
 
        Generates a report containing only critical risk permissions, including all user consents by default.
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject
        Returns a report object containing:
        - Summary: Statistics on permission grants and risks
        - CriticalRiskPermissions: Details of critical permissions granted
        - HighRiskPermissions: Details of high-risk permissions granted
        - MediumRiskPermissions: Details of medium-risk permissions granted
        - LowRiskPermissions: Details of low-risk permissions granted
        - AllPermissions: Complete list of processed permission grants
 
    .NOTES
        Author: Tom de Leeuw
        Website: https://systom.dev
        Module: TenantReports
 
        Required Permissions:
        - Directory.Read.All (Application)
        - User.Read.All (Application)
        - AuditLog.Read.All (Application)
 
    .LINK
        https://systom.dev
    #>


    [CmdletBinding(DefaultParameterSetName = 'ClientSecret')]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [Parameter(ParameterSetName = 'Interactive')]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [Parameter(ParameterSetName = 'Interactive')]
        [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')]
        [Alias('ApplicationId')]
        [string]$ClientId,

        [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret', ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('Secret', 'ApplicationSecret')]
        [SecureString]$ClientSecret,

        [Parameter(Mandatory = $true, ParameterSetName = 'Certificate', ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('Thumbprint')]
        [string]$CertificateThumbprint,

        [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')]
        [switch]$Interactive,

        [Parameter()]
        [switch]$ExcludeUserConsents,

        [Parameter()]
        [ValidateSet('Low', 'Medium', 'High', 'Critical')]
        [string]$FilterByRiskLevel,

        [Parameter()]
        [switch]$ExcludeInactiveApps,

        [Parameter()]
        [ValidateRange(1, 10000)]
        [int]$MaxResults = 2000
    )

    begin {
        # Define risk levels for different permission scopes
        $PermissionRiskLevels = @{
            # Critical Risk Permissions
            'Directory.ReadWrite.All'                = 'Critical'
            'User.ReadWrite.All'                     = 'Critical'
            'Group.ReadWrite.All'                    = 'Critical'
            'RoleManagement.ReadWrite.Directory'     = 'Critical'
            'Application.ReadWrite.All'              = 'Critical'
            'AppRoleAssignment.ReadWrite.All'        = 'Critical'
            'DelegatedPermissionGrant.ReadWrite.All' = 'Critical'

            # High Risk Permissions
            'Directory.Read.All'                     = 'High'
            'User.Read.All'                          = 'High'
            'Group.Read.All'                         = 'High'
            'Mail.ReadWrite'                         = 'High'
            'Files.ReadWrite.All'                    = 'High'
            'Sites.ReadWrite.All'                    = 'High'
            'Calendars.ReadWrite'                    = 'High'

            # Medium Risk Permissions
            'User.Read'                              = 'Medium'
            'Mail.Read'                              = 'Medium'
            'Files.Read.All'                         = 'Medium'
            'Sites.Read.All'                         = 'Medium'
            'Calendars.Read'                         = 'Medium'
            'Contacts.Read'                          = 'Medium'

            # Low Risk Permissions (Default)
            'openid'                                 = 'Low'
            'profile'                                = 'Low'
            'email'                                  = 'Low'
            'offline_access'                         = 'Low'
        }

        # Common Microsoft Graph Resource IDs
        $WellKnownResourceIds = @{
            '00000003-0000-0000-c000-000000000000' = 'Microsoft Graph'
            '00000002-0000-0000-c000-000000000000' = 'Microsoft Graph (Legacy)'
            '797f4846-ba00-4fd7-ba43-dac1f8f63013' = 'Windows Azure Service Management API'
            '00000001-0000-0000-c000-000000000000' = 'Microsoft Graph (Classic)'
        }

        Write-Information 'STARTED : Service Principal permissions report generation...' -InformationAction Continue
    }

    process {
        try {
            $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters
            $ConnectionInfo = Connect-TntGraphSession @ConnectionParams

            # Initialize collections for report data
            $DelegatedPermissionGrants = [System.Collections.Generic.List[PSObject]]::new()
            $ResourceApplications = @{}

            # Get all service principals
            Write-Verbose 'Retrieving service principals...'
            $AllServicePrincipals = Get-MgServicePrincipal -All -Property Id, AppId, DisplayName, CreatedDateTime, SignInAudience, PublisherName, Tags, AppRoleAssignments -ErrorAction Stop

            if ($MaxResults -and $AllServicePrincipals.Count -gt $MaxResults) {
                $AllServicePrincipals = $AllServicePrincipals | Select-Object -First $MaxResults
                Write-Warning "Limited results to $($MaxResults) service principals due to MaxResults parameter"
            }

            Write-Verbose "Found $($AllServicePrincipals.Count) service principals"

            # Build hashtable lookup for O(1) service principal lookups
            $ServicePrincipalLookupById = @{}
            foreach ($SP in $AllServicePrincipals) {
                if ($SP.Id) { $ServicePrincipalLookupById[$SP.Id] = $SP }
            }

            # Get all OAuth2 permission grants
            Write-Verbose 'Retrieving OAuth2 permission grants...'
            $AllOAuth2Grants = Get-MgOauth2PermissionGrant -All -Property Id, ClientId, ResourceId, Scope, ConsentType, PrincipalId -ErrorAction Stop

            Write-Verbose "Found $($AllOAuth2Grants.Count) OAuth2 permission grants"

            # Collect unique PrincipalIds for user consent grants (incremental caching)
            $UserConsentPrincipalIds = @($AllOAuth2Grants.Where({
                        $_.ConsentType -eq 'Principal' -and $_.PrincipalId
                    }) | Select-Object -ExpandProperty PrincipalId -Unique)

            # Pre-fetch only the specific users needed (incremental mode - NOT all users)
            $UserCache = $null
            if ($UserConsentPrincipalIds.Count -gt 0 -and -not $ExcludeUserConsents) {
                Write-Verbose "Pre-fetching $($UserConsentPrincipalIds.Count) users for consent details (incremental mode)..."
                $CacheParams = @{
                    TenantId = $TenantId
                    ClientId = $ClientId
                    UserIds  = $UserConsentPrincipalIds
                }
                $UserCache = Get-CachedUsers @CacheParams
                Write-Verbose "User cache ready: $($UserCache.UserCount) users (CacheHit: $($UserCache.CacheHit))"
            }

            $Now = [DateTime]::Now

            # Process each OAuth2 grant
            foreach ($Grant in $AllOAuth2Grants) {
                try {
                    # Get client service principal using O(1) hashtable lookup (with null-safety)
                    $ClientServicePrincipal = $null
                    if ($Grant.ClientId) {
                        $ClientServicePrincipal = $ServicePrincipalLookupById[$Grant.ClientId]
                    }
                    if (-not $ClientServicePrincipal) {
                        Write-Verbose "Skipping grant for unknown client: $($Grant.ClientId)"
                        continue
                    }

                    # Get resource service principal (API being accessed)
                    if (-not $ResourceApplications.ContainsKey($Grant.ResourceId)) {
                        $ResourceServicePrincipal = Get-MgServicePrincipal -ServicePrincipalId $Grant.ResourceId -Property Id, AppId, DisplayName -ErrorAction SilentlyContinue
                        $ResourceApplications[$Grant.ResourceId] = $ResourceServicePrincipal
                    } else {
                        $ResourceServicePrincipal = $ResourceApplications[$Grant.ResourceId]
                    }

                    if (-not $ResourceServicePrincipal) {
                        Write-Verbose "Skipping grant for unknown resource: $($Grant.ResourceId)"
                        continue
                    }

                    # Parse individual scopes
                    $Scopes = if ($Grant.Scope.Count -gt 0) { @($Grant.Scope.Split(' ').Where({ $_ })) } else { @($Grant.Scope) }

                    foreach ($Scope in $Scopes) {
                        # Determine risk level
                        $RiskLevel = if ($PermissionRiskLevels.ContainsKey($Scope)) {
                            $PermissionRiskLevels[$Scope]
                        } else {
                            'UNKNOWN' # Default for unknown permissions
                        }

                        # Skip if filtering by risk level
                        if ($FilterByRiskLevel -and $RiskLevel -ne $FilterByRiskLevel) {
                            continue
                        }

                        # Get user information if this is a user consent
                        $PrincipalDisplayName = $null
                        $PrincipalUserPrincipalName = $null
                        if ($Grant.ConsentType -eq 'Principal' -and $Grant.PrincipalId) {
                            # O(1) lookup from pre-fetched cache instead of per-grant API call
                            $User = $null
                            if ($UserCache) {
                                $User = $UserCache.LookupById[$Grant.PrincipalId]
                            }

                            # Fallback to individual API call if not in cache (handles transient failures)
                            if (-not $User) {
                                try {
                                    $User = Get-MgUser -UserId $Grant.PrincipalId -Property DisplayName, UserPrincipalName -ErrorAction SilentlyContinue
                                } catch {
                                    Write-Verbose "Could not retrieve user info for principal: $($Grant.PrincipalId)"
                                }
                            }

                            if ($User) {
                                $PrincipalDisplayName = $User.DisplayName
                                $PrincipalUserPrincipalName = $User.UserPrincipalName
                            }
                        }

                        # Skip user consents if excluded
                        if ($ExcludeUserConsents -and $Grant.ConsentType -eq 'Principal') {
                            continue
                        }

                        $ReportEntry = [PSCustomObject]@{
                            GrantId                    = $Grant.Id
                            ClientApplicationId        = $ClientServicePrincipal.AppId
                            ClientApplicationName      = $ClientServicePrincipal.DisplayName
                            ClientPublisher            = $ClientServicePrincipal.PublisherName
                            ClientCreatedDate          = $ClientServicePrincipal.CreatedDateTime
                            ClientTags                 = ($ClientServicePrincipal.Tags -join '; ')
                            ResourceApplicationId      = $ResourceServicePrincipal.AppId
                            ResourceApplicationName    = $ResourceServicePrincipal.DisplayName
                            ResourceFriendlyName       = if ($WellKnownResourceIds.ContainsKey($ResourceServicePrincipal.AppId)) {
                                $WellKnownResourceIds[$ResourceServicePrincipal.AppId]
                            } else {
                                $ResourceServicePrincipal.DisplayName
                            }
                            Permission                 = $Scope
                            ConsentType                = switch ($Grant.ConsentType) {
                                'AllPrincipals' { 'Admin Consent (All Users)' }
                                'Principal' { 'User Consent' }
                                default { $Grant.ConsentType }
                            }
                            PrincipalId                = $Grant.PrincipalId
                            PrincipalDisplayName       = $PrincipalDisplayName
                            PrincipalUserPrincipalName = $PrincipalUserPrincipalName
                            RiskLevel                  = $RiskLevel
                            GrantStartTime             = $Grant.StartTime
                            GrantExpiryTime            = $Grant.ExpiryTime
                            IsExpired                  = if ($Grant.ExpiryTime) { $Grant.ExpiryTime -lt $Now } else { $false }
                            DaysUntilExpiry            = if ($Grant.ExpiryTime) {
                                [math]::Round(($Grant.ExpiryTime - $Now).TotalDays, 0)
                            } else {
                                $null
                            }
                        }

                        $DelegatedPermissionGrants.Add($ReportEntry)
                    }
                } catch {
                    Write-Warning "Error processing grant $($Grant.Id): $($_.Exception.Message)"
                    continue
                }
            }

            # Filter inactive apps if excluded
            if ($ExcludeInactiveApps) {
                Write-Verbose 'Filtering out inactive applications...'
                $InactiveThreshold = [DateTime]::Now.AddDays(-90)
                $DelegatedPermissionGrants = @($DelegatedPermissionGrants.Where({
                            -not $_.ClientCreatedDate -or $_.ClientCreatedDate -gt $InactiveThreshold
                        }))
            }

            # Sort results by risk level and application name
            $SortedResults = $DelegatedPermissionGrants | Sort-Object @{
                Expression = {
                    switch ($_.RiskLevel) {
                        'Critical' { 1 }
                        'High' { 2 }
                        'Medium' { 3 }
                        'Low' { 4 }
                        default { 5 }
                    }
                }
            }, ClientApplicationName, Permission

            Write-Verbose "Generated report with $($SortedResults.Count) permission grants"

            # Generate summary statistics using single-pass accumulation
            $GrantStats = @{
                AdminConsentGrants      = 0
                UserConsentGrants       = 0
                CriticalRiskPermissions = 0
                HighRiskPermissions     = 0
                MediumRiskPermissions   = 0
                LowRiskPermissions      = 0
                ExpiredGrants           = 0
                ExpiringIn30Days        = 0
            }
            $UniqueApplications = @{}
            if ($SortedResults) {
                foreach ($Grant in $SortedResults) {
                    # Consent type counts
                    if ($Grant.ConsentType -like '*Admin*') { $GrantStats.AdminConsentGrants++ }
                    elseif ($Grant.ConsentType -eq 'User Consent') { $GrantStats.UserConsentGrants++ }
                    # Risk level counts
                    switch ($Grant.RiskLevel) {
                        'Critical' { $GrantStats.CriticalRiskPermissions++ }
                        'High' { $GrantStats.HighRiskPermissions++ }
                        'Medium' { $GrantStats.MediumRiskPermissions++ }
                        'Low' { $GrantStats.LowRiskPermissions++ }
                    }
                    # Expiry counts
                    if ($Grant.IsExpired) { $GrantStats.ExpiredGrants++ }
                    if ($null -ne $Grant.DaysUntilExpiry -and $Grant.DaysUntilExpiry -le 30 -and $Grant.DaysUntilExpiry -gt 0) {
                        $GrantStats.ExpiringIn30Days++
                    }
                    # Unique applications
                    if ($Grant.ClientApplicationId) { $UniqueApplications[$Grant.ClientApplicationId] = $true }
                }
            }

            $Summary = [PSCustomObject]@{
                TotalPermissionGrants   = if ($SortedResults) { $SortedResults.Count } else { 0 }
                UniqueApplications      = $UniqueApplications.Count
                AdminConsentGrants      = $GrantStats.AdminConsentGrants
                UserConsentGrants       = $GrantStats.UserConsentGrants
                CriticalRiskPermissions = $GrantStats.CriticalRiskPermissions
                HighRiskPermissions     = $GrantStats.HighRiskPermissions
                MediumRiskPermissions   = $GrantStats.MediumRiskPermissions
                LowRiskPermissions      = $GrantStats.LowRiskPermissions
                ExpiredGrants           = $GrantStats.ExpiredGrants
                ExpiringIn30Days        = $GrantStats.ExpiringIn30Days
                ReportGeneratedDate     = Get-Date
                TenantId                = $TenantId
            }

            Write-Information "FINISHED : Service Principal permissions report - $($Summary.TotalPermissionGrants) permission grants analyzed" -InformationAction Continue

            [PSCustomObject]@{
                Summary                 = $Summary
                CriticalRiskPermissions = @($SortedResults.Where({ $_.RiskLevel -eq 'Critical' }) | Sort-Object ClientApplicationName, Permission)
                HighRiskPermissions     = @($SortedResults.Where({ $_.RiskLevel -eq 'High' }) | Sort-Object ClientApplicationName, Permission)
                MediumRiskPermissions   = @($SortedResults.Where({ $_.RiskLevel -eq 'Medium' }) | Sort-Object ClientApplicationName, Permission)
                LowRiskPermissions      = @($SortedResults.Where({ $_.RiskLevel -eq 'Low' }) | Sort-Object ClientApplicationName, Permission)
                AllPermissions          = $SortedResults
            }
        } catch {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.Exception]::new("Get-TntServicePrincipalPermissionReport failed: $($_.Exception.Message)", $_.Exception),
                'GetTntServicePrincipalPermissionReportError',
                [System.Management.Automation.ErrorCategory]::OperationStopped,
                $TenantId
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        } finally {
            if ($ConnectionInfo.ShouldDisconnect) {
                Disconnect-TntGraphSession -ConnectionState $ConnectionInfo
            }
        }
    }
}