|
1 | | -# The app for which consent is being granted. |
2 | | -$clientAppId = "de8bc8b5-d9f9-48b1-a8ad-b748da725064" # Microsoft Graph Explorer |
3 | | - |
4 | | -# The API to which access will be granted. Microsoft Graph Explorer makes API |
5 | | -# requests to the Microsoft Graph API, so we'll use that here. |
6 | | -$resourceAppId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph API |
7 | | - |
8 | | -# The permissions to grant. Here we're including "openid", "profile", "User.Read" |
9 | | -# and "offline_access" (for basic sign-in), as well as "User.ReadBasic.All" (for |
10 | | -# reading other users' basic profile). |
11 | | -$permissions = @("openid", "profile", "offline_access", "User.Read", "User.ReadBasic.All") |
12 | | - |
13 | | -# The user on behalf of whom access will be granted. The app will be able to access |
14 | | -# the API on behalf of this user. |
15 | | -$userUpnOrId = "mcontestabile@xxx.yyy" |
16 | | - |
17 | | -# Step 0. Connect to Microsoft Graph PowerShell. We need User.ReadBasic.All to get |
18 | | -# users' IDs, Application.ReadWrite.All to list and create service principals, |
19 | | -# DelegatedPermissionGrant.ReadWrite.All to create delegated permission grants, |
20 | | -# and AppRoleAssignment.ReadWrite.All to assign an app role. |
21 | | -# WARNING: These are high-privilege permissions! |
22 | | -Connect-MgGraph -Scopes ("User.ReadBasic.All Application.ReadWrite.All " + "DelegatedPermissionGrant.ReadWrite.All " + "AppRoleAssignment.ReadWrite.All") |
23 | | - |
24 | | -# Step 1. Check if a service principal exists for the client application. |
25 | | -# If one doesn't exist, create it. |
26 | | -$clientSp = Get-MgServicePrincipal -Filter "appId eq '$($clientAppId)'" |
27 | | -if (-not $clientSp) { |
28 | | - $clientSp = New-MgServicePrincipal -AppId $clientAppId |
29 | | -} |
30 | | - |
31 | | -# Step 2. Create a delegated permission that grants the client app access to the |
32 | | -# API, on behalf of the user. (This example assumes that an existing delegated |
33 | | -# permission grant does not already exist, in which case it would be necessary |
34 | | -# to update the existing grant, rather than create a new one.) |
35 | | -$user = Get-MgUser -UserId $userUpnOrId |
36 | | -$resourceSp = Get-MgServicePrincipal -Filter "appId eq '$($resourceAppId)'" |
37 | | -$scopeToGrant = $permissions -join " " |
38 | | -$grant = New-MgOauth2PermissionGrant -ResourceId $resourceSp.Id -Scope $scopeToGrant -ClientId $clientSp.Id -ConsentType "Principal" -PrincipalId $user.Id |
39 | | - |
40 | | -# Step 3. Assign the app to the user. This ensures that the user can sign in if assignment |
41 | | -# is required, and ensures that the app shows up under the user's My Apps portal. |
42 | | -if ($clientSp.AppRoles | ? { $_.AllowedMemberTypes -contains "User" }) { |
43 | | - Write-Warning ("A default app role assignment cannot be created because the " + "client application exposes user-assignable app roles. You must " + "assign the user a specific app role for the app to be listed " + "in the user's My Apps portal.") |
44 | | -} else { |
45 | | - # The app role ID 00000000-0000-0000-0000-000000000000 is the default app role |
46 | | - # indicating that the app is assigned to the user, but not for any specific |
47 | | - # app role. |
48 | | - $assignment = New-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $clientSp.Id -ResourceId $clientSp.Id -PrincipalId $user.Id -AppRoleId "00000000-0000-0000-0000-000000000000" |
| 1 | +#requires -Version 7.0 |
| 2 | +<# |
| 3 | +.SYNOPSIS |
| 4 | +Audits Microsoft Entra delegated OAuth consent grants without modifying the tenant. |
49 | 5 |
|
| 6 | +.DESCRIPTION |
| 7 | +Performs a read-only review of delegated permission grants for a specified client |
| 8 | +application, user, or both. The script resolves client and resource service |
| 9 | +principals, validates granted scope names against the resource API, identifies |
| 10 | +user-specific versus tenant-wide consent, and exports structured CSV evidence. |
| 11 | +
|
| 12 | +The script does not create applications, service principals, consent grants, |
| 13 | +app-role assignments, access tokens, or refresh tokens. |
| 14 | +
|
| 15 | +.PARAMETER ClientAppId |
| 16 | +Optional application (client) ID used to limit results to one client application. |
| 17 | +
|
| 18 | +.PARAMETER UserPrincipalName |
| 19 | +Optional Microsoft Entra user principal name used to limit results to grants made |
| 20 | +for one user. Tenant-wide grants are excluded when this filter is specified unless |
| 21 | +-IncludeTenantWide is also supplied. |
| 22 | +
|
| 23 | +.PARAMETER IncludeTenantWide |
| 24 | +When UserPrincipalName is specified, also includes grants whose ConsentType is |
| 25 | +AllPrincipals. |
| 26 | +
|
| 27 | +.PARAMETER OutputCsv |
| 28 | +Path for the CSV report. |
| 29 | +
|
| 30 | +.EXAMPLE |
| 31 | +./Get-EntraDelegatedConsentGrantAudit.ps1 -ClientAppId '11111111-1111-1111-1111-111111111111' |
| 32 | +
|
| 33 | +.EXAMPLE |
| 34 | +./Get-EntraDelegatedConsentGrantAudit.ps1 -UserPrincipalName 'alice@contoso.com' -IncludeTenantWide |
| 35 | +
|
| 36 | +.EXAMPLE |
| 37 | +./Get-EntraDelegatedConsentGrantAudit.ps1 ` |
| 38 | + -ClientAppId '11111111-1111-1111-1111-111111111111' ` |
| 39 | + -UserPrincipalName 'alice@contoso.com' ` |
| 40 | + -OutputCsv ./delegated-consent-audit.csv |
| 41 | +
|
| 42 | +.NOTES |
| 43 | +Required delegated Microsoft Graph scopes: |
| 44 | +- DelegatedPermissionGrant.Read.All |
| 45 | +- Application.Read.All |
| 46 | +- User.Read.All, only when UserPrincipalName is used |
| 47 | +
|
| 48 | +The signed-in administrator must also hold an appropriate Microsoft Entra role. |
| 49 | +#> |
| 50 | + |
| 51 | +[CmdletBinding()] |
| 52 | +param( |
| 53 | + [Parameter()] |
| 54 | + [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}$')] |
| 55 | + [string]$ClientAppId, |
| 56 | + |
| 57 | + [Parameter()] |
| 58 | + [ValidatePattern('^[^@\s]+@[^@\s]+\.[^@\s]+$')] |
| 59 | + [string]$UserPrincipalName, |
| 60 | + |
| 61 | + [Parameter()] |
| 62 | + [switch]$IncludeTenantWide, |
| 63 | + |
| 64 | + [Parameter()] |
| 65 | + [string]$OutputCsv = (Join-Path $PWD 'EntraDelegatedConsentGrantAudit.csv') |
| 66 | +) |
| 67 | + |
| 68 | +Set-StrictMode -Version Latest |
| 69 | +$ErrorActionPreference = 'Stop' |
| 70 | + |
| 71 | +$requiredCommands = @( |
| 72 | + 'Connect-MgGraph', |
| 73 | + 'Get-MgContext', |
| 74 | + 'Get-MgServicePrincipal', |
| 75 | + 'Get-MgOauth2PermissionGrant' |
| 76 | +) |
| 77 | + |
| 78 | +if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { |
| 79 | + $requiredCommands += 'Get-MgUser' |
| 80 | +} |
| 81 | + |
| 82 | +foreach ($commandName in $requiredCommands) { |
| 83 | + if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) { |
| 84 | + throw "Required command '$commandName' was not found. Install Microsoft.Graph.Authentication, Microsoft.Graph.Applications, and Microsoft.Graph.Users." |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +$requiredScopes = [System.Collections.Generic.List[string]]::new() |
| 89 | +$requiredScopes.Add('DelegatedPermissionGrant.Read.All') |
| 90 | +$requiredScopes.Add('Application.Read.All') |
| 91 | +if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { |
| 92 | + $requiredScopes.Add('User.Read.All') |
| 93 | +} |
| 94 | + |
| 95 | +$context = Get-MgContext |
| 96 | +$mustConnect = $null -eq $context |
| 97 | +if (-not $mustConnect) { |
| 98 | + foreach ($scope in $requiredScopes) { |
| 99 | + if ($scope -notin @($context.Scopes)) { |
| 100 | + $mustConnect = $true |
| 101 | + break |
| 102 | + } |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +if ($mustConnect) { |
| 107 | + Write-Host 'Connecting to Microsoft Graph with read-only scopes...' -ForegroundColor Cyan |
| 108 | + Connect-MgGraph -Scopes $requiredScopes.ToArray() -NoWelcome | Out-Null |
| 109 | +} |
| 110 | + |
| 111 | +$user = $null |
| 112 | +if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { |
| 113 | + $user = Get-MgUser -UserId $UserPrincipalName -Property Id,DisplayName,UserPrincipalName -ErrorAction Stop |
| 114 | + Write-Host "User filter: $($user.DisplayName) <$($user.UserPrincipalName)>" -ForegroundColor DarkGray |
| 115 | +} |
| 116 | + |
| 117 | +$clientServicePrincipal = $null |
| 118 | +if (-not [string]::IsNullOrWhiteSpace($ClientAppId)) { |
| 119 | + $escapedClientAppId = $ClientAppId.Replace("'", "''") |
| 120 | + $clientMatches = @(Get-MgServicePrincipal -Filter "appId eq '$escapedClientAppId'" -All -Property Id,AppId,DisplayName,PublisherName,ServicePrincipalType) |
| 121 | + |
| 122 | + if ($clientMatches.Count -eq 0) { |
| 123 | + throw "No service principal was found for client application ID '$ClientAppId' in the connected tenant." |
| 124 | + } |
| 125 | + if ($clientMatches.Count -gt 1) { |
| 126 | + throw "Multiple service principals were returned for client application ID '$ClientAppId'." |
| 127 | + } |
| 128 | + |
| 129 | + $clientServicePrincipal = $clientMatches[0] |
| 130 | + Write-Host "Client filter: $($clientServicePrincipal.DisplayName) <$ClientAppId>" -ForegroundColor DarkGray |
| 131 | +} |
| 132 | + |
| 133 | +$allServicePrincipals = @(Get-MgServicePrincipal -All -Property Id,AppId,DisplayName,PublisherName,Oauth2PermissionScopes,ServicePrincipalType) |
| 134 | +$servicePrincipalById = @{} |
| 135 | +foreach ($servicePrincipal in $allServicePrincipals) { |
| 136 | + $servicePrincipalById[[string]$servicePrincipal.Id] = $servicePrincipal |
| 137 | +} |
| 138 | + |
| 139 | +$grants = @(Get-MgOauth2PermissionGrant -All) |
| 140 | +$filteredGrants = @( |
| 141 | + foreach ($grant in $grants) { |
| 142 | + if ($null -ne $clientServicePrincipal -and [string]$grant.ClientId -ne [string]$clientServicePrincipal.Id) { |
| 143 | + continue |
| 144 | + } |
| 145 | + |
| 146 | + if ($null -ne $user) { |
| 147 | + $isUserGrant = $grant.ConsentType -eq 'Principal' -and [string]$grant.PrincipalId -eq [string]$user.Id |
| 148 | + $isTenantWide = $IncludeTenantWide -and $grant.ConsentType -eq 'AllPrincipals' |
| 149 | + if (-not $isUserGrant -and -not $isTenantWide) { |
| 150 | + continue |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + $grant |
| 155 | + } |
| 156 | +) |
| 157 | + |
| 158 | +$results = [System.Collections.Generic.List[object]]::new() |
| 159 | + |
| 160 | +foreach ($grant in $filteredGrants) { |
| 161 | + $clientSp = $servicePrincipalById[[string]$grant.ClientId] |
| 162 | + $resourceSp = $servicePrincipalById[[string]$grant.ResourceId] |
| 163 | + |
| 164 | + $grantedScopes = @( |
| 165 | + ([string]$grant.Scope -split '\s+') | |
| 166 | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | |
| 167 | + Sort-Object -Unique |
| 168 | + ) |
| 169 | + |
| 170 | + $definedScopes = @{} |
| 171 | + if ($null -ne $resourceSp) { |
| 172 | + foreach ($scopeDefinition in @($resourceSp.Oauth2PermissionScopes)) { |
| 173 | + if (-not [string]::IsNullOrWhiteSpace([string]$scopeDefinition.Value)) { |
| 174 | + $definedScopes[[string]$scopeDefinition.Value] = $scopeDefinition |
| 175 | + } |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + $unknownScopes = @($grantedScopes | Where-Object { -not $definedScopes.ContainsKey($_) }) |
| 180 | + $adminConsentScopes = @( |
| 181 | + foreach ($scopeName in $grantedScopes) { |
| 182 | + if ($definedScopes.ContainsKey($scopeName)) { |
| 183 | + $definition = $definedScopes[$scopeName] |
| 184 | + if ([string]$definition.Type -eq 'Admin') { |
| 185 | + $scopeName |
| 186 | + } |
| 187 | + } |
| 188 | + } |
| 189 | + ) |
| 190 | + |
| 191 | + $riskFlags = [System.Collections.Generic.List[string]]::new() |
| 192 | + if ($grant.ConsentType -eq 'AllPrincipals') { |
| 193 | + $riskFlags.Add('TenantWideConsent') |
| 194 | + } |
| 195 | + if ($adminConsentScopes.Count -gt 0) { |
| 196 | + $riskFlags.Add('ContainsAdminConsentScope') |
| 197 | + } |
| 198 | + if ($unknownScopes.Count -gt 0) { |
| 199 | + $riskFlags.Add('UnknownOrRetiredScope') |
| 200 | + } |
| 201 | + if ($null -eq $clientSp) { |
| 202 | + $riskFlags.Add('ClientServicePrincipalNotResolved') |
| 203 | + } |
| 204 | + if ($null -eq $resourceSp) { |
| 205 | + $riskFlags.Add('ResourceServicePrincipalNotResolved') |
| 206 | + } |
| 207 | + |
| 208 | + $results.Add([pscustomobject]@{ |
| 209 | + GrantId = $grant.Id |
| 210 | + ConsentType = $grant.ConsentType |
| 211 | + PrincipalId = $grant.PrincipalId |
| 212 | + IsRequestedUser = $null -ne $user -and [string]$grant.PrincipalId -eq [string]$user.Id |
| 213 | + ClientServicePrincipalId = $grant.ClientId |
| 214 | + ClientAppId = if ($null -ne $clientSp) { $clientSp.AppId } else { $null } |
| 215 | + ClientDisplayName = if ($null -ne $clientSp) { $clientSp.DisplayName } else { $null } |
| 216 | + ClientPublisher = if ($null -ne $clientSp) { $clientSp.PublisherName } else { $null } |
| 217 | + ResourceServicePrincipalId = $grant.ResourceId |
| 218 | + ResourceAppId = if ($null -ne $resourceSp) { $resourceSp.AppId } else { $null } |
| 219 | + ResourceDisplayName = if ($null -ne $resourceSp) { $resourceSp.DisplayName } else { $null } |
| 220 | + GrantedScopes = $grantedScopes -join ' ' |
| 221 | + GrantedScopeCount = $grantedScopes.Count |
| 222 | + AdminConsentScopes = $adminConsentScopes -join ' ' |
| 223 | + UnknownOrRetiredScopes = $unknownScopes -join ' ' |
| 224 | + RiskFlags = $riskFlags -join ';' |
| 225 | + }) |
| 226 | +} |
| 227 | + |
| 228 | +$parentDirectory = Split-Path -Parent $OutputCsv |
| 229 | +if (-not [string]::IsNullOrWhiteSpace($parentDirectory)) { |
| 230 | + New-Item -Path $parentDirectory -ItemType Directory -Force | Out-Null |
50 | 231 | } |
| 232 | + |
| 233 | +$results | |
| 234 | + Sort-Object ConsentType, ClientDisplayName, ResourceDisplayName | |
| 235 | + Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding utf8 |
| 236 | + |
| 237 | +Write-Host '' |
| 238 | +Write-Host '=== Microsoft Entra Delegated Consent Grant Audit ===' -ForegroundColor Cyan |
| 239 | +Write-Host "Grants reviewed : $($results.Count)" |
| 240 | +Write-Host "Tenant-wide grants : $(@($results | Where-Object ConsentType -eq 'AllPrincipals').Count)" |
| 241 | +Write-Host "User-specific grants : $(@($results | Where-Object ConsentType -eq 'Principal').Count)" |
| 242 | +Write-Host "Grants with risk flags : $(@($results | Where-Object { -not [string]::IsNullOrWhiteSpace($_.RiskFlags) }).Count)" |
| 243 | +Write-Host "CSV report : $OutputCsv" -ForegroundColor Cyan |
| 244 | +Write-Host '' |
| 245 | + |
| 246 | +if ($results.Count -gt 0) { |
| 247 | + $results | |
| 248 | + Select-Object ClientDisplayName, ResourceDisplayName, ConsentType, GrantedScopeCount, RiskFlags | |
| 249 | + Format-Table -AutoSize -Wrap |
| 250 | +} |
| 251 | +else { |
| 252 | + Write-Host 'No delegated permission grants matched the supplied filters.' -ForegroundColor Green |
| 253 | +} |
| 254 | + |
| 255 | +Write-Host '' |
| 256 | +Write-Host 'Read-only audit complete. No applications, consent grants, assignments, or tokens were created or modified.' -ForegroundColor Green |
0 commit comments