Using the Microsoft Graph PowerShell module with GDAP

Overview

GDAP (Granular Delegated Admin Privileges) improves security over DAP, but adds complexity. The goal of this post is to show you how to use Microsoft Graph PowerShell against your partner tenants. Unlike other guides you may have seen, I'll be avoiding the PartnerCenter module and using MSAL.PS for authentication. Unfortunately, the PartnerCenter has some issues in combination with the Microsoft Graph module and hasn't had any updates since 2020. Token storage for automation is not covered.

Walkthrough

Create your Partner Application

Start by creating your partner application. In Azure AD:

  1. Create a multi-tenant App Registration. Name it something like "Partner App".
  2. Add delegated permissions for Microsoft Partner Center > user_impersonation.
  3. Under Authentication, add Mobile and Desktop Applications and then add https://localhost as a Customer Redirect URI.

Install PowerShell 7 (optional with tweaks)

Next, for this guide, I'm going to recommend you install the latest version of PowerShell 7. This is solely because I use the Microsoft.PowerShell.ConsoleGuiTools module to select tenants. If you want to adjust that part to get your customer(s) as different way, you can continue to use Windows PowerShell 5.1.

Install Modules

After you do that (or adjust the scripts), install the following modules:

Install-Module Microsoft.Graph -Scope CurrentUser
Install-Module MSAL.PS -Scope CurrentUser
Install-Module Microsoft.PowerShell.ConsoleGuiTools -Scope CurrentUser
Install-Module ExchangeOnlineManagement -Scope CurrentUser

Run a script to consent your app and permissions to tenant(s)

Copy the following script somewhere. This script is what we'll use to consent the Microsoft Graph and Exchange Online 1st party Enterprise Application on behalf of our tenants. When we run this, I believe you'll need to do so from an account that is in a group that has the Global Administrator and Application Administrator GDAP role.

#Requires -Version 7.2
#Requires -Modules MSAL.PS, Microsoft.PowerShell.ConsoleGuiTools

