One App Registration, a Thousand Tenants: A Reference Architecture for GDAP Automation
TL;DR
- Under GDAP, adding a new Graph API permission to an app registration is not enough; the role that authorizes it must also exist in every customer's GDAP access assignment, or the API calls fail.
- The Graph delegatedAdminRelationships APIs let you enumerate every active GDAP relationship and patch the access assignment for your service principal's security group in one scripted pass.
- If the role you need was never included in the GDAP relationship itself, no script can save you: a new relationship must be established with each customer one by one.
- After changing app registration permissions, CPV consent must be revoked and re-granted across customers, because tenants are only pre-approved for the original permission list.
- Assigning Global Administrator to every access assignment makes the failures disappear and defeats the entire security purpose of GDAP.
Least privilege under GDAP has a maintenance cost that nobody mentions during migration: your permissions are now enforced in two places. The app registration defines what your automation may ask for, and each customer's GDAP access assignment defines what it may actually do. Widen one without the other and your API calls start failing across the customer base. GDAP effectively broke a lot of MSP and vendor automation for exactly this reason, and there are earlier write-ups on getting automation working across tenants with GDAP in the first place.
This post covers the other half of the architecture: keeping it working as your permission needs grow. The script below updates the access assignments across all of your GDAP relationships in one pass, and it applies to both MSPs and vendors that integrate with Microsoft.
Why least privilege breaks your automation over time
The setup every MSP or vendor wants: an app registration scoped to the minimum permissions the automation needs today. The setup reality: scope grows. And if the Global Administrator role is not assigned to the group holding your service principal, new permissions on the app registration do nothing by themselves. A real-life sequence:
- You have an existing app registration consented across all of your customers.
- Each customer has a GDAP relationship whose access assignments are set to a handful of roles.
- You add the
Device.ReadWrite.Allpermission to your app registration. - You try to call the API with the new permission and find out you cannot.
- Investigating, you see that none of the roles in the access assignment allow writing to devices.
- You now have to add the Intune Administrator role to the GDAP access assignment for every customer.
That last line is the problem worth scripting. At 1,000+ customers, nobody has time to open each relationship in Partner Center and add a role by hand.
One boundary to understand before writing a single line: this works only when the role you need is already part of the GDAP relationship but not yet assigned to the security group where your service account lives. If the role is missing from the relationship itself, the manual work is inevitable. You will have to establish new relationships with each customer, one by one, that include the role.
The architecture in five steps
1. Get a refresh token
Prerequisite: an app registration set up for headless automation, covered step by step in this guide to calling Microsoft APIs from your automations.
2. Define your variables
The script needs two inputs:
GroupID: the security group containing the service principal you used to set up the integration. Find it in the group's properties in Entra ID.

