A custom role starts life scoped tight: a handful of actions, reviewed once, approved, forgotten.
Months later someone edits it to unblock a deployment and adds
Microsoft.Authorization/roleAssignments/write. From that moment, everyone holding the
role can grant themselves anything, including Owner. No alert fires, the role keeps its innocent
name, and the change is invisible until an audit (or an incident) finds it. That is silent
permission creep, and Azure has nothing built in that watches for it.
Why Azure cannot tell you this out of the box
- No native alert on role definition changes. Editing a custom role is just another management-plane write. Nothing watches the contents of the definition or tells you whether the edit made the role dangerous.
- The built-in Azure Policy is deprecated. The policy "Custom subscription owner roles should not exist" was the closest native control, and it is deprecated.
- Defender for Cloud looks at the wrong thing. Its related recommendation counts owner assignments, not the contents of your custom role definitions. A role that is owner-equivalent by permission but named innocuously slips right past it.
- Eyeballing the JSON is unreliable. Wildcards expand
(
Microsoft.Foo/*coversMicrosoft.Foo/bar/write), andnotActionssubtracts, so whether a role is actually dangerous depends on what its wildcards grant after the carve-outs are applied.
The DIY approach: list, classify, diff
Everything you need is in the ARM management plane. The pattern is a scheduled job that lists every custom role, classifies each one, and diffs against the last run:
- List custom roles with
GET .../providers/Microsoft.Authorization/roleDefinitions?$filter=type eq 'CustomRole'(api-version 2022-04-01, follow the paging). The response carriespermissions.actions/notActions/dataActionsplus the audit fieldscreatedOn,updatedOn,createdBy, andupdatedBy, so who changed what needs no Activity Log dependency at all. - Classify each role with wildcard-aware matching and
notActionssubtraction, then flag the dangerous grants: a bare*,Microsoft.Authorization/*,roleDefinitions/write,roleAssignments/write,elevateAccess/action, anddenyAssignments/write. A role that can effectively grant or modify access is owner-equivalent. - Handle the "Contributor clone" correctly. A role with
actions: ['*']but the authorization writes carved out vianotActionsgrants everything except the ability to grant access. A naive check that only looks for*misclassifies it; subtract first, then judge. - Keep a snapshot and diff. Store a hash of each role's dangerous-permission
set together with its
updatedOn, and on every run call out a NEW dangerous role or one whose dangerous set grew. That diff is the actual alert: creep, caught. - Mind the scope. A management-group query returns only roles assignable at that management group or root; custom roles defined only at a child subscription are not returned. For full coverage, run per subscription.
Post the result to a Teams channel on a schedule and you have a working guardrail. The parts
people get wrong are the wildcard expansion, the notActions subtraction, and the
drift snapshot, which is exactly where the edge cases live.
A ready-made option: Role Definition Guard
Role Definition Guard is that pattern packaged, with the edge cases unit-tested. Every Monday at
07:00 UTC it reads every custom role definition at the configured scope, classifies each one with
wildcard-aware matching and notActions subtraction, and posts a Teams digest (an
Adaptive Card via an Incoming Webhook you provide) of every role that is owner-equivalent or
holds a dangerous permission, plus a drift diff against the last run: new dangerous roles and
roles whose dangerous-permission set grew are called out specially. "Last changed by" comes from
the ARM audit fields. It runs on Reader only and never writes; it inspects and reports, nothing
else, and its only egress is to your own Teams webhook.
Key facts
- Native alert on custom role definition changes: none.
- Built-in policy "Custom subscription owner roles should not exist": deprecated.
- Defender for Cloud: counts owner assignments, not what your custom role definitions actually grant.
- The data source: ARM
roleDefinitions(api-version 2022-04-01) returns permissions pluscreatedOn/updatedOn/createdBy/updatedBy, so no Activity Log dependency. - Owner-equivalent test: after wildcard expansion and
notActionssubtraction, can the role write role definitions, role assignments, or deny assignments, or elevate access? - Least privilege: listing and inspecting role definitions needs Reader only.
Related reading: just-in-time Azure role elevation without paying for PIM.
Written by the team at Katabarwa Labs. We build small, single-purpose Azure tools that run entirely inside your own tenant. Questions: abaho@llmgraph.ai.