Search code examples
azureazure-active-directoryazure-ad-graph-apiazure-application-registration

Custom Azure AD Role or Azure Policy to allow only Microsoft Graph User.Read.All permission to Azure Service Prinicpal


Use Case - Automate assigning Microsoft Graphs's User.Read.All permission to App Registration/Service Principal using DevOps pipeline. So that applications can read user profiles. Challenge To grant Microsoft Graphs's User.Read.All permission, service principal under which pipeline will run, requires Global Administrator or Privileged Role Administrator. https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/grant-admin-consent?pivots=portal . The problem is if grant any of these roles, service principal can be used to assign more than Microsoft Graphs's User.Read.All permission. I am looking for a way where I can restrict service principal with Global Administrator or Privileged Role Administrator or Custom Role to allow assigning only User.Read.All permission, so that service principal cannot be misused.


Solution

  • Yes, this is possible.

    As you've noted, the directory roles Global Administrator and Privileged Role Administrator allow granting any Microsoft Graph permissions. Likewise, the Microsoft Graph app roles (application permissions) DelegatedPermissionGrant.ReadWrite.All and AppRoleAssignment.ReadWrite.All also allow granting any delegated permission, or app role, respectively. So, neither of those options are what you're looking for.

    Instead, what you want to do is the following:

    1. Create a permission grant policy that has the condition "only the User.Read.All permission".
    2. Create a custom directory role that includes the permission to grant permissions, subject to the policy we created in step 1.
    3. Assign the custom directory role we created in step 2, to your automation's service principal.

    The following sections show how to do this in more detail.

    0. Setup

    Here I'm using the Microsoft Graph PowerShell module, though you could do this with Microsoft Graph directly, of course. You'll need the Microsoft.Graph.Applications, Microsoft.Graph.Identity.SignIns, and Microsoft.Graph.Identity.Governance modules. If you don't have them yet, you can install them with:

    Install-Module Microsoft.Graph.Applications, Microsoft.Graph.Identity.SignIns, Microsoft.Graph.Identity.Governance -Scope CurrentUser
    

    Now, connect to Microsoft Graph PowerShell with the required permissions. We're doing something very high privilege (creating and assigning directory roles), so the permissions here are all very privileged, and the user running these cmdlets will also need to be very privileged (Global Admin or Privileged Role Admin):

    Connect-MgGraph -Scopes @(
        "Policy.ReadWrite.PermissionGrant"   # For creating a permission grant policy
        "RoleManagement.ReadWrite.Directory" # For creating and assigning a directory role
        "Application.Read.All"               # For retrieving service principals
    )
    

    1. Create a permission grant policy

    We'll start by creating a custom permisison grant policy with two "includes" condition sets: the Microsoft Graph app role (application permission) User.Read.All, and the Microsoft Graph delegated permission User.Read.All. (If you only wanted one or the other, then just skip the one you don't want.)

    # We'll need the Microsoft Graph app ID, and the the permission IDs for "User.Read.All",
    # both of which which we can find on the Microsoft Graph service principal.
    $graph = Get-MgServicePrincipal -Filter "servicePrincipalNames/any(n:n eq 'https://graph.microsoft.com')"
    $userReadAllScope = $graph.Oauth2PermissionScopes | ? { $_.Value -eq "User.Read.All" }
    $userReadAllAppRole = $graph.AppRoles | ? { $_.Value -eq "User.Read.All" }
    
    # Create a new permission grant policy
    $policy = New-MgPolicyPermissionGrantPolicy `
        -Id "only-user-read-all" `
        -DisplayName "Only Microsoft Graph's User.Read.All" `
        -Description "Includes app-only and delegated Microsoft Graph User.Read.All, for any client app"
    
    # Add an "includes" condition for the Graph delegated permission User.Read.All
    New-MgPolicyPermissionGrantPolicyInclude `
        -PermissionGrantPolicyId $policy.Id `
        -PermissionType "delegated" `
        -ResourceApplication $graph.AppId `
        -Permissions @($userReadAllScope.Id) `
        | Out-Null
        
    # Add another "includes" condition set for the Graph app role User.Read.All
    New-MgPolicyPermissionGrantPolicyInclude `
        -PermissionGrantPolicyId $policy.Id `
        -PermissionType "application" `
        -ResourceApplication $graph.AppId `
        -Permissions @($userReadAllAppRole.Id) `
        | Out-Null
    

    With the steps above, we have a permission grant policy that says: "Include the Microsoft Graph delegated permission User.Read.All, and the Microsoft Graph application permission (app role) User.Read.All, for any client application."

    You could further limit the conditions if you wanted to. For example, if you only expect the automation to be granting permissions for apps registered in your tenant, you could use clientApplicationTenantIds to include that constraint. See the different options available in permissionGrantConditionSet.

    2. Create a custom directory role

    The permission grant policy by itself does not do anything. It only comes into effect when it is included as part of the permission to grant permissions to apps, for example, as part of a permission in a custom directory role.

    Here's we'll create a custom directory role, and include in that custom role definition two resource actions:

    1. microsoft.directory/servicePrincipals/allProperties/read: The permission to list service principals. Your automation service will probably need to be able to do this, so you can go ahead and include it here. (Alternatively, you could grant your automation service Application.Read.All, but it might be simpler to do everything with a directory role.) To learn more, see Enterprise app permissions
    2. microsoft.directory/servicePrincipals/managePermissionGrantsForAll.{id}: The permission to grant permissions, tenant-wide and on behalf of all, subject to the constraints that you defined in a permission grant policy. In the previous step we created a permission grant policy with ID "only-user-read-all", so here you'll be using microsoft.directory/servicePrincipals/managePermissionGrantsForAll.only-user-read-all. To learn more, see App consent permissions
    # Create a custom directory role with the permission to list service principals, and with the
    # permission to grant permissions to an app, subject to the policy we created earlier.
    $roleDefinition = New-MgRoleManagementDirectoryRoleDefinition `
        -DisplayName "User.Read.All Grantors" `
        -Description "Can grant delegated and app-only User.Read.All to any app" `
        -IsEnabled `
        -RolePermissions @{
            "AllowedResourceActions" = @(
                "microsoft.directory/servicePrincipals/allProperties/read"
                "microsoft.directory/servicePrincipals/managePermissionGrantsForAll.$($policy.Id)"
            )
        }
    

    3. Assign the custom directory role to the automation service

    We now have a custom role that only allows granting User.Read.All. The remaining step is to assign that role to whoever or whatever needs that privilege. You could assign this to a user (and they'd be able to grant admin consent for User.Read.All), but in this scenario you want to assign it to your automation service's service principal.

    # Retrieve the automation service's service principal
    $automationAppId = "{automation-service-app-id}"
    $automationService = Get-MgServicePrincipal -Filter "appId eq '$($automationAppId)'"
    
    # Assign the custom role we created to the automation's service principal
    $assignment = New-MgRoleManagementDirectoryRoleAssignment `
        -RoleDefinitionId $roleDefinition.Id `
        -PrincipalId $automationService.Id `
        -DirectoryScopeId "/"
    

    4. Test it out

    In my examples below, I'm still using Microsoft Graph PowerShell, connecting as a service principal to which I assigned the custom role as in the previous steps. In your scenario, you might be using the Microsoft Graph SDK to call Microsoft Graph directly.

    • Granting delegated User.Read.All should succeed:
      > New-MgOauth2PermissionGrant -ResourceId $graph.Id -Scope "User.Read.All" -ConsentType "AllPrincipals" -ClientId $automationService.Id
      
      ClientId             : 4c6e6be6-5ce4-472e-b9f2-3eda54ac883e
      ConsentType          : AllPrincipals
      ...
      
    • Granting any other delegated permission should fail:
      > New-MgOauth2PermissionGrant -ResourceId $graph.Id -Scope "Directory.Read.All" -ConsentType "AllPrincipals" -ClientId $automationService.Id
      
      New-MgOauth2PermissionGrant : Insufficient privileges to complete the operation.
      Status: 403 (Forbidden)
      ErrorCode: Authorization_RequestDenied
      ...
      
    • Granting the User.Read.All app role should succeed:
      > New-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $graph.Id -ResourceId $graph.Id -PrincipalId $automationService.Id -AppRoleId $userReadAllAppRole.Id
      
      AppRoleId            : df021288-bdef-4463-88db-98f22de89214
      CreatedDateTime      : 8/29/2023 1:49:53 PM
      ...
      
    • And granting any other app role should fail:
      > New-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $graph.Id -ResourceId $graph.Id -PrincipalId $automationService.Id -AppRoleId $graph.AppRoles[0].Id
      
      New-MgServicePrincipalAppRoleAssignedTo : Insufficient privileges to complete the operation.
      Status: 403 (Forbidden)
      ErrorCode: Authorization_RequestDenied
      ...