Microsoft Can Add a Graph Permission Tomorrow and Nobody Will Email You

Microsoft Can Add a Graph Permission Tomorrow and Nobody Will Email You

Can an app actually gain a new permission without me consenting again?

In the wrong configuration, yes. Not silently in the sense of an app rewriting its own grants — but the moving part most admins miss is the catalogue. Microsoft publishes new Graph permissions on its own cadence. The moment a new scope exists, it can be requested by an app registration, and if your consent settings allow users to consent to permissions classified as “low risk,” a brand-new scope Microsoft shipped last week can be consented to this week by someone in Marketing who wanted a to-do list integration.

The permission didn’t sneak in. It just appeared in a list you weren’t watching, and your policy made a decision about it before you did.

Where does the list of permissions actually come from?

The Microsoft Graph service principal in your own tenant. Every tenant carries a service principal for the first-party Microsoft Graph app — application ID 00000003-0000-0000-c000-000000000000 — and that object holds the full permission catalogue as two properties: AppRoles for application permissions and Oauth2PermissionScopes for delegated permissions. That’s it. No special endpoint, no preview feature. The catalogue you can consent to is sitting in an object you can already read with Application.Read.All.

This is the elegant part of the Office365itpros method published on 31 August: you don’t scrape documentation or trust a marketing changelog. You read the same object the consent engine reads.

Does this catch both delegated and application permissions?

Yes, and you want both. Delegated permissions matter for user-consent risk; application permissions matter because those are the ones that run headless, unattended, with no user in the loop to notice something’s off. Read them together so a new application permission — the more dangerous class — never hides behind a quieter delegated one.

audit.ps1PowerShell
Connect-MgGraph -Scopes 'Application.Read.All' -NoWelcome$graphAppId = '00000003-0000-0000-c000-000000000000'try {
    $graphSp = Get-MgServicePrincipal -Filter "appId eq '$graphAppId'" -ErrorAction Stop
}
catch {
    Write-Error "Could not read the Microsoft Graph service principal: $($_.Exception.Message)"
    return
}$appPermissions = $graphSp.AppRoles | ForEach-Object {
    [pscustomobject]@{
        Type  = 'Application'
        Id    = $_.Id
        Value = $_.Value
        Name  = $_.DisplayName
    }
}$delegatedPermissions = $graphSp.Oauth2PermissionScopes | ForEach-Object {
    [pscustomobject]@{
        Type  = 'Delegated'
        Id    = $_.Id
        Value = $_.Value
        Name  = $_.AdminConsentDisplayName
    }
}$currentPermissions = @($appPermissions) + @($delegatedPermissions)
Write-Host "Retrieved $($currentPermissions.Count) permissions from the Graph service principal."

One connection, one filtered read, no pagination needed here because it’s a single service principal object. Pagination matters at the next stage, when you compare against apps that already hold these grants — more on that below.

How do I snapshot today’s permissions as a baseline?

Write the current set once, review it with your own eyes, then treat it as the reference point. The comparison is only as trustworthy as the day-one snapshot, so don’t automate the baseline creation and the comparison in the same unattended run. Generate the baseline interactively, eyeball it, and commit it deliberately.

audit.ps1PowerShell
# Run this ONCE, interactively, to establish the reviewed baseline.
$baselinePath = ".\graph-permissions-baseline.csv"if (Test-Path $baselinePath) {
    Write-Warning "Baseline already exists at $baselinePath. Refusing to overwrite."
    return
}$currentPermissions |
    Sort-Object Type, Value |
    Export-Csv -Path $baselinePath -NoTypeInformation -Encoding UTF8Write-Host "Baseline written. Review $baselinePath before trusting any comparison."

Why store it in SharePoint instead of a file on the runbook server?

Durability and shared visibility. A CSV on whatever VM ran the last job is a single point of failure and nobody else can see it. A SharePoint Online list gives you version history, a place the whole team can look, and an object your Automation account or Logic App can read and write with the same Graph token you’re already using. The Office365itpros approach parks the baseline in SharePoint Online for exactly this reason — the record outlives the machine that made it.

When you write items into a list, treat it as a state-changing operation and gate it behind -WhatIf so a dry run shows you what would be added before anything is.