RoleID: the ID of the role you want added to the access assignment. Look up role IDs in Microsoft Entra built-in roles (opens in new tab).
3. Confirm the app registration carries the GDAP scope
Listing GDAP relationships, reading their access assignments, and updating them requires this delegated admin permission:
DelegatedAdminRelationship.ReadWrite.All
The Graph APIs in play:
- Get GDAP relationships: List delegatedAdminRelationships (opens in new tab)
- Get access assignments: List accessAssignments (opens in new tab)
- Update access assignments: Update delegatedAdminAccessAssignment (opens in new tab)
4. Run the update script
The script prompts for secrets to acquire an access token, then for the variables above. The logic, summarized:
- Get an access token for Graph
- Get all active GDAP relationships
- Define a variable for the security group ID holding your service principal
- Define a variable for the role ID to add (role reference (opens in new tab))
- Define a blank
RelationshipIDvariable and an emptyGroupAssignmentsarray - Loop through all GDAP relationship IDs:
- Set
RelationshipIDto the ID being passed into the API - For each relationship, loop through each access assignment object
- Check whether
accessContainer.accessContainerIdequals the security group where your service principal lives - If it does, append an object holding the assignment ID and relationship ID to the
GroupAssignmentsarray
- Set
- Loop through
GroupAssignmentsand pass each into the Graph update API (opens in new tab), with the new role ID added in the request body, inside a try/catch that writes success or the failure message per customer
function Get-AccessToken {
param (
[Parameter(Mandatory=$true)]
[string]$clientID,
[Parameter(Mandatory=$true)]
[string]$clientSecret,
[Parameter(Mandatory=$true)]
[string]$tenantID = "common", # Your tenantID
[Parameter(Mandatory=$true)]
[string]$refreshToken, # Your refreshToken
[string]$scope = "https://graph.microsoft.com/.default" # Default scope for Microsoft Graph
)
# Token endpoint
$tokenUrl = "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token"
# Prepare the request body
$body = @{
client_id = $clientID
scope = $scope
client_secret = $clientSecret
grant_type = "refresh_token"
refresh_token = $refreshToken
}
# Request the token
$response = Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType "application/x-www-form-urlencoded" -Body $body
# Return the access token
return $response.access_token
}
$accessToken = Get-AccessToken
# Prompt the user for the Security Group ID
$SecurityGroup = Read-Host -Prompt "Enter the Security Group ID where your service principal is located"
# Prompt the user for the Role ID they wish to add to assignments
$RoleID = Read-Host -Prompt "Enter the Role ID you want to add to assignments"
# Initialize the RelationshipID variable with a blank value
$RelationshipID = ""
# Initialize the GroupAssignments variable as an empty array
$GroupAssignments = @()
# Define your Graph API endpoint for GDAP relationships
$gdapApiUrl = "https://graph.microsoft.com/v1.0/tenantRelationships/delegatedAdminRelationships?$filter=status eq 'active'"
# Use the existing access token for authorization
$headers = @{
Authorization = "Bearer $accessToken"
"Content-Type" = "application/json"
}
# Function to get GDAP relationships and access assignments
Function Get-GDAPAssignments {
param (
[string]$apiUrl,
[hashtable]$headers
)
Try {
# Make the API call to get the GDAP assignments
$response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Get
# Check if there are more pages of data
while ($response.'@odata.nextLink') {
# If there are more pages, update the apiUrl and perform another request
$apiUrl = $response.'@odata.nextLink'
$nextPage = Invoke-RestMethod -Uri $apiUrl -Headers $headers -Method Get
$response.value += $nextPage.value
$response.'@odata.nextLink' = $nextPage.'@odata.nextLink'
}
# Return the list of assignments
return $response.value
} Catch {
Write-Error "Error fetching GDAP assignments: $_"
}
}
# Call the function to get GDAP assignments
$activeGDAPRelationships = Get-GDAPAssignments -apiUrl $gdapApiUrl -headers $headers
# Display the assignments (for verification)
$activeGDAPRelationships | Format-Table
# Assume $gdapRelationships is the object containing all the GDAP relationships obtained from a previous API call
foreach ($gdapRelationship in $activeGDAPRelationships) {
# Set the RelationshipID to the current GDAP Relationship ID
$RelationshipID = $gdapRelationship.id
# Store the customer's display name
$customerDisplayName = $gdapRelationship.customer.displayName
# Form the URI for the API call
$uri = "https://graph.microsoft.com/v1.0/tenantRelationships/delegatedAdminRelationships/$RelationshipID/accessAssignments"
# Make the API call
$response = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $uri -Method Get
# Check if any access assignments match the Security Group ID
foreach ($assignment in $response.value) {
if ($assignment.accessContainer.accessContainerId -eq $SecurityGroup) {
$roleDefinitionIds = @()
# Create an array to hold all roleDefinitionIds including the new one
$roleDefinitionIds += $assignment.accessDetails.unifiedRoles | ForEach-Object { $_.roleDefinitionId }
# Add the new RoleID to the array of roleDefinitionIds
$roleDefinitionIds += $RoleID
# Create a new object with the assignment ID and Relationship ID
$assignmentObject = [PSCustomObject]@{
AssignmentID = $assignment.id
RelationshipID = $RelationshipID
etag = $assignment.'@odata.etag'
CustomerName = $customerDisplayName
RoleDefinitionIds = $roleDefinitionIds
}
# Add the object to the GroupAssignments array
$GroupAssignments += $assignmentObject
}
}
}
# Now $GroupAssignments array contains the assignments that match the Security Group
# Loop through each assignment in the GroupAssignments array
foreach ($groupAssignment in $GroupAssignments) {
try {
$headers = @{
Authorization = "Bearer $accessToken"
'If-Match' = $groupAssignment.ETag
'Content-Type' = 'application/json'
}
$updateBody = @{
accessDetails = @{
unifiedRoles = $groupAssignment.RoleDefinitionIds | ForEach-Object {
@{ roleDefinitionId = $_ }
}
}
} | ConvertTo-Json -Depth 5
$uri = "https://graph.microsoft.com/v1.0/tenantRelationships/delegatedAdminRelationships/$($groupAssignment.RelationshipID)/accessAssignments/$($groupAssignment.AssignmentID)"
# Execute the PATCH request
$response = Invoke-RestMethod -Headers $headers -Method PATCH -Uri $uri -Body $updateBody
Write-Host "Success for $($groupAssignment.CustomerName) $($groupAssignment.AssignmentID)" -ForegroundColor Green
} catch {
Write-Host "Failed for $($groupAssignment.CustomerName) $($groupAssignment.AssignmentID): $($_.Exception.Message) check to see if this role is available as part of the GDAP relationship" -ForegroundColor Red
}
}The output reports success or failure per tenant. A failure most likely means the role is not part of that customer's existing GDAP relationship, the boundary case called out earlier.