param(
    [Parameter(
        Mandatory = $true, 
        HelpMessage = @"
Create an App registration with the following:
- For permissions, added Microsoft Partner Center > user_impersonation as a delegated permission
- Under Authentication, add Mobile and Device Applications and add https://localhost as a Custom Redirect URI

Use the Application ID for this parameter.
"@)]
    [string] $PartnerAppId,

    [Parameter(
        HelpMessage = @"
This is the list of scopes you'll need for your script or automation. Example:

Directory.Read.All,AccessReview.Read.All
"@)]
    [string] $Scope
)

$ErrorActionPreference = "stop"

$MsalParams = @{
    ClientId    = $PartnerAppId;
    Interactive = $true;
    Scope       = "https://api.partnercenter.microsoft.com/user_impersonation";
}

$PartnerAccessToken = Get-MsalToken @MsalParams

$PartnerCustomersParams = @{
    Uri     = "https://api.partnercenter.microsoft.com/v1/customers";
    Headers = @{
        "Authorization" = "Bearer $($PartnerAccessToken.AccessToken)"
    };
    Method  = "Get";
}

$SelectedCustomers = (Invoke-RestMethod @PartnerCustomersParams).items | `
    Select-Object -ExpandProperty companyProfile | `
    Select-Object -Property companyName, tenantId | `
    Out-ConsoleGridView -OutputMode Multiple -Title "Select your tenant"

<#
    This step is what will add the application to your customer's organization.
    The EnterpriseAppplicationId below is the app you're allowing to use your partner credentials.
    In this example, it's just the Microsoft Graph enterprise application.
    Notice how the scope isn't in our Partner App. You don't need to specify it in the partner app.
    If you change the scope, it should add the ones you didn't include, but I don't see a way to remove any.
    Your user account's permissions through GDAP are what determines what you really have permissions to so if your user
    didn't have Directory.Read.All permissions, this wouldn't work.

    Since there's no way to check or force this to update, it will throw an error-like message if you've already done this.
#>

# Forcing a scope selection from the list if one wasn't specified.
if (!$Scope) {
    $SelectedScopes = Find-MgGraphPermission | `
        Where-Object PermissionType -eq "Delegated" | `
        Select-Object Name, Description | `
        Out-ConsoleGridView -OutputMode Multiple -Title "Select your scopes"

    $Scope = $SelectedScopes.Name -Join ","

    if (!$Scope) {
        throw "Selecting a scope(s) is required to continue."
    }
}

$AppConsentHeaders = @{
    "Accept"        = "application/json";
    "Content-Type"  = "application/json";
    "Authorization" = "Bearer $($PartnerAccessToken.AccessToken)"
}

$AppConsentBody = ConvertTo-JSON -InputObject @{
    "applicationid"     = $PartnerAppId;
    "applicationGrants" = @(
        @{
            "EnterpriseApplicationId" = "00000003-0000-0000-c000-000000000000"; # Microsoft Graph.
            "Scope"                   = $Scope
        },
        @{
            "EnterpriseApplicationId" = "00000002-0000-0ff1-ce00-000000000000"; # Exchange Online.
            "Scope"                   = "Exchange.Manage"
        }
    )
}

$Index = 0

ForEach ($Customer in $SelectedCustomers) {
    $Index++
    $PercentComplete = [int][math]::Ceiling(100 / $SelectedCustomers.Count * $Index ) 

    Write-Progress `
        -Activity $Customer.companyName `
        -Status "$Index out of $($SelectedCustomers.Count)" `
        -PercentComplete $PercentComplete

    $CustomerTenantId = $Customer.tenantId

    # Removing existing consent if it exists. There isn't a way to query or updates, unfortunately.
    $PartnerCustomerRemoveAppConsentParams = @{
        Uri     = "https://api.partnercenter.microsoft.com/v1/customers/$CustomerTenantId/applicationconsents/$PartnerAppId";
        Headers = $AppConsentHeaders;
        Method  = "DELETE";
    }

    Invoke-RestMethod @PartnerCustomerRemoveAppConsentParams

    # Adding new consents.
    $PartnerCustomerPostAppConsentParams = @{
        Uri     = "https://api.partnercenter.microsoft.com/v1/customers/$CustomerTenantId/applicationconsents";
        Headers = $AppConsentHeaders;
        Body    = $AppConsentBody
        Method  = "POST";
    }

    Invoke-RestMethod @PartnerCustomerPostAppConsentParams | Out-Null
}

Go ahead and run this script using the Application ID you created earlier for $PartnerAppId and a comma-separated list of scopes that you're comfortable with. If you're just testing, Directory.Read.All should be fine. You'll also have the option to select a single or multiple tenants to do this with.If you saved the script as Update-PartnerAppConsent.ps, you'd run:

.\Update-PartnerAppConsent.ps -ApplicationID "{YOUR_PARTNER_APP_ID}" -Scope "Directory.Read.All"

If you're okay with adding the full list of scopes, you should restrict this to any scope anyone in your organization will need. Their permissions will be restricted by GDAP, so they won't be able to get to anything if their role doesn't allow it. By default, the only one pre-included is for Exchange Online.

Example Script: Get users and mailboxes for a tenant

Copy the following script to your system. We'll use this for testing getting a list of a tenant's users along with their mailboxes.

#Requires -Version 7.2
#Requires -Modules MSAL.PS, Microsoft.PowerShell.ConsoleGuiTools, ExchangeOnlineManagement

$ErrorActionPreference = "stop"

$PartnerAppId = "{PartnerAppId}"

$MsalParams = @{
    ClientId    = $PartnerAppId;
    Interactive = $true;
    Scope       = "https://api.partnercenter.microsoft.com/user_impersonation";
}

$PartnerAccessToken = Get-MsalToken @MsalParams

$PartnerCustomersParams = @{
    Uri     = "https://api.partnercenter.microsoft.com/v1/customers";
    Headers = @{
        "Authorization" = "Bearer $($PartnerAccessToken.AccessToken)"
    };
    Method  = "Get";
}

# Use this cool module to select a single customer from the list.

$Customer = (Invoke-RestMethod @PartnerCustomersParams).items | `
    Select-Object -ExpandProperty companyProfile | `
    Select-Object -Property companyName, tenantId | `
    Out-ConsoleGridView -OutputMode Single -Title "Select your tenant"

# Get tokens for Exchange Online and Microsoft Graph

$MsalCustomerGraphParams = @{
    ClientId = $PartnerAppId;
    TenantId = $Customer.tenantId;
    Scope    = "Directory.Read.All"; # Change this depending on what you're doing below
    Silent   = $true; # Uses the saved refresh token
}

$MsalCustomerEXOParams = @{
    ClientId = $PartnerAppId;
    TenantId = $Customer.tenantId;
    Scope    = "https://outlook.office365.com/.default";
    Silent   = $true; # Uses the saved refresh token
}

$CustomerGraphAccessToken = Get-MsalToken @MsalCustomerGraphParams
$CustomerEXOAccessToken = Get-MsalToken @MsalCustomerEXOParams

# Connect to Exchange Online and Microsoft Graph and run commands

Connect-ExchangeOnline -DelegatedOrganization $Customer.tenantId -AccessToken $CustomerEXOAccessToken.AccessToken
Connect-MgGraph -AccessToken $CustomerGraphAccessToken.AccessToken

Get-MgUser
Get-ExoMailbox

The only two things you need now to make this work are:

  1. Replace the PartnerAppId value in the script above with your actual Application ID. I didn't use a variable in this case since it's not sensitive and I can imagine you'd want to create scripts that can be ran as-is by your employees.
  2. Update your $MsalCustomerGraphParams scope to whatever is relevant to the actual thing you're trying to do, then replace everything below Connect-MgGraph with what you actually want to do.

Resources

This blog post from tminus365 is a great reference for how to design your GDAP groups. It's important to have this exactly the way you want so you don't have to redo any of it later. I used their post in combination with the following to decide on what roles I needed:

The main difference for us was we decided Global Reader would be appropriate for all of our staff. I imagine there are certain types of customers where this wouldn't be appropriate, but it works for us.

Another thing that isn't clearly documented anywhere I can find is what Azure AD roles map to what Microsoft Graph scopes. Fortunately, I haven't had a hard time guessing based on the descriptions, but it is a huge pain if you're trying to run something quickly.

Another resource I found was this, which explains how to connect to the new Exchange Online module with GDAP. The sample got me started, even though I ultimately ended up using the APIs directly.