audit.ps1PowerShell
function Add-PermissionToList {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] [string] $SiteId,
        [Parameter(Mandatory)] [string] $ListId,
        [Parameter(Mandatory)] [object] $Permission
    )$fields = @{
        Title           = $Permission.Value
        PermissionType  = $Permission.Type
        PermissionId    = $Permission.Id
        DisplayName     = $Permission.Name
        FirstSeen       = (Get-Date).ToString('o')
    }if ($PSCmdlet.ShouldProcess($Permission.Value, "Add to SharePoint list")) {
        try {
            New-MgSiteListItem -SiteId $SiteId -ListId $ListId `
                -BodyParameter @{ fields = $fields } -ErrorAction Stop | Out-Null
        }
        catch {
            Write-Error "Failed to add $($Permission.Value): $($_.Exception.Message)"
        }
    }
}

Run it with -WhatIf first. Every time. The point of this whole exercise is to reduce surprises, not to introduce a new script that surprises you.

How does the comparison flag what’s new?

Compare-Object against the reviewed baseline, keyed on the permission ID rather than the display name — Microsoft changes display strings occasionally, but the GUID is stable.

audit.ps1PowerShell
$baseline = Import-Csv -Path $baselinePath$newPermissions = Compare-Object -ReferenceObject $baseline `
    -DifferenceObject $currentPermissions `
    -Property Id -PassThru |
    Where-Object SideIndicator -eq '=>'if (-not $newPermissions) {
    Write-Host "No new Graph permissions since the last baseline."
}
else {
    Write-Warning "$($newPermissions.Count) new permission(s) detected:"
    $newPermissions | Format-Table Type, Value, Name -AutoSize
}

The => side indicator is what appeared in the live catalogue but not in your baseline — the net-new. Anything on the <= side would be a permission that vanished, which is rarer and worth a separate look if it ever happens.

Which of my apps already hold the new permission?

This is the question the baseline exists to answer, and it’s where pagination stops being optional. Once you know a new permission ID, sweep the app role assignments across your tenant to see who already has it. A large tenant has thousands of service principals; read them with -All so you don’t silently miss half of them on page two.

audit.ps1PowerShell
# For each newly-discovered APPLICATION permission, find who holds it.
foreach ($perm in ($newPermissions | Where-Object Type -eq 'Application')) {Write-Host "Checking grants for: $($perm.Value)"try {
        $assignments = Get-MgServicePrincipalAppRoleAssignedTo `
            -ServicePrincipalId $graphSp.Id -All -ErrorAction Stop
    }
    catch {
        Write-Error "Could not enumerate assignments: $($_.Exception.Message)"
        continue
    }$assignments |
        Where-Object AppRoleId -eq $perm.Id |
        Select-Object PrincipalDisplayName, PrincipalId |
        Format-Table -AutoSize
}

If that returns nothing, good — the permission is new to the catalogue and no app has grabbed it yet. That’s the window you want to be operating in.

How often should I run this?

Weekly is the honest answer. Microsoft doesn’t tie Graph permission additions to Patch Tuesday, so anchoring the check to the second Tuesday of the month buys you a false sense of rhythm and up to four weeks of blind spot. A weekly scheduled run — Azure Automation, a Function on a timer, or a Logic App — keeps the lag short without generating noise, because most weeks the answer is “nothing changed” and the job exits quietly.

How should it alert me?

Only when there’s something to say. A weekly “no changes” email trains everyone to ignore the alert, and then the one week it matters, they ignore that too. Fire a notification exclusively when $newPermissions has contents. A Teams webhook or a Graph sendMail call both work; the delivery mechanism matters far less than the discipline of staying silent on quiet weeks.

audit.ps1PowerShell
if ($newPermissions) {
    $summary = ($newPermissions | ForEach-Object {
        "- [$($_.Type)] $($_.Value)"
    }) -join "`n"$body = "New Microsoft Graph permissions detected:`n$summary`n`nReview consent policy and existing grants."
    # Route $body to Teams webhook / sendMail / your ticketing queue.
    Write-Output $body
}

A new permission showed up. What do I actually do?

Three things, in order. Check whether any app already holds it using the sweep above — a grant that predates your awareness is the thing you most need to know. Then look at your user consent settings and decide whether this new scope’s risk classification means someone could consent to it without you. Finally, update your documentation and add the permission to the SharePoint baseline so the next run treats it as known.

The trap is the “all permissions” app — a well-meaning integration granted a broad set of application permissions years ago, whose grant quietly expands in capability every time Microsoft extends the catalogue underneath it. You didn’t re-consent because you didn’t need to. The app’s reach grew anyway.

Where does this live in the admin center?

The evidence lives under Entra admin center → Identity → Applications → Enterprise applications for existing grants, and Enterprise applications → Consent and permissions → User consent settings for the policy that decides who can approve what. But the catalogue itself — the thing that changes without a notification — has no dashboard. It’s a property bag on a service principal. That’s precisely why you need the script: Microsoft gave you the data and not the alarm.

Watch the list, or find out from the audit log after the fact. Those are the two options.