How to manage Entra ID delegate permissions for specific users

A somewhat common question I run over in the different communities I frequent is the possibility to add or remove user consent (delegate permissions) for a specific Entra ID integrated application (service principal). Annoyingly, some people seem to be convinced that such operations are only possible via the UI or not at all. I suppose we can blame this on the fact that the relevant Graph API documentation articles are a bit harder to find, or whatever. So let’s bring some visibility to this, shall we.

Setting the stage

First things first, let’s set the stage. We’re talking about delegate(d) permissions here, i.e. permissions for applications that run in the context of a logged in user. Those are represented by an oAuth2PermissionGrant object within the Graph API and are exposed via the scp (“scopes”) claim within the access token. Depending on the classification of the permission requested and the tenant’s consent settings, an end user can either consent to it himself, request an admin to consent, or be blocked from granting the consent altogether.

Herein lies the first common misunderstanding. Whenever a user requests permission for which admin consent is required, it is NOT mandatory to grant tenant-wide consent, by selecting the “consent on behalf of your organization” checkbox. Yes, I know, there are certainly some improvements Microsoft should made on the UI side of things to better handle and expand support for such scenarios. Until that happens though, you can use the Graph API or the Graph SDK for PowerShell to grant the consent instead.

We would need a guinea pig for our examples, so let’s take the Graph explorer tool itself. Before we start, here’s how the permissions on the Graph explorer’s service principal currently look like in my own tenant. We have a total of nine entries, all of them corresponding to my own user account:

oAuth2PermissionGrantsHere’s how a sample permission object looks like and conversely, what building blocks we need for our “consent” operations. The clientId is the GUID of the service principal representing the app, principalId is the GUID of the user object. The consentType value of “principal” signals that this grant applies to a given user only. The value of the scope property lists the set of permissions (“scopes”) granted on that user. They in turn are related to the “resource” or “api” we are granting access to, which is represented by the resourceId value, i.e. the GUID of the service principal representing said resource in our tenant. Lastly, we have the id of the Oauth2PermissionGrant object itself, which is the only thing we need for removal request.

 "clientId": "728a0b66-c446-48ee-a959-f9669ee6f6d9",
"consentType": "Principal",
"id": "ZguKckbE7kipWflmnub22YhlrhELZBpBiBJr-xJXoOCiFxFC6BtiQrhHknsEg5yb",
"principalId": "421117a2-1be8-4262-b847-927b04839c9b",
"resourceId": "11ae6588-640b-411a-8812-6bfb1257a0e0",
"scope": "openid profile User.Read offline_access"

Add delegate permissions for specific user

For our first example, let’s use the Graph SDK for PowerShell to grant the Directory.Read.All scope to another user. As said scope requires admin consent, the user cannot add it on its own, unlike the “generic” openid, profile, User.Read and offline_access scopes we also see on the screenshot above. If our operation is successful, the Directory.Read.All entry above should reflect the new user, and once said user successfully logs into the Graph explorer tool himself, we will also note the “generic” scopes being updated.

We already described the building blocks above, so here are some examples on how to put them all together. Make sure you are running the examples with sufficient permissions – as mentioned few times already, this is an admin-level operation. The minimum permission required is DelegatedPermissionGrant.ReadWrite.All.

#Connect to the Graph and make sure you have sufficient permissions to add the grant
Connect-MgGraph -Scopes DelegatedPermissionGrant.ReadWrite.All

#Get the user's ID
Get-MgUser -UserId pesho@michev.info | select -ExpandProperty id
421117a2-1be8-4262-b847-927b04839c9b

#Get the Graph explorer's service principal ID
Get-MgServicePrincipal -Filter "AppId eq 'de8bc8b5-d9f9-48b1-a8ad-b748da725064'" | select -ExpandProperty id
728a0b66-c446-48ee-a959-f9669ee6f6d9

#Get the Graph API resource's ID
Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'" | select -ExpandProperty id
11ae6588-640b-411a-8812-6bfb1257a0e0

