ACloud.Solutions

Automation

Microsoft Graph API permissions least privilege, not the first result

The reporting script needs to list users and their MFA registration status. It has Directory.ReadWrite.All, because that was granted eighteen months ago when somebody was making it work at half past six on a Thursday, and it has worked ever since so nobody has revisited it.

Microsoft graph api permissions least privilege is not a difficult principle. It fails in practice for a specific and forgivable reason: the permission that definitely works is easy to find, and the minimum permission that works takes twenty minutes to establish.

Why microsoft graph api permissions least privilege loses to expedience

The sequence is always the same. A call returns 403. You search the error. The first useful result mentions a permission. You grant it, the call works, and you move on to the actual task, which was never "configure Graph permissions".

Nothing in that sequence is unreasonable. The problem is that the permission is now permanent, and it is attached to a credential that will be copied into a pipeline, a config file and possibly a ticket. The script was correct. The grant outlived the afternoon that produced it.

The fix is not discipline, it is knowing where to look, which turns twenty minutes into two.

Application versus delegated, which decides everything else

This distinction is the one that causes the most trouble, and it is worth being precise.

Delegated permissions act on behalf of a signed-in user. The effective access is the intersection of what the app was granted and what that user can already do. A delegated User.ReadWrite.All held by an app a standard user signs into does not let that user rewrite the directory, because they could not anyway.

Application permissions act as the app itself, with no user context. There is no intersection and no ceiling. User.ReadWrite.All as an application permission means exactly that, across the whole tenant, at three in the morning, with nobody signed in.

So an over-permissioned application registration is materially worse than an over-permissioned person, and unattended automation is precisely where application permissions are used. That is the combination worth auditing.

The corollary people miss: for anything scheduled, you want the narrowest application permission that works, and you should assume it grants exactly what it says with no mitigating context.

Reading the reference properly

Every Graph operation's documentation lists its permissions in a table, ordered from least to most privileged, split by delegated and application. The least privileged one that appears is the answer, and it is frequently much narrower than the one in the search result that fixed your 403.

Concretely, for the reporting script above, listing users needs User.Read.All rather than Directory.Read.All, and certainly rather than Directory.ReadWrite.All. Reading authentication methods needs UserAuthenticationMethod.Read.All, which is narrow and specific and exists precisely for this.

The pattern to internalise from the names: .Read. beats .ReadWrite., resource-specific beats Directory., and if a permission's name does not contain the resource you are touching, you are probably holding something too broad. Microsoft's permissions reference is the canonical list and is worth having open while writing anything new.

Certificates over secrets

Once the permission set is right, the credential holding it matters.

A client secret is a string. It gets pasted into pipelines, copied into .env files, shared in a message to unblock somebody, and it is readable by anyone who can see any of those places. A certificate keeps the private key where it was generated, so the thing you distribute is a thumbprint rather than the credential itself.

Both expire, and the expiry is its own problem covered in the credential note. The difference is what happens between now and then.

Better again: if the thing authenticating runs in Azure, use a managed identity and hold no credential at all. That removes the expiry problem, the distribution problem and the leaver problem simultaneously, and it applies to more workloads than people assume.

Reviewing what you already granted

The audit is short and almost always surprising.

List your application registrations and service principals with their granted permissions, and sort by breadth. You are looking for three things: Directory.ReadWrite.All on anything that only reads, .ReadWrite. where the name of the app suggests reporting, and any application whose purpose nobody can state.

Then check last sign-in per service principal. An app registration holding tenant-wide write access with no sign-ins in six months is not a permissions problem, it is a deletion opportunity, and deleting it removes the permission, the credential and next quarter's review item together.

The uncomfortable finding on most first passes is an app called something like test-app-2 with Directory.ReadWrite.All, created during an integration trial that did not proceed. Nobody remembers it, it still authenticates, and it can rewrite the directory. That single deletion is usually worth more than any amount of narrowing elsewhere.

Consent grants are worth the same treatment. A user-consented app somebody authorised years ago may hold delegated access nobody reviewed, and admin consent granted tenant-wide applies to everybody including future joiners.

Finding the minimum without guessing

Two techniques beat reading documentation, when the documentation is ambiguous or the operation is undocumented.

Start with nothing and add. Grant no permissions, run the script, and read the 403. Graph error responses frequently name the permission the call wanted, which turns the search into a lookup. Add that one, re-run, repeat. Three iterations gets you a minimal set derived from your actual calls rather than from what somebody else's script needed.

Check what the module is really calling. A cmdlet wrapping several Graph calls needs the union of their permissions, which is often broader than the cmdlet's own documentation implies. Turning on request debugging shows the URIs, and once you have the URIs you can look each one up individually. This is how you discover that a convenience cmdlet needs write access because of a call it makes for display purposes rather than for the operation you wanted.

Both are worth doing once per script and never again, and the output belongs in a comment at the top of the file next to the connect call. That comment is the single most useful line for whoever inherits it, because it converts "which of these permissions can I safely remove" from an experiment into a fact.

Where this sits

The permission set is part of the handover rather than an implementation detail. Automation delivered as part of consulting work comes with the service principal, the permission list, the reason each permission is there, and the expiry date, because the alternative is an app registration nobody can safely remove in two years.

It sits alongside the leaver automation note, which is the one case in this cluster where broad write access is genuinely required, and the rest of the automation notes.