5. Revoke and re-add CPV consent
Adding a new API permission to the app registration and a new role to the GDAP access assignments is still not the end. Customer tenants are only pre-approved for the original list of permissions, so consent has to be revoked and granted again with the new scope.
5a. Get an access token for Partner Center
# Define variables
$appId = 'your Azure AD application client ID'
$appSecret = 'your Azure AD application client secret'
$tenantId = 'your Azure AD tenant ID'
$scope = 'https://api.partnercenter.microsoft.com/.default'
$redirectUri = 'your redirect URI'
# Construct authorization endpoint URL
$authEndpoint = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?client_id=$appId&response_type=code&redirect_uri=$redirectUri&scope=$scope"
# Navigate to authorization endpoint and obtain authorization code
Start-Process $authEndpoint
$code = Read-Host "Enter authorization code"
$body = "grant_type=authorization_code&client_id=$appId&client_secret=$appSecret&code=$code&redirect_uri=$redirectUri&scope=$scope"
$headers = @{ 'Content-Type' = 'application/x-www-form-urlencoded' }
$tokenEndpoint = "https://login.microsoftonline.com/$tenantId/oauth2/token"
$response = Invoke-RestMethod -Method POST -Uri $tokenEndpoint -Body $body -Headers $headers
$AccessToken = $response.Access_Token5b. Revoke consent across your customers. Loop through all of your customers (the same GDAP relationship API gives you the tenant IDs) and call the removal API against the existing app ID (ClientID): Remove consent (opens in new tab)
5c. Re-add consent with the new scope. Modify the body below with the list of scopes you want to consent to on behalf of your customers:
# Define variables
$appId = 'your Azure AD application client ID'
$appSecret = 'your Azure AD application client secret'
$tenantId = 'your Azure AD tenant ID'
$scope = 'https://api.partnercenter.microsoft.com/.default'
$redirectUri = 'your redirect URI'
# Construct authorization endpoint URL
$authEndpoint = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?client_id=$appId&response_type=code&redirect_uri=$redirectUri&scope=$scope"
# Navigate to authorization endpoint and obtain authorization code
Start-Process $authEndpoint
$code = Read-Host "Enter authorization code"
$body = "grant_type=authorization_code&client_id=$appId&client_secret=$appSecret&code=$code&redirect_uri=$redirectUri&scope=$scope"
$headers = @{ 'Content-Type' = 'application/x-www-form-urlencoded' }
$response = Invoke-RestMethod -Method POST -Uri $tokenEndpoint -Body $body -Headers $headers
$AccessToken = $response.AccessToken
$CustomerTenantId = 'Your Customer TenantID'
# Get list of customers
$uri = "https://api.partnercenter.microsoft.com/v1/customers"
$headers = @{
Authorization = "Bearer $($AccessToken)"
'Accept' = 'application/json'
}
$Customers = Invoke-RestMethod -Uri $uri -Headers $headers
# Loop through each customer and run the existing script
foreach ($Customer in $Customers.value) {
$CustomerTenantId = $Customer.id
# Consent to required applications
$uri = "https://api.partnercenter.microsoft.com/v1/customers/$CustomerTenantId/applicationconsents"
$body = @{
applicationGrants = @(
@{
enterpriseApplicationId = "00000003-0000-0000-c000-000000000000"
scope = "Directory.Read.All,Directory.AccessAsUser.All"
},
@{
enterpriseApplicationId = "00000002-0000-0ff1-ce00-000000000000"
scope = "Exchange.Manage"
}
)
applicationId = $AppId
displayName = $AppDisplayName
} | ConvertTo-Json
Invoke-RestMethod -Uri $uri -Headers $headers -Method POST -Body $body -ContentType 'application/json'
}The shortcut that defeats the purpose
There is an obvious way to never run any of this: assign Global Administrator to every access assignment used by your automation. It works, and it quietly turns GDAP back into the standing, all-powerful access that DAP was retired for. Keeping assignments least privilege and scripting the role updates is the version of this architecture that survives a security review. Happy scripting.
Frequently asked questions
Why do API calls fail after adding a permission to a GDAP app registration?
The permission exists on the app registration, but the GDAP access assignment for the security group holding your service principal does not include an Entra role that authorizes the action. Both layers must agree before the call succeeds.
What Graph permission does the GDAP update script itself need?
DelegatedAdminRelationship.ReadWrite.All as a delegated permission, which covers listing GDAP relationships, listing access assignments, and updating them.
What does a failure from the update script usually mean?
Most often the role you are adding is not part of that customer's underlying GDAP relationship. Assignments can only reference roles the relationship already contains, so those customers need a new relationship established that includes the role.
Automation across tenants, without the scaffolding
CloudCapsule runs its multi-tenant assessment and remediation through this exact least-privilege model, so you get 250+ controls checked per tenant without maintaining the scripts yourself.
Book a demo
Written by
Nick Ross
CEO · Microsoft MVP · Founder, T-Minus 365
Nick is not just a CEO, he's a respected thought leader and influencer in the MSP space. Tens of thousands of MSPs learn through his YouTube channel, T-Minus365. Nick has been honored as a three-time Microsoft MVP for his educational content; his expertise and influence are the backbone of our mission, ensuring that you are in the best hands when it comes to security.
Nick joined Pax8 in 2017, where he would ultimately oversee product management for PSA and Microsoft integrations. Following his tenure at Pax8, Nick has continued to demonstrate his leadership prowess as an executive at various MSPs, culminating in his most recent role at Sourcepass.
Nick holds a Bachelor's Degree in Business Management from Florida State University, as well as a Minor Degree in Entrepreneurship. In his free time, Nick is an avid hiker, reader, and fitness-junkie.