#Grant the Directory.Read.All scope for said user
New-MgOauth2PermissionGrant -ClientId 728a0b66-c446-48ee-a959-f9669ee6f6d9 -PrincipalId 421117a2-1be8-4262-b847-927b04839c9b -ResourceId 11ae6588-640b-411a-8812-6bfb1257a0e0 -Scope "Directory.Read.All" -ConsentType "Principal"


Id ClientId ConsentType PrincipalId ResourceId
-- -------- ----------- ----------- ----------
ZguKckbE7kipWflmnub22YhlrhELZBpBiBJr-xJXoOCiFxFC6BtiQrhHknsEg5yb 728a0b66-c446-48ee-a959-f9669ee6f6d9 Principal 421117a2-1be8-4262-b847-927b04839c9b 11ae6588-640b-411a-8812-6bfb1257a0e0

At this point, the Directory.Read.All permission has been granted on the user, without any interaction on his side. Once he logs into the Graph explorer tool (and consents to the “generic” scopes as part of the process), the user will be able to leverage the newly granted permission to run directory-wide queries, for which otherwise he would not have access. You can confirm this by running a sample query, one that requires the presence of the Directory.Read.All scope, such as one using the /servicePrincipals endpoint:

oAuth2PermissionGrants4

oAuth2PermissionGrants1

If you want to optimize the example above in the form of one-liner, you can try something like:

New-MgOauth2PermissionGrant -ClientId (Get-MgServicePrincipal -Filter "AppId eq 'de8bc8b5-d9f9-48b1-a8ad-b748da725064'").Id -PrincipalId (Get-MgUser -UserId pesho@michev.info).Id -ResourceId (Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'").Id -Scope "Directory.Read.All" -ConsentType "Principal"

Of course, you can also use direct Graph API request to grant the permission:

POST https://graph.microsoft.com/v1.0/oauth2PermissionGrants
{
"clientId": "728a0b66-c446-48ee-a959-f9669ee6f6d9",
"consentType": "Principal",
"principalId": "421117a2-1be8-4262-b847-927b04839c9b",
"resourceId": "11ae6588-640b-411a-8812-6bfb1257a0e0",
"scope": "Directory.Read.All"
}

Note that all the GUIDs we referenced above are the “local” representation of the corresponding application and resource. As a local representation of a third-party application will not exist by default, in some scenarios you might end up needing to create the service principal object itself, before granting the permissions. As for the resource values, the Graph API resource is just one of many others you can leverage. You can find a sample list of additional values in this article.

Another note is due here. If the user already has some permissions granted on the app (service principal), the method we used above will fail with an “Permission entry already exists” error. This happens because of the way the permissions are stored, as there is no way to incrementally add (or remove) scopes on the same resource (i.e. the Graph API). Instead, you have to replace the existing entry altogether. See the examples in the last section on how to do that.

For the sake of completeness, if you want to grant directory-wide consent, you should replace the ConsentType value above with AllPrincipals and omit the principalId property. Here’s also how the audit log entry of the “add consent” operation we performed above looks like:

ActivityDateTime ActivityDisplayName Actor Modified properties
---------------- ------------------- ----- -------------------
13/05/24 08:16:17 Add delegated permission grant vasil@michev.info DelegatedPermissionGrant.Scope "Directory.Read.All"
DelegatedPermissionGrant.ConsentType "Principal"
ServicePrincipal.ObjectID "728a0b66-c446-48ee-a959-f9669ee6f6d9"
ServicePrincipal.DisplayName
ServicePrincipal.AppId
ServicePrincipal.Name
SPN "14d82eec-204b-4c2f-b7e8-296a70dab67e"

List delegate permissions for specific user

Now that we know how to add delegate permissions for specific user, and avoid granting tenant-wide consent in the process, we can also discuss the “opposite” scenario, removing permissions. Before that though, it doesn’t hurt to add few examples on how to get the permissions our user has on a given app (service principal). In fact, there is also a tenant-wide (or non service principal specific) endpoint we can use to list all grants for the user in question, across all service principals. Let’s review few examples.

To list all delegate permissions granted on specific service principal to a given user, you can use any of the following:

#List delegate permissions on the 728a0b66-c446-48ee-a959-f9669ee6f6d9 SP for user 421117a2-1be8-4262-b847-927b04839c9b
#Note that this is an "advanced query" and as such requires the consistencyLevel=eventual header

GET https://graph.microsoft.com/v1.0/servicePrincipals/728a0b66-c446-48ee-a959-f9669ee6f6d9/oauth2PermissionGrants?$filter=principalId+eq+'421117a2-1be8-4262-b847-927b04839c9b'&$count=true

#To achieve the same via the Graph SDK for PowerShell, use Get-MgServicePrincipalOauth2PermissionGrant
#You cannot use server-side filters as the cmdlet does not support the -ConsistencyLevel parameter, thanks Microsoft

Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId 728a0b66-c446-48ee-a959-f9669ee6f6d9 | ? {$_.principalId -eq '421117a2-1be8-4262-b847-927b04839c9b'}

#Alternatively, you can use Invoke-MgGraphRequest cmdlet

Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/728a0b66-c446-48ee-a959-f9669ee6f6d9/oauth2PermissionGrants?`$filter=principalId+eq+'421117a2-1be8-4262-b847-927b04839c9b'&`$count=true" -Headers @{"consistencyLevel"="eventual"} | select -ExpandProperty Value

oAuth2PermissionGrants2

As mentioned above, we can also leverage the tenant-wide /oauth2PermissionGrants endpoint:

#List delegate permissions on the 728a0b66-c446-48ee-a959-f9669ee6f6d9 SP for user 421117a2-1be8-4262-b847-927b04839c9b
#This one is NOT considered an "advanced query"

GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=principalId eq '421117a2-1be8-4262-b847-927b04839c9b' and clientId eq '728a0b66-c446-48ee-a959-f9669ee6f6d9'

#List all delegate permissions across all service principals for a given user

GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=principalId eq '421117a2-1be8-4262-b847-927b04839c9b'

Instead of using a filter on the principalId value, we can also use a query against the /users/{id}/oauth2PermissionGrants endpoint. The downside of this approach is that we cannot use a server-side filter on the clientId value.

#List all delegate permissions across all service principals for a given user
GET https://graph.microsoft.com/v1.0/users/pesho@michev.info/oauth2PermissionGrants

Here are also the corresponding Graph SDK for PowerShell examples:

#To achieve the same via the Graph SDK for PowerShell, use Get-MgOauth2PermissionGrant

Get-MgOauth2PermissionGrant -Filter "principalId eq '421117a2-1be8-4262-b847-927b04839c9b' and clientId eq '728a0b66-c446-48ee-a959-f9669ee6f6d9'"

Get-MgOauth2PermissionGrant -Filter "principalId eq '421117a2-1be8-4262-b847-927b04839c9b'"

Get-MgOauth2PermissionGrant -Filter "principalId eq `'$((Get-MgUser -UserId pesho@michev.info).Id)`'"

Get-MgUserOauth2PermissionGrant -UserId pesho@michev.info

Remove delegate permissions for specific user

Finally, let’s cover removing the permissions. We can outline two scenarios here. First, remove any and all permissions for the user from a given service principal. This scenario can also be extended to remove any and all permissions for said user across all service principals in the organization, something we did in our recent script. The second scenario is a bit more targeted, namely removing a specific permission only. Say the Directory.Read.All permission we granted above. Let’s review both cases.

We are already familiar with the building blocks for such a request from the above examples. We know that the permissions are stamped as an oAuth2PermissionGrant object on the corresponding service principal. The first scenario is easier to handle – we need to locate any Oauth2PermissionGrant objects referencing the user in question and remove them. We can leverage the examples from the previous section to do the “locating” part. Once we have the list of IDs for the corresponding Oauth2PermissionGrant objects, removing the permissions is easy. If using the Graph API, this is done via the DELETE method, by specifying the corresponding ID. Do note that we can only use the tenant-wide endpoint, much like in the “add permissions” scenario. In other words the service principal specific endpoint can only be used to LIST delegate permission grants, nothing else.

#Remove all delegate permissions for a given user from specific service principal

DELETE https://graph.microsoft.com/v1.0/oauth2PermissionGrants/ZguKckbE7kipWflmnub22YhlrhELZBpBiBJr-xJXoOCiFxFC6BtiQrhHknsEg5yb

#To perform the same operation via the Graph SDK for PowerShell, use Remove-MgOauth2PermissionGrant

Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId "ZguKckbE7kipWflmnub22YhlrhELZBpBiBJr-xJXoOCiFxFC6BtiQrhHknsEg5yb"

The “extended” version where we want to remove all permissions for the user across all service principals is addressed via the same request/cmdlet. We simply need to generate the list of IDs, then iterate over each of them and remove the entry. If you need more detailed instructions or code samples, refer to this article.

Lastly, let’s address the scenario where we want to remove specific permission only. There is no direct cmdlet/endpoint we can leverage for this scenario. Instead, we will use a workaround: we remove the existing OAuth2PermissionGrant entry and create a new one by leveraging the same set of property values, with the updated list of scopes, effectively replacing the OAuth2PermissionGrant object. While the process might sound a bit convoluted, the operation is easy to automate via PowerShell. The “raw” Graph API request is also not that complicated, as we can use the GET method to fetch the existing entry and copy its properties.

#Fetch the existing entry, copy its properties
GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants/ZguKckbE7kipWflmnub22YhlrhELZBpBiBJr-xJXoOCiFxFC6BtiQrhHknsEg5yb

#Remove the existing entry
DELETE https://graph.microsoft.com/v1.0/oauth2PermissionGrants/ZguKckbE7kipWflmnub22YhlrhELZBpBiBJr-xJXoOCiFxFC6BtiQrhHknsEg5yb

#Add the updated entry
POST https://graph.microsoft.com/v1.0/oauth2PermissionGrants
{
    "clientId": "728a0b66-c446-48ee-a959-f9669ee6f6d9",
    "consentType": "Principal",
    "principalId": "421117a2-1be8-4262-b847-927b04839c9b",
    "resourceId": "11ae6588-640b-411a-8812-6bfb1257a0e0",
    "scope": "openid profile User.Read offline_access"
}

PowerShell can of course make the process a bit easier, and we can even add some additional checks to make sure we do not indivertibly remove/replace a different entry.

#Fetch the existing entry and store its properties in the $oldentry variable
$oldentry = Get-MgUserOauth2PermissionGrant -UserId pesho@michev.info | ? {$_.ClientId -eq "728a0b66-c446-48ee-a959-f9669ee6f6d9" -and $_.Scope -match "Directory.Read.All"}

#Remove the existing entry
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $oldentry.id

#Add the updated entry by leveraging the $oldentry variable
#Make sure to update the list of scopes!
New-MgOauth2PermissionGrant -ClientId $oldentry.ClientId -PrincipalId $oldentry.PrincipalId -ResourceId $oldentry.ResourceId -ConsentType "Principal" -Scope $oldentry.Scope.Replace("Directory.Read.All","").Trim()

Here’s what the end result looks like (do note the Oauth2PermissionGrant object has a new id value!):

oAuth2PermissionGrants3

Of course, you can also use the same method to add additional permissions, instead of removing them. As mentioned above, you can use this as a workaround for scenarios where the user already has some permissions granted on the same resource and service principal combo, as the “regular” method would error out.

Another important thing to keep in mind when managing permissions is that they are NOT immediately effective. Any existing access tokens for the user must expire first before the changes in permissions are reflected. In the case of the Graph explorer tool and any other app that supports Continuous access evaluation, this can take up to 28 hours! But more on that in another article 🙂

Lastly, if you ever have a concern that the application might be malicious in nature, do not bother with the methods outlined above. Instead, remove the service principal object corresponding to the app as soon as possible and revoke access tokens for all users. Better safe than sorry!

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.