Skip to main content

Setting Managed Identity Permissions via PowerShell (No Portal Needed)

The Problem
#

Managed Identities are the recommended way to authenticate Azure resources – Azure Functions, Logic Apps, Automation Accounts – to Microsoft Graph. No secrets. No certificates. No credential rotation. The identity is managed by the platform.

The problem comes when you need to assign Graph API permissions to that identity. Microsoft added basic portal support for this in late 2023 via Enterprise Applications, but it is click-heavy, not scriptable, and gives you no audit trail. If you’re deploying infrastructure as code, managing multiple identities, or need to know what every managed identity in your tenant can do – you need PowerShell.

This is one of those scripts I run every time I deploy a new Azure Function that needs Graph access. It is short, but the alternative – figuring out the correct Az module cmdlets and the Microsoft Graph service principal’s role IDs – takes longer than it should.

Prerequisites
#

  • PowerShell 7.x with Az.Accounts and Az.Resources modules
  • Azure AD role: Global Administrator or Privileged Role Administrator
  • The Managed Identity’s Object ID (the service principal ID, not the resource ID – find it under the resource’s “Identity” blade)

Granting Permissions
#

The script takes a service principal ID and a list of permissions, looks up the Microsoft Graph service principal by its well-known AppId, and assigns only the permissions that aren’t already there. It validates that each permission name actually exists and warns you if you mistype one:

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string]$PrincipalId,

    [string[]]$GraphPermissions = @('User.Read.All')
)

Connect-AzAccount -ErrorAction Stop

# Well-known AppId for Microsoft Graph -- never changes across tenants
$graphServicePrincipal = Get-AzADServicePrincipal `
    -SearchString 'Microsoft Graph' | Select-Object -First 1

$graphAppRoles = $graphServicePrincipal.AppRole | Where-Object {
    $GraphPermissions -contains $_.Value -and
    $_.AllowedMemberType -contains 'Application'
}

# Warn about typos
$foundNames = @($graphAppRoles | ForEach-Object { $_.Value })
foreach ($requested in $GraphPermissions) {
    if ($requested -notin $foundNames) {
        Write-Warning "Permission '$requested' not found. Check the spelling."
    }
}

The -contains 'Application' filter matters. Graph permissions come in two types: delegated (user context) and application (service context). Managed identities always use application permissions.

Idempotent Assignment
#

The script checks existing assignments before adding new ones. The New-AzADServicePrincipalAppRoleAssignment call is the equivalent of granting admin consent – there is no separate consent step:

$currentPermissions = Get-AzADServicePrincipalAppRoleAssignment `
    -ServicePrincipalId $PrincipalId

foreach ($role in $graphAppRoles) {
    if ($currentPermissions.AppRoleId -notcontains $role.Id) {
        if ($PSCmdlet.ShouldProcess($role.Value, "Assign to $PrincipalId")) {
            try {
                New-AzADServicePrincipalAppRoleAssignment `
                    -ServicePrincipalId $PrincipalId `
                    -ResourceId $graphServicePrincipal.Id `
                    -AppRoleId $role.Id | Out-Null
                Write-Output "Assigned: '$($role.Value)'"
            }
            catch {
                Write-Error "Failed to assign '$($role.Value)': $($_.Exception.Message)"
            }
        }
    }
    else {
        Write-Output "Already assigned: '$($role.Value)'"
    }
}

Run with -WhatIf first to preview what would change:

.\Add-ManagedIdentityGraphPermissions.ps1 `
    -PrincipalId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
    -GraphPermissions @("User.Read.All", "Group.Read.All") `
    -WhatIf

Removing Permissions
#

The companion script reverses the process. Same interface, same idempotency – permissions that aren’t currently assigned are silently skipped:

.\Remove-ManagedIdentityGraphPermissions.ps1 `
    -PrincipalId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
    -GraphPermissions @("User.Read.All") `
    -WhatIf

You need this when decommissioning a function app, cleaning up after testing, or when a security review flags over-permissioned identities. Without a removal script, the blog would be saying “grant permissions forever” – and that’s not the message.

Auditing All Managed Identities
#

The question you’ll get asked in every security review: “What can each managed identity in this tenant actually do?” There is no portal dashboard for this. Here is how you answer it:

$graphSP = Get-AzADServicePrincipal `
    -SearchString 'Microsoft Graph' | Select-Object -First 1
$roleMap = @{}
$graphSP.AppRole | ForEach-Object { $roleMap[$_.Id] = $_.Value }

Get-AzADServicePrincipal -Filter "servicePrincipalType eq 'ManagedIdentity'" |
    ForEach-Object {
        $sp = $_
        Get-AzADServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id |
            ForEach-Object {
                [PSCustomObject]@{
                    ManagedIdentity = $sp.DisplayName
                    Permission      = $roleMap[$_.AppRoleId]
                    AssignedDate    = $_.CreatedDateTime
                }
            }
    } | Format-Table -AutoSize

Run this before and after any permission changes. Keep the output. When someone asks “who has User.ReadWrite.All?” you want the answer in ten seconds, not ten minutes.

Security Considerations
#

Permissions assigned this way are hard to find in the portal. They don’t show up in the “API permissions” blade like app registrations do. You can see them under Enterprise Applications > [the MI] > Permissions, but that requires knowing which identity to look at. If you’re handing this off to someone else or coming back six months later, the audit script above is your documentation.

Application permissions like User.Read.All cover every user in the tenant. There’s no scope limiting. Grant what you need and nothing else – not because “least privilege” is a principle, but because you’ll forget what you granted, and the blast radius of a compromised function app is proportional to what you gave it.

This same approach works for other Microsoft first-party APIs – Defender for Endpoint, SharePoint, Exchange Online. Just change the target service principal AppId.

When to Use This / When Not To
#

Use this every time you deploy an Azure Function, Logic App, or Automation Account that needs to call Microsoft Graph with application permissions via a managed identity.

Do not use this for delegated permissions (user context flows) – those require an app registration with a configured redirect URI. If your resource uses a user-assigned managed identity, verify that the service principal ID matches the user-assigned identity, not the system-assigned one.

GitHub
#