-
-
Save psignoret/41793f8c6211d2df5051d77ca3728c09 to your computer and use it in GitHub Desktop.
# THIS CODE IS PROVIDED AS IS WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF | |
# FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT. | |
#Requires -Modules @{ ModuleName="Microsoft.Graph.Authentication" ; ModuleVersion="2.15.0" } | |
#Requires -Modules @{ ModuleName="Microsoft.Graph.DirectoryObjects"; ModuleVersion="2.15.0" } | |
#Requires -Modules @{ ModuleName="Microsoft.Graph.Identity.SignIns"; ModuleVersion="2.15.0" } | |
#Requires -Modules @{ ModuleName="Microsoft.Graph.Applications" ; ModuleVersion="2.15.0" } | |
#Requires -Modules @{ ModuleName="Microsoft.Graph.Users" ; ModuleVersion="2.15.0" } | |
<# | |
.SYNOPSIS | |
Lists delegated permission grants (OAuth2PermissionGrants) and app role assignments (AppRoleAssignments). | |
.PARAMETER DelegatedPermissionGrants | |
If set, will return delegated permission grants. If neither this switch nor the AppRoleAssignments switch is set, | |
both delegated permission grants and app role assignments will be returned. | |
.PARAMETER AppRoleAssignments | |
If set, will return app role assignments. If neither this switch nor the DelegatedPermissionGrants switch is set, | |
both delegated permission grants and app role assignments will be returned. | |
.PARAMETER UserProperties | |
The list of properties of user objects to include in the output. Defaults to DisplayName only. | |
.PARAMETER ServicePrincipalProperties | |
The list of properties of service principals (i.e. apps identities) to include in the output. Defaults to DisplayName only. | |
.PARAMETER ShowProgress | |
Whether or not to display a progress bar when retrieving application permissions (which could take some time). | |
.PARAMETER PrecacheSize | |
The number of users to pre-load into a cache. For tenants with over a thousand users, | |
increasing this may improve performance of the script. | |
.EXAMPLE | |
PS C:\> .\Get-AzureADPSPermissions.ps1 | Export-Csv -Path "permissions.csv" -NoTypeInformation | |
Generates a CSV report of all permissions granted to all apps. | |
.EXAMPLE | |
PS C:\> .\Get-AzureADPSPermissions.ps1 -ApplicationPermissions -ShowProgress | Where-Object { $_.Permission -eq "Directory.Read.All" } | |
Get all apps which have application permissions for Directory.Read.All. | |
.EXAMPLE | |
PS C:\> .\Get-AzureADPSPermissions.ps1 -UserProperties @("DisplayName", "UserPrincipalName", "Mail") -ServicePrincipalProperties @("DisplayName", "AppId") | |
Gets all permissions granted to all apps and includes additional properties for users and service principals. | |
#> | |
[CmdletBinding()] | |
param( | |
[Alias("DelegatedPermissions")] | |
[switch] $DelegatedPermissionGrants, | |
[Alias("ApplicationPermissions")] | |
[switch] $AppRoleAssignments, | |
[string[]] $UserProperties = @("DisplayName"), | |
[string[]] $ServicePrincipalProperties = @("DisplayName"), | |
[switch] $ShowProgress, | |
[int] $PrecacheSize = 999, | |
[switch] $VeryVerbose | |
) | |
# Check that we've connected to Microsoft Graph | |
$context = Get-MgContext | |
if (-not $context) | |
{ | |
throw "You must call Connect-MgGraph -Scopes `"Application.Read.All User.Read.All`" before running this script." | |
} | |
# If neither are selected, retrieve both | |
if (-not ($DelegatedPermissionGrants -or $AppRoleAssignments)) | |
{ | |
$DelegatedPermissionGrants = $true | |
$AppRoleAssignments = $true | |
} | |
# An in-memory cache of objects by {object ID} andy by {object class, object ID} | |
$script:ObjectByObjectId = @{} | |
$script:ObjectByObjectClassId = @{} | |
# Function get object type | |
function GetObjectType ($Object) { | |
if ($Object) { | |
$typeName = $Object.GetType().Name | |
if ($typeName -match "MicrosoftGraph([A-Za-z]+)") { | |
return $Matches[1] | |
} else { | |
Write-Warning "Unable to determine object type: '$($typeName)'" | |
return "Unknown" | |
} | |
} | |
} | |
# Function to add an object to the cache | |
function CacheObject ($Object, $ObjectType = $null) { | |
if ($Object) { | |
if (-not $ObjectType) { | |
$ObjectType = GetObjectType -Object $Object | |
} | |
if (-not $script:ObjectByObjectClassId.ContainsKey($ObjectType)) { | |
$script:ObjectByObjectClassId[$ObjectType] = @{} | |
} | |
$script:ObjectByObjectClassId[$ObjectType][$Object.Id] = $Object | |
$script:ObjectByObjectId[$Object.Id] = $Object | |
} | |
} | |
$ODataObjectTypeMap = @{ | |
"#microsoft.graph.user" = @( "User", [Microsoft.Graph.PowerShell.Models.MicrosoftGraphUser] ) | |
"#microsoft.graph.group" = @( "Group", [Microsoft.Graph.PowerShell.Models.MicrosoftGraphGroup] ) | |
"#microsoft.graph.servicePrincipal" = @( "ServicePrincipal", [Microsoft.Graph.PowerShell.Models.MicrosoftGraphServicePrincipal] ) | |
} | |
$ConsistencyLevelHeader = @{ "ConsistencyLevel" = "eventual" } | |
# Function to retrieve an object from the cache (if it's there), or from Microsoft Graph (if not). | |
function GetObjectByObjectId ($ObjectId) { | |
if (-not $script:ObjectByObjectId.ContainsKey($ObjectId)) { | |
if ($script:VeryVerbose) { | |
Write-Verbose ("Querying Microsoft Graph for single object ID '{0}'" -f $ObjectId) | |
} | |
try { | |
$object = Get-MgDirectoryObject -DirectoryObjectId $ObjectId | |
ResolveTypeAndCacheObject -Object $object | |
} catch { | |
Write-Warning "Single object $($ObjectId) not found." | |
} | |
} | |
return $script:ObjectByObjectId[$ObjectId] | |
} | |
# Function to retrieve the objects for a list of object IDs and store it in the cache | |
function LoadObjectsByObjectIds ($ObjectIds, $objectTypes, $properties) { | |
$ObjectIds = @($ObjectIds | Where-Object { -not $script:ObjectByObjectId.ContainsKey($_) }) | |
if ($ObjectIds) { | |
if ($script:VeryVerbose) { | |
Write-Verbose ("Fetching {0} objects by object IDs" -f $ObjectIds.Count) | |
} | |
try { | |
Get-MgDirectoryObjectById -BodyParameter @{ | |
"ids" = $ObjectIds | |
"types" = @("servicePrincipal", "user") | |
} | ForEach-Object { | |
ResolveTypeAndCacheObject -Object $_ | |
} | |
} catch { | |
Write-Warning "Error fetching objects by object IDs." | |
} | |
} | |
} | |
# Get-MgDirectoryObject and Get-MgDirectoryObjectById are returned as generic directory objects, with the | |
# type and most properties in AdditionalProperties. This function detects the type, casts the object to | |
# that type, and puts it in the cache. | |
function ResolveTypeAndCacheObject ($Object) { | |
($objectType, $type) = $script:ODataObjectTypeMap[$Object.AdditionalProperties.'@odata.type'] | |
if ($type) { | |
$Object = $Object -as $type | |
CacheObject -Object $object -ObjectType $objectType | |
} else { | |
Write-Warning "Unexpected object type: $($type)" | |
} | |
} | |
$empty = @{} # Used later to avoid null checks | |
$maxGetByIdsSize = 999 # Maximum number of object IDs to retrieve in bulk (e.g. using LoadObjectsByObjectIds) | |
# If app role assignments are going to be loaded, we need to pre-load all possible resource service principals. | |
# We do app role assignments first because if we're going to fetch all these service principals anyway, it's | |
# better to fetch them before we start fetching delegated permission grants, so that they're already in the object cache. | |
if ($AppRoleAssignments) { | |
$startTime = [DateTime]::UtcNow | |
Write-Verbose "Retrieving app role assignments..." | |
# We use this filter to get service principals that might be the resource in an app role assignment. This will | |
# ignore service principals for managed identities, which can be the assigned principal for an app role assignment | |
# but currently can't be the resource service principal. | |
# $resourceServicePrincipalFilter = "appRoleAssignedTo/$count ge 1" # Sadly, not supported yet 😔 | |
$resourceServicePrincipalFilter = "servicePrincipalType ne 'ManagedIdentity'" | |
$fetchServicePrincipalsPageSize = 999 | |
# This is just to retrieve the (approximate) count of potential resource service principals. | |
Get-MgServicePrincipal -ConsistencyLevel "eventual" -CountVariable "countResourceServicePrincipals" ` | |
-Select "id" -Filter $resourceServicePrincipalFilter -PageSize 1 | Out-Null | |
# TODO: Select only required properties | |
Write-Verbose "Retrieving all $($countResourceServicePrincipals) potential resource service principals..." | |
Get-MgServicePrincipal -ConsistencyLevel "eventual" -CountVariable "c" ` | |
-Filter $resourceServicePrincipalFilter ` | |
-PageSize $fetchServicePrincipalsPageSize -All | ForEach-Object { $i = 0 } { | |
# Show the progress with estimated time remaining | |
if ($ShowProgress -and $countResourceServicePrincipals) { | |
Write-Progress -Activity "Loading all potential resource service principals..." ` | |
-Status ("Retrieved {0}/{1} service principals" -f $i++, $countResourceServicePrincipals) ` | |
-PercentComplete (($i / $countResourceServicePrincipals) * 100) | |
} | |
# Add the retrieved service principal to a cache | |
CacheObject -Object $_ -ObjectType "ServicePrincipal" | |
} | |
# We need to make a copy of the list of possible resource service principals because later we'll need | |
# to enumerate it, and (1) we want to make sure it only includes the possible resource service | |
# principals, not client service principals that may have been retrieved when retrieving delegated | |
# permission grants, and (2) as we enumerate through these, we'll possibly be fetching additional | |
# service principals that we'll want to place in the cache, and we can't modify a collection that's | |
# being enumerated. | |
$resourceServicePrincipals = $script:ObjectByObjectClassId['ServicePrincipal'].Values | ForEach-Object { $_ } | |
Write-Progress -Activity "Loading all potential resource service principals..." -Completed | |
$clientIsNeeded = $ServicePrincipalProperties.Count -gt 0 | |
$pendingAssignments = {@()}.Invoke() | |
$pendingIds = [Collections.Generic.HashSet[string]]::new() | |
# Iterate over all potential resource ServicePrincipal objects and get app role assignments | |
Write-Verbose "Fetching appRoleAssignedTo for each potential resource service principal..." | |
$resourceServicePrincipals | ForEach-Object { $i = 0 } { | |
if ($ShowProgress) { | |
Write-Progress -Activity "Retrieving app role assignments..." ` | |
-Status ("Checked {0}/{1} service principals" -f $i++, $countResourceServicePrincipals) ` | |
-PercentComplete (($i / $countResourceServicePrincipals) * 100) | |
} | |
$sp = $_ | |
Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $sp.Id -PageSize 999 -All ` | |
| Where-Object { $_.PrincipalType -eq "ServicePrincipal" } | |
} | ForEach-Object -Begin { } -Process { | |
# In this first pass over assignments, we collect assignments with unresolved objects until we have enough | |
# unresolved objects to make a getByIds request. When we do, we make the getByIds request, load the results | |
# into the cache, then "release" these assignments down the pipe, knowing their dependencies are resolved. | |
$assignment = $_ | |
$resourceIsResolved = $script:ObjectByObjectId.ContainsKey($assignment.ResourceId) | |
$clientIsResolved = (-not $clientIsNeeded) -or $script:ObjectByObjectId.ContainsKey($assignment.PrincipalId) | |
if ($resourceIsResolved -and $clientIsResolved) { | |
# Everything that's needed is available | |
$assignment | |
} else { | |
# We don't have everything we need. Set aside the pending assignment, and queue up the object IDs to retrieve | |
$pendingAssignments.Add($assignment) | |
if (-not $resourceIsResolved) { | |
$pendingIds.Add($assignment.ResourceId) | Out-Null | |
} | |
if (-not $clientIsResolved) { | |
$pendingIds.Add($assignment.PrincipalId) | Out-Null | |
} | |
if ($pendingIds.Count -gt ($maxGetByIdsSize - 2)) { | |
# Now that we have a batch of object IDs to retrieve, | |
# fetch them and then emit the pending assignments. | |
LoadObjectsByObjectIds -ObjectIds $pendingIds | |
$pendingIds.Clear() | |
$pendingAssignments | ForEach-Object { $_ } | |
$pendingAssignments.Clear() | |
} | |
} | |
} -End { | |
if ($pendingIds.Count) { | |
LoadObjectsByObjectIds -ObjectIds $pendingIds | |
$pendingIds.Clear() | |
$pendingAssignments | % { $_ } | |
$pendingAssignments.Clear() | |
} | |
} | ForEach-Object { | |
# At this point, we have the assignment and both the client and resource service principal | |
$assignment = $_ | |
$resource = GetObjectByObjectId -ObjectId $assignment.ResourceId | |
$appRole = $resource.AppRoles | Where-Object { $_.Id -eq $assignment.AppRoleId } | |
$grantDetails = [ordered]@{ | |
"PermissionType" = "Application" | |
"ClientObjectId" = $assignment.PrincipalId | |
"ResourceObjectId" = $assignment.ResourceId | |
"PermissionId" = $assignment.AppRoleId | |
"Permission" = $appRole.Value | |
} | |
# Add properties for client and resource service principals | |
if ($ServicePrincipalProperties.Count -gt 0) { | |
$client = GetObjectByObjectId -ObjectId $assignment.PrincipalId | |
$insertAtClient = 2 | |
$insertAtResource = 3 | |
foreach ($propertyName in $ServicePrincipalProperties) { | |
$grantDetails.Insert($insertAtClient++, "Client$($propertyName)", $client.$propertyName) | |
$insertAtResource++ | |
$grantDetails.Insert($insertAtResource, "Resource$($propertyName)", $resource.$propertyName) | |
$insertAtResource ++ | |
} | |
} | |
New-Object PSObject -Property $grantDetails | |
} | |
$endTime = [DateTime]::UtcNow | |
Write-Verbose "Done retrieving app role assignments. Duration: $(($endTime - $startTime).TotalSeconds) seconds" | |
} | |
if ($DelegatedPermissionGrants) { | |
$startTime = [DateTime]::UtcNow | |
$pendingGrants = {@()}.Invoke() | |
$pendingIds = [Collections.Generic.HashSet[string]]::new() | |
# Get one page of User objects and add to the cache | |
Write-Verbose ("Retrieving up to {0} user objects..." -f $PrecacheSize) | |
Get-MgUser -Top $PrecacheSize | Where-Object { | |
CacheObject -Object $_ -ObjectType "User" | |
} | |
# Get all existing delegated permission grnats, get the client, resource and scope details | |
Write-Verbose "Retrieving delegated permission grants..." | |
# As of module version 2.15.0, Get-MgOauth2PermissionGrant doesn't have the -ConsistencyLevel switch, | |
# but it does support the -Header parameter, so we can manually add the required header. | |
Get-MgOauth2PermissionGrant -Header $ConsistencyLevelHeader -CountVariable "c" -PageSize 999 -All ` | |
| ForEach-Object -Begin { } -Process { | |
$grant = $_ | |
# Collect pending objects and emit grants when ready | |
$resourceIsResolved = $script:ObjectByObjectId.ContainsKey($grant.ResourceId) | |
$clientIsResolved = $script:ObjectByObjectId.ContainsKey($grant.ClientId) | |
$userIsResolved = (-not $grant.PrincipalId) -or ($grant.PrincipalId -and $script:ObjectByObjectId.ContainsKey($grant.PrincipalId)) | |
if ($resourceIsResolved -and $clientIsResolved -and $userIsResolved) { | |
# Everything that's needed is available | |
$grant | |
} else { | |
# We don't have everything we need. Set aside the pending grant, and queue up the object IDs to retrieve | |
$pendingGrants.Add($grant) | |
if (-not $resourceIsResolved) { | |
$pendingIds.Add($grant.ResourceId) | Out-Null | |
} | |
if (-not $clientIsResolved) { | |
$pendingIds.Add($grant.ClientId) | Out-Null | |
} | |
if (-not $userIsResolved) { | |
$pendingIds.Add($grant.PrincipalId) | Out-Null | |
} | |
if ($pendingIds.Count -gt ($maxGetByIdsSize - 3)) { | |
# Now that we have a batch of object IDs to retrieve, | |
# fetch them and then emit the pending grants. | |
LoadObjectsByObjectIds -ObjectIds $pendingIds | |
$pendingIds.Clear() | |
$pendingGrants | % { $_ } | |
$pendingGrants.Clear() | |
} | |
} | |
} -End { | |
if ($pendingIds.Count) { | |
LoadObjectsByObjectIds -ObjectIds $pendingIds | |
$pendingIds.Clear() | |
$pendingGrants | % { $_ } | |
$pendingGrants.Clear() | |
} | |
} | ForEach-Object { | |
$grant = $_ | |
if ($grant.Scope) { | |
$grant.Scope.Split(" ") | Where-Object { $_ } | ForEach-Object { | |
$scope = $_ | |
$grantDetails = [ordered]@{ | |
"PermissionType" = "Delegated" | |
"ClientObjectId" = $grant.ClientId | |
"ResourceObjectId" = $grant.ResourceId | |
"Permission" = $scope | |
"ConsentType" = $grant.ConsentType | |
"PrincipalObjectId" = $grant.PrincipalId | |
} | |
# Add properties for client and resource service principals | |
if ($ServicePrincipalProperties.Count -gt 0) { | |
$client = GetObjectByObjectId -ObjectId $grant.ClientId | |
$resource = GetObjectByObjectId -ObjectId $grant.ResourceId | |
$insertAtClient = 2 | |
$insertAtResource = 3 | |
foreach ($propertyName in $ServicePrincipalProperties) { | |
$grantDetails.Insert($insertAtClient++, "Client$propertyName", $client.$propertyName) | |
$insertAtResource++ | |
$grantDetails.Insert($insertAtResource, "Resource$propertyName", $resource.$propertyName) | |
$insertAtResource ++ | |
} | |
} | |
# Add properties for principal (will all be null if there's no principal) | |
if ($UserProperties.Count -gt 0) { | |
$principal = $empty | |
if ($grant.PrincipalId) { | |
$principal = GetObjectByObjectId -ObjectId $grant.PrincipalId | |
} | |
foreach ($propertyName in $UserProperties) { | |
$grantDetails["Principal$propertyName"] = $principal.$propertyName | |
} | |
} | |
New-Object PSObject -Property $grantDetails | |
} | |
} | |
} | |
$endTime = [DateTime]::UtcNow | |
Write-Verbose "Done retrieving delegated permission grants. Duration: $(($endTime - $startTime).TotalSeconds) seconds" | |
} |
You can actually modify the script to read the permissions per Azure AD Service Prinicipal and using
Get-AzureADServicePrincipalOAuth2PermissionGrant -ObjectId $sp.ObjectId
instead of "Get-AzureADOAuth2PermissionGrant -all $true"
Snipset:
Get all registered applications
$AzSPAll = get-AzureADServicePrincipal -All $true
.
.
Get all existing OAuth2 permission grants, get the client, resource and scope details
Write-Verbose "Retrieving OAuth2PermissionGrants...per SP"
foreach($sp in $AzSPAll){
Get-AzureADServicePrincipalOAuth2PermissionGrant -ObjectId $sp.ObjectId | ForEach-Object {
$grant = $_
if ($grant.Scope) {
.
.
at least this worked for me.
hth,
Claus
Hi Claus,
Thanks for the solution. It worked like a charm!!
below is the format I am using..
$azurespnall = Get-AzureADServicePrincipal -all $true
Foreach ($spn in $azurespnall)
{
#$spn = "053f4ac8-8329-4ef8-993e-dca44c6150be"
Get-AzureADServicePrincipalOAuth2PermissionGrant -ObjectId $spn.objectid | ForEach-Object {
$scope = $_
$client = Get-AzureADObjectByObjectId -ObjectIds $spn.objectid
$resource = Get-AzureADObjectByObjectId -ObjectIds $scope.Resourceid
$principalDisplayname = ""
if($scope.PrincipalID)
{
$principal = Get-AzureADObjectByObjectId -ObjectIds $scope.principalID
$principalDisplayname = $principal.DisplayName
}
$scope.clientid
$client.DisplayName
$scope.Resourceid
$resource.DisplayName
$scope.Scope
$principalDisplayname
$principalDisplayname
}
Regards,
Shesh
@evgaff @shesha1 There's currently a bug in Azure AD when you have more than 1000 OAuth2PermissionGrants (delegated permission grants) in the tenant. As @cwitjes rightly points out, a workaround available today is to query these from each ServicePrincipal object's. Unfortunately, this is orders of magnitude slower than the original approach.
I've updated the script to test for the bug, and if it is triggered (because it hasn't been fixed and your tenant has more than 1000 grants), then it will fall back to the per-object (slow) query.
Would it be possible to change this to export either samaccountname, UPN or mail attributes?
@ryandriftingfat I've updated the script to support -UserProperties
and -ServicePrincipalProperties
, so that you can list the properties you'd like to see. See the third example (at the top of the script) to see how to get UserPrincipalName
and Mail
. sAMAccountName
is not available with Azure AD PowerShell, so you wouldn't be able to do that.
This is perfect, thank you so much.
Hello,
Thank you so much for this script !
For my side, I am not facing any error but rather am trying to undestand the following interesting difference.
In a recent effort to extend the script's output so its result displays 2 additional columns representing
the description of the consented permission (UserConsentDescription, AdminConsentDescription),
If I compare some the description I get of the permissions, I see it is not the exact description string/text from the two below approaches.
Approach 1/ If I extend the script you provide by adding the following
as an additional member/column in the custom object being forged in the "Delegated permissions" data collection section
"UserConsentDescription" = $client.Oauth2Permissions.UserConsentDescription
as an additional member/column in the custom object being forged in the "Application permission" data collection section
"AdminConsentDescription" = $appRole.Description
Versus
Approach 2/ If I use the following code region
knowing that the variable $ResultOfTheOriginalScriptAsItIs simply contains the output of the script you provide
#region Get/Display the "AdminConsentDescription", "UserConsentDescription" for each unique "ResourceDisplayName"
$FullyQualifiedPermissions = $ResultOfTheOriginalScriptAsItIs | select -Unique ResourceDisplayName |%{
$resource = $_.ResourceDisplayName
(Get-AzureADServicePrincipal -filter "DisplayName eq '$resource'" -all $true).OAuth2Permissions |
select *, @{n="ResourceDisplayName";e={$resource}}
}
$FullyQualifiedPermissions | GM
$FullyQualifiedPermissions.count
$FullyQualifiedPermissions |
select ResourceDisplayName ,
AdminConsentDescription,
UserConsentDescription ,
AdminConsentDisplayName,
UserConsentDisplayName ,
Id ,
IsEnabled ,
Type ,
Value |
ogv
#endregion
The environment being quite large (50k consented mix of Delegated / Application permissions),
I am trying to understand what is the best approach to use
- why is there this difference
but also - why the first approach has a lot of what I am feeding into the column "UserConsentDescription" through $client.Oauth2Permissions.UserConsentDescription showing as empty
Again thanks !
$client.OAuth2Permissions
is the collection of all delegated permissions defined on the client application's service principal. The. $client.OAuth2Permissions.UserConsentDescription
will be the collection of all user consent descriptions of all delegated permissions defined on the client app's service principal. So, you have two issues there:
- The delegated permission is defined on the resource app, not on the client app.
- You need to select the specific delegated permission which was granted, not all of them.
So, to include the delegated permission display name or description, you could add the following ~L153:
# $scope is the delegated permission claim value, obtained from the OAuth2PermissionGrant
$scope = $_
# Get the service principal for the resource application
$resource = GetObjectByObjectId -ObjectId $grant.ResourceId
# Get the delegated permission (OAuth2Permission) with the same value as $scope
$delegatedPermission = $resource.OAuth2Permissions | Where-Object { $_.Value -ieq $scope }
# Include the delegated permission details in the output object
$grantDetails = [ordered]@{
"PermissionType" = "Delegated"
"ClientObjectId" = $grant.ClientId
"ResourceObjectId" = $grant.ResourceId
"Permission" = $scope
"ConsentType" = $grant.ConsentType
"PrincipalObjectId" = $grant.PrincipalId
"AdminConsentDisplayName" = $delegatedPermission.AdminConsentDisplayName
"AdminConsentDescription" = $delegatedPermission.AdminConsentDescription
"UserConsentDisplayName" = $delegatedPermission.UserConsentDisplayName
"UserConsentDescription" = $delegatedPermission.UserConsentDescription
}
Hi @psignoret
What would be a best way to run this in Azure runbook and export in CSV ?
I tried to "plug in" Export-CSV switch at the end but it doesn't seem to export: ClientDisplayName, ResourceDisplayName and PrincipalDisplayName, any ideas?
$results = @(
....
)
$results | Export-Csv -Path "c:\tmp\permissions.csv" -Append -NoTypeInformation
Hi Philippe
Are you able to help with my previous post?
https://gist.github.com/psignoret/41793f8c6211d2df5051d77ca3728c09#gistcomment-3331284
@r3-zbigniews Can you include how you're calling the script?
@psignoret I'm using AzureRunAsConnection
connection
$Conn = Get-AutomationConnection -Name AzureRunAsConnection
Connect-AzAccount -ServicePrincipal -Tenant $Conn.TenantID `
-ApplicationId $Conn.ApplicationID -CertificateThumbprint $Conn.CertificateThumbprint
Connect-AzureAD -TenantId "TENANTID" -ApplicationId "APPID" -CertificateThumbprint $Conn.CertificateThumbprint
@r3-zbigniews Can you share how you're calling this script?
$Conn = Get-AutomationConnection -Name AzureRunAsConnection
Connect-AzAccount -ServicePrincipal -Tenant $Conn.TenantID `
-ApplicationId $Conn.ApplicationID -CertificateThumbprint $Conn.CertificateThumbprint
Connect-AzureAD -TenantId TENANTID -ApplicationId APPID -CertificateThumbprint $Conn.CertificateThumbprint
$date = Get-Date -Format "ddMMyyyyTHHmmssZ"
<#
.SYNOPSIS
Lists delegated permissions (OAuth2PermissionGrants) and application permissions (AppRoleAssignments).
.PARAMETER DelegatedPermissions
If set, will return delegated permissions. If neither this switch nor the ApplicationPermissions switch is set,
both application and delegated permissions will be returned.
.PARAMETER ApplicationPermissions
If set, will return application permissions. If neither this switch nor the DelegatedPermissions switch is set,
both application and delegated permissions will be returned.
.PARAMETER UserProperties
The list of properties of user objects to include in the output. Defaults to DisplayName only.
.PARAMETER ServicePrincipalProperties
The list of properties of service principals (i.e. apps) to include in the output. Defaults to DisplayName only.
.PARAMETER ShowProgress
Whether or not to display a progress bar when retrieving application permissions (which could take some time).
.PARAMETER PrecacheSize
The number of users to pre-load into a cache. For tenants with over a thousand users,
increasing this may improve performance of the script.
.EXAMPLE
PS C:\> .\Get-AzureADPSPermissions.ps1 | Export-Csv -Path "permissions.csv" -NoTypeInformation
Generates a CSV report of all permissions granted to all apps.
.EXAMPLE
PS C:\> .\Get-AzureADPSPermissions.ps1 -ApplicationPermissions -ShowProgress | Where-Object { $_.Permission -eq "Directory.Read.All" }
Get all apps which have application permissions for Directory.Read.All.
.EXAMPLE
PS C:\> .\Get-AzureADPSPermissions.ps1 -UserProperties @("DisplayName", "UserPrincipalName", "Mail") -ServicePrincipalProperties @("DisplayName", "AppId")
Gets all permissions granted to all apps and includes additional properties for users and service principals.
#>
$report = @(
# function something {
# [CmdletBinding()]
# param(
# [switch] $DelegatedPermissions,
# [switch] $ApplicationPermissions,
# [string[]] $UserProperties = @("DisplayName"),
# [string[]] $ServicePrincipalProperties = @("DisplayName"),
# [switch] $ShowProgress,
# [int] $PrecacheSize = 999
# )
# }
# Get tenant details to test that Connect-AzureAD has been called
try {
$tenant_details = Get-AzureADTenantDetail
} catch {
throw "You must call Connect-AzureAD before running this script."
}
Write-Verbose ("TenantId: {0}, InitialDomain: {1}" -f `
$tenant_details.ObjectId, `
($tenant_details.VerifiedDomains | Where-Object { $_.Initial }).Name)
# An in-memory cache of objects by {object ID} andy by {object class, object ID}
$script:ObjectByObjectId = @{}
$script:ObjectByObjectClassId = @{}
# Function to add an object to the cache
function CacheObject ($Object) {
if ($Object) {
if (-not $script:ObjectByObjectClassId.ContainsKey($Object.ObjectType)) {
$script:ObjectByObjectClassId[$Object.ObjectType] = @{}
}
$script:ObjectByObjectClassId[$Object.ObjectType][$Object.ObjectId] = $Object
$script:ObjectByObjectId[$Object.ObjectId] = $Object
}
}
# Function to retrieve an object from the cache (if it's there), or from Azure AD (if not).
function GetObjectByObjectId ($ObjectId) {
if (-not $script:ObjectByObjectId.ContainsKey($ObjectId)) {
Write-Verbose ("Querying Azure AD for object '{0}'" -f $ObjectId)
try {
$object = Get-AzureADObjectByObjectId -ObjectId $ObjectId
CacheObject -Object $object
} catch {
Write-Verbose "Object not found."
}
}
return $script:ObjectByObjectId[$ObjectId]
}
# Function to retrieve all OAuth2PermissionGrants, either by directly listing them (-FastMode)
# or by iterating over all ServicePrincipal objects. The latter is required if there are more than
# 999 OAuth2PermissionGrants in the tenant, due to a bug in Azure AD.
function GetOAuth2PermissionGrants ([switch]$FastMode) {
if ($FastMode) {
Get-AzureADOAuth2PermissionGrant -All $true
} else {
$script:ObjectByObjectClassId['ServicePrincipal'].GetEnumerator() | ForEach-Object { $i = 0 } {
if ($ShowProgress) {
Write-Progress -Activity "Retrieving delegated permissions..." `
-Status ("Checked {0}/{1} apps" -f $i++, $servicePrincipalCount) `
-PercentComplete (($i / $servicePrincipalCount) * 100)
}
$client = $_.Value
Get-AzureADServicePrincipalOAuth2PermissionGrant -ObjectId $client.ObjectId
}
}
}
$empty = @{} # Used later to avoid null checks
# Get all ServicePrincipal objects and add to the cache
Write-Verbose "Retrieving all ServicePrincipal objects..."
Get-AzureADServicePrincipal -All $true | ForEach-Object {
CacheObject -Object $_
}
$servicePrincipalCount = $script:ObjectByObjectClassId['ServicePrincipal'].Count
if ($DelegatedPermissions -or (-not ($DelegatedPermissions -or $ApplicationPermissions))) {
# Get one page of User objects and add to the cache
Write-Verbose ("Retrieving up to {0} User objects..." -f $PrecacheSize)
Get-AzureADUser -Top $PrecacheSize | Where-Object {
CacheObject -Object $_
}
Write-Verbose "Testing for OAuth2PermissionGrants bug before querying..."
$fastQueryMode = $false
try {
# There's a bug in Azure AD Graph which does not allow for directly listing
# oauth2PermissionGrants if there are more than 999 of them. The following line will
# trigger this bug (if it still exists) and throw an exception.
$null = Get-AzureADOAuth2PermissionGrant -Top 999
$fastQueryMode = $true
} catch {
if ($_.Exception.Message -and $_.Exception.Message.StartsWith("Unexpected end when deserializing array.")) {
Write-Verbose ("Fast query for delegated permissions failed, using slow method...")
} else {
throw $_
}
}
# Get all existing OAuth2 permission grants, get the client, resource and scope details
Write-Verbose "Retrieving OAuth2PermissionGrants..."
GetOAuth2PermissionGrants -FastMode:$fastQueryMode | ForEach-Object {
$grant = $_
if ($grant.Scope) {
$grant.Scope.Split(" ") | Where-Object { $_ } | ForEach-Object {
$scope = $_
$grantDetails = [ordered]@{
"PermissionType" = "Delegated"
"ClientObjectId" = $grant.ClientId
#"ClientDisplayName" = $grant.ClientDisplayName
#"ResourceDisplayName" = $grant.ResourceDisplayName
"ResourceObjectId" = $grant.ResourceId
"Permission" = $scope
"ConsentType" = $grant.ConsentType
"PrincipalObjectId" = $grant.PrincipalId
#"PrincipalDisplayName" = $grant.PrincipalDisplayName
}
# Add properties for client and resource service principals
if ($ServicePrincipalProperties.Count -gt 0) {
$client = GetObjectByObjectId -ObjectId $grant.ClientId
$resource = GetObjectByObjectId -ObjectId $grant.ResourceId
$insertAtClient = 2
$insertAtResource = 3
foreach ($propertyName in $ServicePrincipalProperties) {
$grantDetails.Insert($insertAtClient++, "Client$propertyName", $client.$propertyName)
$insertAtResource++
$grantDetails.Insert($insertAtResource, "Resource$propertyName", $resource.$propertyName)
$insertAtResource ++
}
}
# Add properties for principal (will all be null if there's no principal)
if ($UserProperties.Count -gt 0) {
$principal = $empty
if ($grant.PrincipalId) {
$principal = GetObjectByObjectId -ObjectId $grant.PrincipalId
}
foreach ($propertyName in $UserProperties) {
$grantDetails["Principal$propertyName"] = $principal.$propertyName
}
}
New-Object PSObject -Property $grantDetails
}
}
}
}
if ($ApplicationPermissions -or (-not ($DelegatedPermissions -or $ApplicationPermissions))) {
# Iterate over all ServicePrincipal objects and get app permissions
Write-Verbose "Retrieving AppRoleAssignments..."
$script:ObjectByObjectClassId['ServicePrincipal'].GetEnumerator() | ForEach-Object { $i = 0 } {
if ($ShowProgress) {
Write-Progress -Activity "Retrieving application permissions..." `
-Status ("Checked {0}/{1} apps" -f $i++, $servicePrincipalCount) `
-PercentComplete (($i / $servicePrincipalCount) * 100)
}
$sp = $_.Value
Get-AzureADServiceAppRoleAssignedTo -ObjectId $sp.ObjectId -All $true `
| Where-Object { $_.PrincipalType -eq "ServicePrincipal" } | ForEach-Object {
$assignment = $_
$resource = GetObjectByObjectId -ObjectId $assignment.ResourceId
$appRole = $resource.AppRoles | Where-Object { $_.Id -eq $assignment.Id }
$grantDetails = [ordered]@{
"PermissionType" = "Application"
"ClientObjectId" = $assignment.PrincipalId
"ResourceObjectId" = $assignment.ResourceId
"Permission" = $appRole.Value
}
# Add properties for client and resource service principals
if ($ServicePrincipalProperties.Count -gt 0) {
$client = GetObjectByObjectId -ObjectId $assignment.PrincipalId
$insertAtClient = 2
$insertAtResource = 3
foreach ($propertyName in $ServicePrincipalProperties) {
$grantDetails.Insert($insertAtClient++, "Client$propertyName", $client.$propertyName)
$insertAtResource++
$grantDetails.Insert($insertAtResource, "Resource$propertyName", $resource.$propertyName)
$insertAtResource ++
}
}
New-Object PSObject -Property $grantDetails
}
}
}
)
$report | Export-CSV -nti -Path "$Env:temp\AADSPPermissions_$date.csv"
@psignoret what do you think?
Thank you for the script.
How far back in time does this script go or how long are the logs kept in Azure? For a month? Or does it scan all apps in our tenant? Also is there any way to see who consented for the entire org? There seems to be very little info for an app in the Enterprise Applications list to assist with investigating a specific app.
Thanks
Thank you for the script. How far back in time does this script go or how long are the logs kept in Azure? For a month? Or does it scan all apps in our tenant? Also is there any way to see who consented for the entire org? There seems to be very little info for an app in the Enterprise Applications list to assist with investigating a specific app. Thanks
Script loops through all app permissions, it doesn't check logs. Logs are by default saved only for 1 month in AAD, unless you use additional services to keep them longer. It's in these logs you can see who grants admin consent.
@psignoret I don't know if I fully grasp what happens here, but should it really be "$insertAtResource++" both times?
$insertAtClient = 2
$insertAtResource = 3
foreach ($propertyName in $ServicePrincipalProperties) {
$grantDetails.Insert($insertAtClient++, "Client$propertyName", $client.$propertyName)
$insertAtResource++
$grantDetails.Insert($insertAtResource, "Resource$propertyName", $resource.$propertyName)
$insertAtResource ++
}
My brain logically says it probably should be $insertAtClient++ the first time, but I believe the script works as is so I might be wrong.
I there any powershell command to remove only one or few selected auth permission. When try to run this command Remove-AzureADOAuth2PermissionGrant
-ObjectId
[-InformationAction ]
[-InformationVariable ]
[] it removes all permissions. It would really be helpful if you could help me with removing only one delegated permission
@p0shkar It's not incorrect. The reason is pretty simple, but takes a lot to describe:
By using [ordered]
in $grantDetails = [ordered]@{ ...
, we define $grantDetails
as a System.Collections.Specialized.OrderedDictionary. This allows us to use $grantDetails.Insert(<position>, <key>, <value>)
to insert a new key/value pair at a specific position in the dictionary.
In the foreach
you mention, we're adding two key/value pairs to the dictionary on each iteration: one for the client service principal, and another for the resource service principal. Let's walk through each step for one iteration, to add the "DisplayName" property.
Initially, $insertAtClient
points to index 2, which is the, right after the ClientObjectId
field, and $insertAtResource
points to index 3, the location right after ResourceObjectId
):
{
"PermissionType": "...", // 0
"ClientObjectId": "...", // 1
"ResourceObjectId": "...", // 2 <- $insertAtClient
"Permission": "..." // 3 <- $insertAtResource
}
Now, we execute the line where we insert the property for the client service principal:
$grantDetails.Insert($insertAtClient++, "Client$propertyName", $client.$propertyName)
{
"PermissionType": "...", // 0
"ClientObjectId": "...", // 1
"ClientDisplayName": "...", // 2
"ResourceObjectId": "...", // 3 <- $insertAtClient, $insertAtResource
"Permission": "..." // 4
}
Note how $insertAtClient
is ready for the next iteration, pointing to the location after "ClientDisplayName". However, because we just added a property earlier in the list $insertAtResource
is no longer pointing to where we want it (remember, we want it pointing to the location after ResourceObjectId
). To fix that, we increment $insertAtResource
:
$insertAtResource++
{
"PermissionType": "...", // 0
"ClientObjectId": "...", // 1
"ClientDisplayName": "...", // 2
"ResourceObjectId": "...", // 3 <- $insertAtClient
"Permission": "..." // 4 <- $insertAtResource
}
Now we can go ahead insert a second key/value pair, this time for the property of the resource service principal:
$grantDetails.Insert($insertAtResource, "Resource$propertyName", $resource.$propertyName)
{
"PermissionType": "...", // 0
"ClientObjectId": "...", // 1
"ClientDisplayName": "...", // 2
"ResourceObjectId": "...", // 3 <- $insertAtClient
"ResourceDisplayName": "...", // 4 <- $insertAtResource
"Permission": "..."
}
For no particular reason, in this line I didn't increment $insertAtResource
at the same time as we used 🤷♂️. So we increment it (on a separate line), leaving it pointing to the location after the property ResourceDisplayName
(the one we just added):
$insertAtResource++
{
"PermissionType": "...", // 0
"ClientObjectId": "...", // 1
"ClientDisplayName": "...", // 2
"ResourceObjectId": "...", // 3 <- $insertAtClient
"ResourceDisplayName": "...", // 4
"Permission": "..." // 5 <- $insertAtResource
}
If we did all that again for "AppId" (for example) at the end of the iteration we'd have:
{
"PermissionType": "...", // 0
"ClientObjectId": "...", // 1
"ClientDisplayName": "...", // 2
"ClientAppId": "...", // 3
"ResourceObjectId": "...", // 4 <- $insertAtClient
"ResourceDisplayName": "...", // 5
"ResourceAppId": "...", // 6
"Permission": "..." // 7 <- $insertAtResource
}
And so on, for every property we want to insert. The $insertAtClient
variable will follow the sequence 2, 3, 4, ..., while $insertAtResource
will follow the sequence 3, 5, 7, ...
The only reason we bother with using an ordered dictionary in the first place is so that the object created at New-Object PSObject -Property $grantDetails
lists the properties in that order, rather than in the arbitrary order which we would have gotten if we used a simple hashtable. This allows you to display the output or pipe it directly to something like Export-Csv
, and the fields will be in a logical order (i.e. all the client properties are grouped, as are the resource properties).
To illustrate the problem, consider the following, and note how the output object has the properties in a different order from the hashtable:
> New-Object PSObject -Property (@{"foo" = 1; "bar" = 2; "fizz" = 3}) | fl
bar : 2
foo : 1
fizz : 3
Compare that to the result we get if we used an ordered dictionary. The output object has its properties in the original order:
> New-Object PSObject -Property ([ordered]@{"foo" = 1; "bar" = 2; "fizz" = 3}) | fl
foo : 1
bar : 2
fizz : 3
@pragdh To revoke only some permissions, you can't use Azure AD PowerShell module, as it lacks cmdlets for creating or updating delegated permission grants. Instead, you can use the newer Microsoft Graph PowerShell.
There are two aproaches you could take. The simplest is to remove any existing delegated permission grants entirely, and then create a new one which omits the specific permissions you want to revoke. Alternatively, the existing delegated permission grant can be updated to omit the permissions you want to revoke. I'll demonstrate the second approach, as it minimizes the risk of failing halfway through and leaving the app with no permissions granted at all.
Note: This example revokes a delegated permission which was granted on behalf of all users (i.e. through tenant-wide admin consent). A similar process can be followed to revoke a delegated permission granted on behalf of a single user.
# The app ID of the client application. In this example, we're using Microsoft Graph Explorer,
# an application published by Microsoft (https://aka.ms/ge)
$clientAppId = "de8bc8b5-d9f9-48b1-a8ad-b748da725064" # Microsoft Graph Explorer
# The API for which permissions will be revoked. Microsoft Graph Explorer makes API requests to
# the Microsoft Graph API, so we'll use that here.
$resourceAppId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph API
# The delegated permissions for which to revoke access
$permissionsToRemove = @("User.Read", "Directory.Read.All")
# Step 0. Connect to Microsoft Graph PowerShell. We need Application.Read.All
# to list service principals, and DelegatedPermissionGrant.ReadWrite.All
# to update delegated permission grants.
# WARNING: These are high-privilege permissions!
Connect-MgGraph -Scopes ("Application.Read.All DelegatedPermissionGrant.ReadWrite.All")
# Search for an existing tenant-wide delegated permission grant between the client and the resource
$clientSp = Get-MgServicePrincipal -Filter "appId eq '$($clientAppId)'"
$resourceSp = Get-MgServicePrincipal -Filter "appId eq '$($resourceAppId)'"
$grantFilter = @(
"clientId eq '$($clientSp.Id)'"
"resourceId eq '$($resourceSp.Id)'"
"consentType eq 'AllPrincipals'"
) -join " and "
$grant = Get-MgOauth2PermissionGrant -Filter $grantFilter
Write-Host "Before: $($grant.Scope)"
# Filter granted permissions to only those which are not in $permissionsToRemove
$permissionsToRemove = $permissionsToRemove | % { $_.ToLower() }
$newPermissions = $grant.Scope.Split(" ") | ? { -not $permissionsToRemove.Contains($_.ToLower()) }
# Update the delegated permission grant (or delete it, if all granted permissions were removed)
if ($newPermissions.Count -gt 0) {
$newScope = $newPermissions -join " "
Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $grant.Id -Scope $newScope
} else {
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $grant.Id
}
$grant = Get-MgOauth2PermissionGrant -Filter $grantFilter
Write-Host "After: $($grant.Scope)"
In my case, running this gives:
Welcome To Microsoft Graph!
Before: openid profile offline_access User.Read User.ReadBasic.All
After: openid profile offline_access User.ReadBasic.All
Minor note: In general, using
ToLower()
is not the correct way to do case-insensitive string comparison. In this particular case, it's OK for us to do this because we know that the character set of delegated permission claim values is limited to characters where usingToLower()
works as expected.
@psignoret I tried it on my side , it worked. Thank you so much
@psignoret Thanks for the script! Is there any chance that this script will be updated to replace the Azure AD PowerShell module? Seems like its EOL come December 2022. Thanks!
If anyone is still interested in a working version of this script that uses the new MS Graph cmdlets, you can check my workaround here: https://github.com/acap4z/Azure_Scripts/blob/main/Get-AzureADPSPermissions_graph.ps1
I used the below script in the article, which uses the new Microsoft Graph PowerShell commands:
Really great script I've been using it to create PowerBi reports on all our enterprise and app registrations. Any chance of updating the script to use Microsoft Graph PowerShell? Since Azure AD PowerShell Module is being deprecated.
I used acap4z's updated script that they mentioned above as it's been ported to use the Graph cmdlets, seems to work well:
https://github.com/acap4z/Azure_Scripts/blob/main/Get-AzureADPSPermissions_graph.ps1
I am curious though, how can I get the display name of the app in question? afrad2006 asked this above, I have the same question... I feel like I'm missing something here. I've tried to adjust the $ServicePrincipalProperties param to no success.
Edit - I added 4 new lines, although I get the feeling there is a better way to do it.
Please see my updates to the script here.
Thanks for this post.
the command get-AzureADOAuth2PermissionGrant is returning only finite number of results. If i run "get-AzureADOAuth2PermissionGrant -all $true" it is throwing the below error.
Get-AzureADOAuth2PermissionGrant : Unexpected end when deserializing array. Path 'value[1000]', line 1, position 366740.
At line:1 char:1
Please let me know if you have any workaround for the same.
Is there any other command which provides the user scopes?
Thanks,
Shesh