A developer needs to restart App Service web apps in production, and nothing more. You reach for a built-in role and find the choices are bad in both directions: Website Contributor lets them edit app settings, swap slots, pull publishing credentials and change the plan; Reader lets them restart nothing. There is no built-in role that says “restart these web apps, touch nothing else.” This gap — built-in roles that are always either too broad or too narrow for the exact task — is the daily reality of running Azure at any real scale, and it is exactly what custom RBAC roles exist to close. A custom role is a JSON definition you author that names the precise set of control-plane operations (and, optionally, data-plane operations) an identity may perform, scoped to exactly the subscriptions, resource groups or resources you choose.
The whole machinery rests on four arrays and one rule. The arrays are Actions (management operations you allow), NotActions (operations subtracted back out of what Actions granted), DataActions (operations on the data inside a resource — reading a blob, getting a secret), and NotDataActions (the data-plane subtraction). The rule is deny-by-default with no allow-overrides-deny: an identity can do nothing until a role assignment grants it, the effective permission is the union of every role assigned to it at or above the scope, and NotActions only carves holes out of that same role’s Actions — it is not a global deny. Get those two ideas right and custom roles stop being mysterious. Get them wrong and you ship a role that either does nothing or quietly grants far more than you intended.
This article is a step-by-step implementation guide. You will read the mental model, then build a real least-privilege role end to end — first in the Azure portal, then with the az role definition commands, then as a versioned Bicep deployment — assign it to a group, prove it works and prove it cannot do the things you excluded, and finally tear it all down. Along the way come the option matrices, the limits, the wildcard and NotActions gotchas, and the troubleshooting table for the “why is this 403 / why does this person have more access than I gave them” questions that show up the week after you ship. If you haven’t yet internalised how role assignments and scopes work, read Azure RBAC Fundamentals: Roles, Scopes and Assignments Without the Confusion first — this guide assumes that foundation and goes a layer deeper into authoring the definitions themselves.
What problem this solves
The built-in roles are deliberately coarse. Azure ships a few fundamental ones (Owner, Contributor, Reader, User Access Administrator) plus hundreds of service-specific ones (Virtual Machine Contributor, Storage Blob Data Reader, Key Vault Secrets User, …). They are excellent defaults — prefer them whenever one fits. But they target the common case, and production access requests are usually specific: “this CI principal may only create and delete resource groups tagged env=ephemeral”; “this on-call group may restart VMs but not delete anything”; “this auditor may read every resource but see no secret values.” None map cleanly to a built-in role.
What breaks without custom roles is one of two expensive failure modes. The first is over-grant: you give Contributor because the perfect role doesn’t exist, and now an account that needed to restart three web apps can also delete the database, rewrite network rules and exfiltrate connection strings — latent blast radius, the difference between a compromised CI token causing an annoyance and an outage. The second is friction and shadow access: no role fits, so the work routes through a human with Owner who “just does it,” or someone widens an assignment “temporarily,” and your access model now lives in Slack threads instead of auditable definitions.
Custom roles also solve a governance problem that compounds with size. With one definition assigned to a group, you can read it, review it, diff it in git, and answer “who can restart prod web apps?” in a single query; with forty hand-edited Contributor-minus-a-few-things assignments, you can answer nothing. This bites hardest on platform and landing-zone teams handing out scoped access across many subscriptions, security teams chasing least-privilege findings, and anyone running CI/CD service principals — which should be the most tightly scoped identities in your tenant and almost never fit a built-in role.
| Pain in production | What it looks like | The custom-role fix |
|---|---|---|
| No built-in role fits the task | Reader (too little) or Contributor (too much) | A role with the exact Actions for the task |
| Over-grant as a workaround | Contributor handed out “because nothing else works” | Subtract the dangerous bits, or build up from Reader |
| Secret/data exposure via mgmt role | Contributor can read connection strings, list keys | Omit the listKeys/data path; use DataActions deliberately |
| Unauditable, hand-edited access | 40 bespoke assignments nobody can summarise | One named definition, versioned in git, on a group |
| Pipeline identity too powerful | CI service principal holds Contributor at subscription | Tightly scoped custom role at the exact RG |
Learning objectives
By the end of this article you can:
- Explain the deny-by-default RBAC evaluation model and why
NotActionsis a subtraction within one role, not a tenant-wide deny — and how that differs from an Azure deny assignment. - Read any Azure operation string (
Microsoft.Compute/virtualMachines/restart/action) and decompose it into provider, resource type, and operation, and decide whether it is a control-planeActionor a data-planeDataAction. - Author a custom role definition’s five working fields —
Actions,NotActions,DataActions,NotDataActions, andAssignableScopes— and predict its effective permission set before you assign it. - Build the same least-privilege role three ways: in the portal (cloned and clone-free), with
az role definition create/update, and as idempotent Bicep usingMicrosoft.Authorization/roleDefinitions. - Discover the exact operation strings you need with
az provider operation showand the portal’s permission picker, and use wildcards safely (and know when they are dangerous). - Assign the role to a group, validate it end to end with
az role assignment list, the Check access blade, and a negative test that proves an excluded action is denied. - Diagnose the common failures: a role that won’t assign at a scope, a 403 despite the assignment,
AuthorizationFailed, the DataActions-with-management-scope rejection, and stale-permission caching — and fix each.
Prerequisites & where this fits
You need an Azure subscription where you hold Owner or User Access Administrator at the scope you’ll work in — authoring and assigning roles is itself privileged, governed by Microsoft.Authorization/roleDefinitions/write and .../roleAssignments/write, which only those two roles carry by default. You also need the az CLI (or Cloud Shell), basic comfort reading JSON, and a resource group for throwaway resources. The lab uses one small App Service web app that fits in free/trial credit and is torn down at the end.
You should already understand that a role assignment binds a role definition to a security principal (user, group, service principal or managed identity) at a scope (management group, subscription, resource group, or single resource), inherited downward. If any of those words are fuzzy, the upstream primer Azure RBAC Fundamentals: Roles, Scopes and Assignments Without the Confusion covers them; this article goes inside the definition object that piece treats as a black box.
Where this sits: RBAC is the authorization layer of Azure’s control plane, downstream of authentication (Entra ID proves who you are) and adjacent to Azure Policy (which governs what configuration resources may have, not who may act — see Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists). It is also the foundation under Privileged Identity Management, which makes a privileged role eligible and time-bound; once you can author a tight role, Privileged Identity Management and PAM Architecture: Just-in-Time Access at Scale is how you stop it being standing access. Custom roles are defined per Entra tenant and usable across every subscription under it, which is why one well-authored role pays off at organisation scale.
| Layer | Question it answers | Mechanism | Where custom roles touch it |
|---|---|---|---|
| Authentication | Who are you? | Entra ID sign-in, tokens | Upstream — RBAC trusts the proven identity |
| Authorization (RBAC) | What may you do? | Roles + assignments + scope | This article |
| Policy | What may a resource be? | Azure Policy effects | Adjacent — governs config, not actors |
| Just-in-time | When/for how long? | PIM eligibility + activation | Wraps a privileged custom role |
| Resource hierarchy | Where does access apply? | MG → Sub → RG → resource | AssignableScopes + assignment scope |
Core concepts
Six ideas make every later decision obvious; the rest is application.
Deny-by-default, additive allow. A freshly created Entra identity can do nothing in Azure’s control plane; capability appears only when a role assignment grants it. With several assignments, the effective set is the union of all their Actions/DataActions minus their NotActions/NotDataActions, at the request scope. RBAC has no deny rules in the assignment model — the only true denies are platform-managed deny assignments (Blueprints / Managed Apps), which you don’t normally author. This is why you cannot “fix” an over-broad assignment by adding a narrow one: more assignments only ever add permission.
Control plane vs data plane. Azure splits operations into two planes. The control plane manages the resource as an object in ARM — create a storage account, change its firewall, delete it. The data plane operates on the contents — read a blob, enqueue a message, get a secret value. Control-plane operations go in Actions/NotActions; data-plane operations go in DataActions/NotDataActions. The frequently-missed consequence: a control-plane */read does not let you read blob bytes, and .../listKeys/action is a control-plane action that nonetheless yields a credential bypassing data-plane RBAC entirely. Separating the planes deliberately is the heart of least privilege.
The operation string is structured. Every permission has the form {ResourceProvider}/{resourceType}/{operation} — e.g. Microsoft.Compute/virtualMachines/restart/action: the resource provider, the resource type path, and the operation verb. The verbs you’ll see constantly: read (GET/list), write (PUT/PATCH create or update), delete, and action (a POST for non-CRUD operations like restart, listKeys). Data-plane operations carry the same shape in the provider’s data namespace. Learning to read these strings is most of authoring roles.
NotActions subtracts, it does not deny. NotActions removes operations from what this role’s Actions granted — nothing more. If Actions is Microsoft.Storage/* and NotActions is .../storageAccounts/delete, the effective set is “everything in Storage except deleting accounts.” But if the same identity also holds Contributor elsewhere, that delete comes back — the union recombines them, and NotActions only ever applied to its own role. For a hard “never,” you need a deny assignment or to simply never grant the Action in any assigned role.
AssignableScopes is where the role may be used, not where it is used. It lists the management groups, subscriptions or resource groups where the role is allowed to be assigned; it grants nothing by itself — you still create an assignment at a concrete scope. Set it to the narrowest scope covering all intended use: a role for one subscription lists that subscription, not /. Custom roles can be assigned at management group scope, but only if they carry no DataActions (covered later).
Effective permission = union of allows, minus that-role’s NotActions, at scope. At request time Azure gathers every role assigned to the identity (and its groups) at or above the request scope, computes each role’s Actions − NotActions and DataActions − NotDataActions, takes the union, and checks whether the requested operation is in it. A 403 means “not in the union”; an unexpected allow almost always means “another assignment you forgot about added it.”
The vocabulary in one table
| Term | One-line definition | Field / location | Why it matters |
|---|---|---|---|
| Role definition | JSON object listing allowed operations | Microsoft.Authorization/roleDefinitions |
The thing you author |
Actions |
Control-plane operations allowed | Role def array | Management ops (read/write/delete/action) |
NotActions |
Subtracted from Actions |
Role def array | Trims a broad allow; not a deny |
DataActions |
Data-plane operations allowed | Role def array | Read blob, get secret, send message |
NotDataActions |
Subtracted from DataActions |
Role def array | Carve a data hole within DataActions |
AssignableScopes |
Scopes where the role may be assigned | Role def array | Bounds reuse; narrow it |
| Role assignment | Binds a definition to a principal at a scope | roleAssignments |
The thing that actually grants |
| Scope | MG / Sub / RG / resource access applies to | Assignment + AssignableScopes | Inherited downward |
| Operation string | Provider/type/verb permission token |
Inside the arrays | The unit of permission |
| Deny assignment | Platform-managed hard deny | Blueprints / Managed Apps | The only true RBAC deny |
| Control plane | Manage the resource object (ARM) | Actions |
Create/configure/delete |
| Data plane | Operate on resource contents | DataActions |
Bytes, secrets, messages |
Anatomy of a role definition
A custom role is a single JSON document. Here is a real one — restart and read web apps and see their logs/metrics, but not edit config, swap slots, read publishing credentials, or delete (the complete runnable copy is in the lab’s Step 4):
{
"Name": "Web App Operator (Restart & Read)",
"Description": "Restart and read App Service web apps and read logs/metrics. No config edit, no slot swap, no publishing credentials, no delete.",
"Actions": [
"Microsoft.Web/sites/read",
"Microsoft.Web/sites/restart/action",
"Microsoft.Web/sites/start/action",
"Microsoft.Web/sites/stop/action",
"Microsoft.Web/sites/config/read",
"Microsoft.Web/sites/metrics/read",
"Microsoft.Web/sites/diagnostics/read"
],
"NotActions": [],
"DataActions": [],
"NotDataActions": [],
"AssignableScopes": [
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-shop-prod"
]
}
Every field, what it does, whether it’s required, and the trap to know:
| Field | Required | What it holds | Default if omitted | Trap |
|---|---|---|---|---|
Name |
Yes | Display name (unique per tenant) | — | Duplicate names collide on assign-by-name |
Id |
No (set by Azure) | The role’s GUID | Azure generates | Don’t hand-set on create; needed on update |
IsCustom |
Yes (effectively) | true for custom roles |
false |
Built-ins are read-only; yours must be true |
Description |
Recommended | Free text in the portal | empty | The only human hint reviewers get |
Actions |
Yes (or DataActions) | Allowed control-plane ops | none | Empty + empty DataActions = grants nothing |
NotActions |
No | Subtracted from Actions |
[] |
Subtraction within this role only — not a deny |
DataActions |
No | Allowed data-plane ops | [] |
Rejected at management-group scope |
NotDataActions |
No | Subtracted from DataActions |
[] |
Same plane only — won’t trim Actions |
AssignableScopes |
Yes | Where the role may be assigned | — | / (root) almost never right; needs elevated access |
A few non-obvious rules the table can’t fully carry:
ActionsandDataActionscannot both be empty — a role must grant something. A role with onlyDataActionsis valid (e.g. Storage Blob Data Reader).- Any
DataActions/NotDataActionspresent means the role cannot list a management group inAssignableScopes— data-plane roles are assignable at subscription scope and below only. AssignableScopesaccepts MGs, subscriptions and resource groups — but not an individual resource, and not the tenant root/for a normal author. You can still assign the role at a single resource as long as it sits under a listed scope.
Reading an operation string
Decomposing Microsoft.Compute/virtualMachines/restart/action:
| Segment | Value | Meaning |
|---|---|---|
| Resource provider | Microsoft.Compute |
The Azure service namespace |
| Resource type | virtualMachines |
The object type within it |
| Operation verb | restart |
The specific operation |
| Plane marker | /action |
A non-CRUD POST; control plane |
The four verbs and what each maps to in practice:
| Verb suffix | HTTP it gates | Examples | Notes |
|---|---|---|---|
/read |
GET / LIST | Microsoft.Web/sites/read |
Listing and getting properties |
/write |
PUT / PATCH | Microsoft.Web/sites/write |
Create and update (no separate “create”) |
/delete |
DELETE | Microsoft.Storage/storageAccounts/delete |
Removal |
/action |
POST (non-CRUD) | .../restart/action, .../listKeys/action |
Not CRUD; many are sensitive |
The /action verbs are where danger hides: listKeys/action, regenerateKey/action, listConnectionStrings/action and getSecrets-style operations all return credentials or secrets through the control plane. A role granting Microsoft.Storage/storageAccounts/* includes listKeys/action, which hands over the account key — bypassing every data-plane RBAC restriction you built. Enumerating /action operations deliberately, rather than wildcarding them in, is a core least-privilege discipline.
Wildcards: power and peril
You may use * to match a span. Microsoft.Web/* means every operation in the Web provider; Microsoft.Web/sites/* every operation on sites; */read read everything across every provider (essentially the Reader role’s Actions). Wildcards keep definitions short and automatically pick up new operations Microsoft adds later — both their value and their risk.
| Wildcard pattern | Matches | Good for | Risk |
|---|---|---|---|
* |
Every control-plane op, every provider | The Owner/Contributor tier | Total control plane; almost never custom |
*/read |
Read on every provider | A broad auditor/reader role | Reads everything, including sensitive props |
Microsoft.Web/* |
All Web provider ops | A Web-focused admin role | Includes listKeys-style sensitive /actions |
Microsoft.Web/sites/* |
All operations on sites | A site admin role | Includes config write, slot swap, publish creds |
Microsoft.Web/sites/*/read |
All read ops under sites | A site-scoped reader | Picks up future read sub-resources |
Microsoft.Storage/storageAccounts/blobServices/* |
All blob-service ops | Blob-service admin | Control plane only; not blob data |
The discipline that keeps wildcards safe: wildcard the reads, enumerate the writes and actions. */read or Microsoft.Web/sites/*/read is usually fine — reads rarely cause damage and you want to inherit new read sub-resources. But for write, delete and especially /action, list each operation explicitly so a future operation Microsoft adds (say a …/exportSecrets/action) is not silently granted the day it ships. Use NotActions to claw back the sensitive bits if you must wildcard a provider:
{
"Actions": [ "Microsoft.Storage/*" ],
"NotActions": [
"Microsoft.Storage/storageAccounts/delete",
"Microsoft.Storage/storageAccounts/listKeys/action",
"Microsoft.Storage/storageAccounts/regenerateKey/action"
]
}
Remember the limit of that pattern: NotActions only protects within this role. It reduces this grant’s surface, but a parallel Contributor assignment re-adds listKeys. Treat NotActions-on-a-wildcard as “make this role tighter,” never as “make this identity incapable.”
DataActions and the data plane
DataActions is the newer half of the model and the one teams most often get wrong — usually by forgetting it exists. A control-plane Action lets you manage a storage account; it does not let you read a blob. Reading the bytes requires a DataAction like .../blobServices/containers/blobs/read, delivered by a data role such as Storage Blob Data Reader. The separation is powerful: you can grant full control-plane management of a storage account (resize, firewall, lifecycle) while granting zero ability to read the data inside it — or the reverse.
Which Azure services actually have a meaningful data plane (and therefore meaningful DataActions):
| Service | Control-plane Action example |
Data-plane DataAction example |
Why the split matters |
|---|---|---|---|
| Storage (Blob) | .../storageAccounts/write |
.../containers/blobs/read |
Manage the account ≠ read the data |
| Key Vault (RBAC mode) | Microsoft.KeyVault/vaults/write |
.../secrets/getSecret/action |
Manage the vault ≠ read secret values |
| Service Bus | .../namespaces/write |
.../queues/messages/send/action |
Manage the namespace ≠ send/receive |
| Cosmos DB | .../databaseAccounts/write |
data-plane via Cosmos RBAC roles | Account mgmt ≠ document read/write |
| Event Hubs | .../namespaces/write |
.../eventhubs/messages/send/action |
Manage hub ≠ publish events |
Two hard rules and a credential trap to keep in mind:
| Rule / trap | Detail | Consequence if ignored |
|---|---|---|
| No DataActions at MG scope | A role with DataActions can’t list a management group in AssignableScopes |
az role definition create fails validation |
*/read (control) ≠ data read |
Control-plane read never grants data-plane read | Auditor with */read still can’t read blob bytes — by design |
listKeys bypasses data RBAC |
.../listKeys/action is control-plane but returns the account key |
Key-holder reads all data regardless of DataActions; treat as a data grant |
| Key Vault must be in RBAC mode | KV DataActions apply only when the vault uses the RBAC model | In access-policy mode, DataActions are ignored |
For Key Vault specifically, the choice between RBAC-mode DataActions and the legacy access-policy model is a decision in itself — Key Vault RBAC vs Access Policies: Choosing and Migrating the Permission Model walks that fork. The listKeys-as-a-credential trap on storage produces the same 403 reasoning in Fixing Azure Storage 403 Errors: Firewalls, Private Endpoints, RBAC & SAS.
Custom vs built-in: when to author one
Authoring a custom role is a maintenance commitment — a definition to review, version and keep current as providers add operations. Reach for it only when no built-in fits:
| If you need… | Prefer | Why |
|---|---|---|
| A standard job (full VM admin, blob reader) | Built-in role | Maintained by Microsoft; auto-updated |
| A built-in minus a few dangerous bits | Built-in + tighter second assignment, or a clone with NotActions |
Avoid re-implementing a huge Actions list |
| A handful of specific operations | Custom role from scratch | Smallest surface; easy to read |
| The same scoped access across many subs | Custom role (once per tenant) | Reused everywhere via AssignableScopes |
| Different config rules, not different actors | Azure Policy, not RBAC | RBAC governs who acts, Policy what config is allowed |
| Time-bound privileged access | PIM over any role | RBAC is standing access; PIM is just-in-time |
Hard platform limits you must design within (real Azure caps current at the time of writing — verify the exact number against the docs):
| Limit | Value | Implication |
|---|---|---|
| Custom roles per tenant | 5,000 (2,000 in some sovereign clouds) | Don’t mint one per resource; parameterise via scope |
| Role assignments per subscription | 4,000 | The harder ceiling in practice; group principals |
AssignableScopes entries per role |
2,000 | Plenty; prefer a single MG over thousands of RGs |
| Definition / assignment changes | Eventually consistent (seconds to a minute) | Don’t assert success the instant after create |
| Name uniqueness | Per Entra tenant | Same-named roles break assign-by-name |
The “clone a built-in” path is the most common real workflow. In the portal, IAM → Roles → (pick a built-in) → Clone copies that role’s entire Actions/DataActions into a new editable custom role; you then trim with NotActions or delete lines — how you build “Contributor but cannot delete and cannot touch RBAC” without typing hundreds of operation strings. From the CLI, seed the same way by exporting a built-in’s JSON first: az role definition list --name "Website Contributor" -o json > seed.json, then edit and create from it.
Architecture at a glance
There is no diagram for this topic — the model is small enough to hold in your head, and a picture would only restate the lists. Carry this instead.
Picture a request arriving at Azure Resource Manager: “restart web app app-shop-prod.” ARM first resolves who is asking — the authenticated identity plus every Entra group it belongs to. It gathers every role assignment for those principals at the request’s scope and every scope above it (the resource group, the subscription, and any management groups up the chain), because assignments inherit downward. For each it computes the role’s effective set as Actions − NotActions (and, for data requests, DataActions − NotDataActions), then takes the union across all assignments. Finally it asks one yes/no question: is Microsoft.Web/sites/restart/action in that union? If yes the operation proceeds; if no, ARM returns 403 AuthorizationFailed naming the missing action and scope.
Two side-channels sit beside that path. First, deny assignments (Blueprints and Managed Apps) win over any allow — the only override of the additive union, and rarely hand-created. Second, the data plane of a service (the storage REST endpoint, the Key Vault secret endpoint) does its own check against the identity’s DataActions for requests that touch contents — which is why a control-plane allow never leaks into data access. Hold those three things — the inheriting union of Actions, the rare deny-assignment override, and the separate data-plane check — and you can predict any assignment’s outcome before you make it. The scope chain that feeds the union is the same Management Group → Subscription → Resource Group → Resource hierarchy in Management Groups 101: Designing a Hierarchy That Scopes Policy and RBAC.
Real-world scenario
Northwind Retail runs a flash-sale platform on Azure: a resource group rg-shop-prod holding two App Service web apps (storefront and checkout API), an Azure SQL database, a storage account, and a Key Vault. The on-call rotation is a six-person Entra group, grp-shop-oncall. After a Friday incident where a tired engineer with Contributor “fixed” a stuck checkout by deleting and recreating the web app — wiping its slot configuration and causing a 40-minute outage — the platform lead, Priya, was handed a directive: on-call may operate production, not reshape it.
Priya’s first instinct was the built-in Website Contributor, but a five-minute audit killed it: that role can swap deployment slots, edit application settings (where the SQL connection string lives), and call listPublishingCredentials/action to pull FTP/Git credentials. None of that is “operate.” Reader plus a side channel was no good either, because on-call genuinely needs to restart and stop/start apps during a bad deploy. So she authored Web App Operator (Restart & Read) — exactly the definition shown earlier, with AssignableScopes pinned to rg-shop-prod.
She built it in Bicep so it lived in the platform repo and went through pull-request review, deployed it, and created one assignment binding the role to grp-shop-oncall at the resource group. Then she did the part most people skip: she proved the negatives. Signed in as a test member, she confirmed az webapp restart succeeded, then confirmed az webapp config appsettings set, az webapp delete, and az webapp deployment list-publishing-credentials each returned 403 AuthorizationFailed. Each negative went into the change ticket as evidence the blast radius was actually closed, not just intended to be.
Because the role granted standing access to six people 24/7, she then wrapped it in PIM: the assignment became eligible rather than active, so an on-call engineer activates “Web App Operator” for the duration of an incident with justification and an audit trail, and it deactivates automatically. The role defined what they could do; PIM defined when. The Friday-delete class of incident has not recurred, and “who can restart prod checkout?” is now answered by reading one definition and one assignment, not by auditing every Contributor in the subscription.
Advantages and disadvantages
| Advantages | Disadvantages |
|---|---|
| Exact least privilege — only the operations the task needs | A maintenance burden: yours to update as providers add ops |
| One named, versionable definition you can review and diff | Easy to misjudge a wildcard and over-grant silently |
| Reusable across every subscription in the tenant | Capped (5,000/tenant); proliferation is an anti-pattern |
| Auditable answer to “who can do X?” | NotActions lulls people into thinking it’s a deny |
| Separates control-plane and data-plane access deliberately | DataActions can’t be used at management-group scope |
| Removes the “Contributor as a workaround” over-grant | Authoring/assigning needs Owner or UAA |
The advantages dominate whenever the access request is specific and recurring, or the identity is a pipeline/service principal (which should be the tightest thing in your tenant). A custom role pays for its maintenance the moment more than a couple of principals need the same scoped capability, or a security review asks you to prove least privilege.
The disadvantages dominate for one-off, short-lived needs (use a built-in plus PIM rather than minting a role you’ll forget to delete), deeply standard jobs (a maintained built-in already exists), and anything where the real requirement is a configuration constraint rather than an actor constraint — that is Azure Policy’s job, not RBAC’s. The honest rule: prefer built-ins, reach for custom only when the gap is real, and delete custom roles the moment nothing references them.
Hands-on lab
This is the centerpiece. You will build the Web App Operator (Restart & Read) role end to end three ways — portal, az CLI, and Bicep — assign it, validate it positively and negatively, then tear everything down. Every step states its expected output so you can confirm before moving on. The lab fits in free/trial credit; the only billable resource is a single Basic-tier web app you delete at the end (skip it and reuse any existing app for zero spend).
Step 0 — Prerequisites and variables
You need Owner or User Access Administrator at the subscription you’ll use, plus Cloud Shell (bash) or a local terminal with az ≥ 2.50. Set working variables once:
# Sign in if local (Cloud Shell is already authenticated)
az login
# Pin the subscription you'll work in
SUB_ID=$(az account show --query id -o tsv)
echo "Subscription: $SUB_ID"
# Names used throughout the lab
RG="rg-rbaclab"
LOCATION="centralindia" # pick a region near you
PLAN="plan-rbaclab"
APP="app-rbaclab-$RANDOM" # site names are globally unique
ROLE_NAME="Web App Operator (Restart & Read)"
Expected output: az account show prints your subscription GUID; echo confirms it.
Step 1 — Create a throwaway resource group and web app
az group create --name "$RG" --location "$LOCATION"
az appservice plan create --name "$PLAN" --resource-group "$RG" \
--sku B1 --is-linux
az webapp create --name "$APP" --resource-group "$RG" \
--plan "$PLAN" --runtime "NODE:20-lts"
Expected output: three JSON blobs, each with "provisioningState": "Succeeded". The RG scope you’ll use everywhere is:
RG_SCOPE="/subscriptions/$SUB_ID/resourceGroups/$RG"
echo "$RG_SCOPE"
Step 2 — Discover the exact operation strings
Before authoring, confirm the operations you’ll grant exist with these exact names — never guess a string:
# List every operation the Web provider exposes (long); filter to sites
az provider operation show --namespace Microsoft.Web \
--query "resourceTypes[?name=='sites'].operations[].{op:name, display:displayName}" \
-o table | grep -iE "restart|read|start|stop|config|delete|publish|slot"
Expected output: a table with rows such as Microsoft.Web/sites/restart/action Restart Web App, .../sites/Read, .../config/Read, .../slotsswap/Action, and .../sites/Delete. This is your menu — cross-check the few you’ll grant and copy them verbatim (strings are case-insensitive on evaluation, but copy the casing you see).
| Operation you want | String to grant | Why it’s in / out |
|---|---|---|
| Restart a web app | Microsoft.Web/sites/restart/action |
In — the core ask |
| Start / stop | Microsoft.Web/sites/start/action, .../stop/action |
In — bad-deploy control |
| See the app & its config | Microsoft.Web/sites/read, .../config/read |
In — read only, never config/write |
| Read metrics/diagnostics | Microsoft.Web/sites/metrics/read, .../diagnostics/read, Microsoft.Insights/metrics/read |
In — observability |
| Edit app settings | Microsoft.Web/sites/config/write |
Out — config edit is reshaping |
| Swap slots | Microsoft.Web/sites/slotsswap/action |
Out — slot swap is a deploy |
| Pull publishing creds | Microsoft.Web/sites/publishxml/action, .../listPublishingCredentials/action |
Out — credential exposure |
| Delete the app | Microsoft.Web/sites/delete |
Out — the Friday-incident action |
Step 3 (Portal path) — Author the role in the Azure portal
You’ll build the same role two ways; start with the portal so the fields are concrete.
- Go to portal.azure.com → Resource groups →
rg-rbaclab→ Access control (IAM). - Click + Add → Add custom role. (Doing it at the resource group pre-fills
AssignableScopeswith this RG.) - On Basics: set Custom role name to
Web App Operator (Restart & Read), add the description, and leave Baseline permissions as Start from scratch (the other options, Clone a role and Start from JSON, are the two alternative seeds). - On Permissions: click + Add permissions, search the provider Microsoft Web, and tick the read/restart/start/stop/config-read/metrics/diagnostics operations from Step 2’s “In” rows. The picker groups them by
read/write/delete/other (action). Confirm none of the “Out” rows (config write, slotsswap, delete, publishing credentials) are selected. - On Assignable scopes: confirm
rg-rbaclabis listed. Do not add the subscription or/. - On JSON: review the generated definition — it should match the Anatomy JSON (modulo GUIDs) and is editable; it’s the same document the CLI and Bicep produce.
- Review + create.
Expected output: a green “Successfully created custom role” notification; under IAM → Roles, filtering to Custom now lists it. This is the exact same object the CLI creates below — so either skip Step 4’s create and jump to assignment, or delete the portal role and recreate via CLI to practise both.
Step 4 (CLI path) — Author the role with az role definition create
If you built it in the portal already, delete it first so the name is free:
az role definition delete --name "$ROLE_NAME" # no-op if it doesn't exist
Write the definition to a file (AssignableScopes uses your real RG scope):
cat > webapp-operator-role.json <<EOF
{
"Name": "Web App Operator (Restart & Read)",
"IsCustom": true,
"Description": "Restart and read App Service web apps and read logs/metrics. No config edit, no slot swap, no publishing credentials, no delete.",
"Actions": [
"Microsoft.Web/sites/read",
"Microsoft.Web/sites/restart/action",
"Microsoft.Web/sites/start/action",
"Microsoft.Web/sites/stop/action",
"Microsoft.Web/sites/config/read",
"Microsoft.Web/sites/metrics/read",
"Microsoft.Web/sites/diagnostics/read",
"Microsoft.Web/serverfarms/read",
"Microsoft.Insights/metrics/read",
"Microsoft.Resources/subscriptions/resourceGroups/read"
],
"NotActions": [],
"DataActions": [],
"NotDataActions": [],
"AssignableScopes": [
"$RG_SCOPE"
]
}
EOF
az role definition create --role-definition webapp-operator-role.json
Expected output: a JSON object echoing the role, now carrying an "id" (the role’s GUID) and "roleType": "CustomRole". Capture the GUID for assignment (you can assign by display name or GUID):
ROLE_ID=$(az role definition list --name "$ROLE_NAME" --query "[0].name" -o tsv)
echo "Role definition GUID: $ROLE_ID"
If create returns RoleDefinitionWithSameNameExists, the portal or a prior run already made it — delete and retry, or use az role definition update.
Step 5 — Create a test group and a member to assign to
Assign to a group, never a person — it’s auditable, reusable, and the practice you want to build:
# Create an Entra security group for the assignment target
GROUP_ID=$(az ad group create --display-name "grp-rbaclab-oncall" \
--mail-nickname "grp-rbaclab-oncall" --query id -o tsv)
echo "Group object id: $GROUP_ID"
Expected output: a group object GUID. (No Entra group-creation rights? Assign to your own user via az ad signed-in-user show --query id -o tsv instead — but a group is production-correct.) Optionally add yourself:
# Add yourself to the group so you can test (optional)
ME=$(az ad signed-in-user show --query id -o tsv)
az ad group member add --group "$GROUP_ID" --member-id "$ME"
Step 6 — Assign the role at the resource-group scope
az role assignment create \
--assignee-object-id "$GROUP_ID" \
--assignee-principal-type Group \
--role "$ROLE_NAME" \
--scope "$RG_SCOPE"
Expected output: a JSON assignment with "roleDefinitionName" (or GUID), "principalId" = your group, "scope" = rg-rbaclab, and a fresh "id". --assignee-object-id with --assignee-principal-type skips a Graph lookup and is the robust form for groups and service principals.
Step 7 — Validate positively: the role can do what it should
First, confirm the assignment exists and reads back:
az role assignment list --scope "$RG_SCOPE" \
--query "[?roleDefinitionName=='$ROLE_NAME'].{role:roleDefinitionName, principal:principalId, type:principalType, scope:scope}" \
-o table
Expected output: one row showing the role, your group id, Group, and the RG scope.
Use Check access to confirm what the principal can do without signing in:
# What does the group have at this scope?
az role assignment list --assignee "$GROUP_ID" --scope "$RG_SCOPE" -o table
For a true positive test, sign in as a group member (or your own session, if you added yourself) and restart the app:
az webapp restart --name "$APP" --resource-group "$RG"
echo "Exit code: $?"
Expected output: exit code 0 and no error; the web app restarts. The grant works. (Allow a few seconds after Step 6 — assignments are eventually consistent, so an immediate test can race propagation and 403 spuriously; if it does, wait ~30 seconds and retry.)
Step 8 — Validate negatively: prove the excluded actions are denied
The step that separates “I intended least privilege” from “I verified it.” Each should fail with 403 / AuthorizationFailed, signed in as the group member:
# 8a — config write (edit app settings) must be DENIED
az webapp config appsettings set --name "$APP" --resource-group "$RG" \
--settings TEST=1
# Expected: (AuthorizationFailed) ... does not have authorization to perform action
# 'Microsoft.Web/sites/config/write' over scope '.../app-rbaclab-...'
# 8b — delete the app must be DENIED
az webapp delete --name "$APP" --resource-group "$RG"
# Expected: AuthorizationFailed on 'Microsoft.Web/sites/Delete'
# 8c — pull publishing credentials must be DENIED
az webapp deployment list-publishing-credentials --name "$APP" --resource-group "$RG"
# Expected: AuthorizationFailed on a publishing-credentials action
Expected output: each command prints an (AuthorizationFailed) error naming the exact missing action and scope. That message is your proof — and is how you debug real 403s. Capture these for your change record.
| Test | Command | Expected result | Proves |
|---|---|---|---|
| Restart (positive) | az webapp restart |
Exit 0 | The intended grant works |
| Read (positive) | az webapp show |
Returns JSON | read granted |
| Config write (negative) | az webapp config appsettings set |
403 on config/write |
Cannot reshape config |
| Slot swap (negative) | az webapp deployment slot swap |
403 on slotsswap/action |
Cannot deploy via swap |
| Delete (negative) | az webapp delete |
403 on sites/Delete |
Cannot destroy the app |
| Publishing creds (negative) | az webapp deployment list-publishing-credentials |
403 | No credential exposure |
Step 9 (Bicep path) — The same role as versioned infrastructure
The portal and CLI are great for exploration, but production roles belong in source control, reviewed and deployed idempotently. The crucial trick in Bicep is a deterministic GUID name so re-deploying updates the role rather than duplicating it:
// webapp-operator-role.bicep — deploy at resource-group scope
targetScope = 'resourceGroup'
@description('Stable name (GUID) for the role definition so redeploys are idempotent.')
param roleDefName string = guid(subscription().id, 'WebAppOperatorRestartRead')
resource webAppOperator 'Microsoft.Authorization/roleDefinitions@2022-04-01' = {
name: roleDefName
properties: {
roleName: 'Web App Operator (Restart & Read)'
description: 'Restart and read App Service web apps and read logs/metrics. No config edit, no slot swap, no publishing credentials, no delete.'
type: 'CustomRole'
permissions: [
{
actions: [
'Microsoft.Web/sites/read'
'Microsoft.Web/sites/restart/action'
'Microsoft.Web/sites/start/action'
'Microsoft.Web/sites/stop/action'
'Microsoft.Web/sites/config/read'
'Microsoft.Web/sites/metrics/read'
'Microsoft.Web/sites/diagnostics/read'
'Microsoft.Web/serverfarms/read'
'Microsoft.Insights/metrics/read'
'Microsoft.Resources/subscriptions/resourceGroups/read'
]
notActions: []
dataActions: []
notDataActions: []
}
]
assignableScopes: [
resourceGroup().id
]
}
}
output roleDefinitionId string = webAppOperator.id
Deploy it:
az deployment group create \
--resource-group "$RG" \
--template-file webapp-operator-role.bicep
Expected output: "provisioningState": "Succeeded" and an outputs.roleDefinitionId. Re-run it: it succeeds again and does not create a second role, because name (the GUID) is stable — that idempotency is the whole reason to deploy roles as IaC. To add the assignment in the same template, use a roleAssignments resource named by a guid() of the scope, principal and role:
@description('Object id of the group to grant the role to.')
param principalId string
resource assignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(resourceGroup().id, principalId, webAppOperator.id)
properties: {
roleDefinitionId: webAppOperator.id
principalId: principalId
principalType: 'Group'
}
}
Deterministic guid() names for both the definition and the assignment are what make role IaC safe to re-apply on every pipeline run — the same idempotency discipline as in Deploy Your First Bicep File From Scratch.
Step 10 — Update the role (add an operation) without breaking assignments
Suppose on-call now needs to read deployment history. Update in place — existing assignments survive because the role’s GUID is unchanged:
# Pull the current definition, add an operation, push it back
az role definition update --role-definition <(
az role definition list --name "$ROLE_NAME" --query "[0]" -o json \
| python3 -c "import sys,json; d=json.load(sys.stdin); d['permissions'][0]['actions'].append('Microsoft.Web/sites/deployments/read'); print(json.dumps(d))"
)
Expected output: the updated JSON with the new action in actions; the group’s existing assignment reflects it after propagation. With Bicep, add the line to the actions array and re-deploy — the stable GUID makes it an update, not a new role.
Step 11 — Teardown
Remove everything in order — assignments first, then the definition, then the resources (you can’t delete a definition while assignments reference it):
# 11a — remove the role assignment(s) at the scope
az role assignment delete --assignee "$GROUP_ID" --role "$ROLE_NAME" --scope "$RG_SCOPE"
# 11b — delete the custom role definition
az role definition delete --name "$ROLE_NAME"
# 11c — delete the test group
az ad group delete --group "$GROUP_ID"
# 11d — delete the resource group (web app, plan, everything in it)
az group delete --name "$RG" --yes --no-wait
Expected output: each delete returns quietly; az role definition list --name "$ROLE_NAME" now returns []. If 11b errors with “role is in use,” an assignment still references it — list them with az role assignment list --all --query "[?roleDefinitionName=='$ROLE_NAME']", remove the stragglers, and retry. The RG delete reclaims the only billable resource (the B1 plan).
Common mistakes & troubleshooting
The failures below are the ones that actually generate tickets the week after you ship a custom role. Each is symptom → root cause → how to confirm → fix.
| # | Symptom | Root cause | Confirm with | Fix |
|---|---|---|---|---|
| 1 | AuthorizationFailed despite the assignment existing |
Wrong scope, stale token, or the operation isn’t in Actions |
Read the 403 — it names the missing action & scope; az role assignment list --assignee <id> --scope <scope> |
Add the named action, or assign at the correct (higher) scope; re-auth to refresh the token |
| 2 | The role grants more than intended | A wildcard pulled in sensitive /actions (e.g. listKeys) |
az role definition list --name "<role>" and scan actions for * |
Replace the wildcard with enumerated ops, or add NotActions for the sensitive ones |
| 3 | NotActions “isn’t working” — the user can still do it |
Another assignment (e.g. Contributor) re-adds it via the union | az role assignment list --assignee <id> --all -o table |
Remove the broad parallel assignment; NotActions only trims its own role |
| 4 | Role definition with DataActions cannot be assigned at management group scope |
DataActions present and a management group in AssignableScopes |
Inspect assignableScopes and dataActions in the JSON |
Drop the MG to subscription scope, or split control-plane and data-plane into two roles |
| 5 | RoleDefinitionWithSameNameExists on create |
A role with that display name already exists in the tenant | az role definition list --name "<name>" |
Use a unique name, or az role definition update, or delete the existing one |
| 6 | does not have authorization to perform action 'Microsoft.Authorization/roleDefinitions/write' |
You’re not Owner / User Access Administrator at the scope | az role assignment list --assignee <you> --scope <scope> |
Get Owner/UAA at the scope, or have someone who has it create the role |
| 7 | Assignment “succeeded” but access fails for ~30–60s | RBAC is eventually consistent; the token/cache hasn’t refreshed | Retry after ~1 min; check az role assignment list shows it |
Wait for propagation; in code, retry with backoff — never assert right after create |
| 8 | Data-plane action denied though DataActions is set |
The service isn’t in RBAC mode (Key Vault in access-policy mode) | Check the permission model (KV: enableRbacAuthorization) |
Switch the vault to RBAC mode, or use its access policies instead |
| 9 | AssignableScopes rejected with an authorization error |
Trying to set / (root) or a scope you lack rights over |
The error names the scope | Use the narrowest real scope (sub or RG); root needs rarely-used elevated access |
| 10 | Storage data still readable despite tight DataActions |
Someone has .../storageAccounts/listKeys/action and uses the account key |
Search assignments for Storage Account Contributor/wildcards |
Remove listKeys from roles; disable shared-key access; use Entra auth only |
| 11 | Can’t delete the role definition | Assignments still reference it (possibly in another subscription) | az role assignment list --all --query "[?roleDefinitionName=='<name>']" |
Delete every referencing assignment first, then the definition |
| 12 | Custom role doesn’t appear when assigning at a scope | The scope isn’t within the role’s AssignableScopes |
Compare the assignment scope to assignableScopes |
Add the scope (or a parent of it) to AssignableScopes and re-save |
Two deeper notes the table compresses:
The 403 message is your best diagnostic tool, not a dead end. AuthorizationFailed always names the action and scope it checked — either the operation you forgot to grant (mistake #1) or proof the exclusion worked (Step 8). Read it first; it usually tells you the fix outright. The same shape appears across the platform — the storage variant and its firewall/SAS interplay in Fixing Azure Storage 403 Errors: Firewalls, Private Endpoints, RBAC & SAS, the Key Vault flavour in Key Vault 403 Forbidden: Untangling Firewall, RBAC, Purge Protection, and Soft-Delete Recovery.
“More access than I granted” is almost always the union, not a bug. When someone can do something your role doesn’t list, the answer is in az role assignment list --assignee <id> --all — they hold another assignment (often inherited Owner/Contributor from a higher scope) that adds it. RBAC never removes via additional assignments, so the only way to reduce effective access is to remove the broad assignment or replace it with a tighter custom role — the over-grant cleanup this article enables.
Best practices
- Prefer built-in roles; author custom only for a real gap. Every custom role is maintenance you own. If a built-in fits, use it and move on.
- Build up, don’t tear down, when you can. An empty
Actionsplus the few operations you need yields a smaller, more auditable surface than cloning Contributor and trimming. Reserve clone-and-NotActionsfor “built-in minus a couple of things.” - Wildcard reads, enumerate writes/deletes/actions.
*/readfuture-proofs reads; spell out everywrite,deleteand/actionso new sensitive operations are never silently granted. - Treat
/actionoperations as sensitive by default.listKeys,regenerateKey,listConnectionStrings,listPublishingCredentialsand friends hand over credentials through the control plane. Grant only when truly needed. - Scope
AssignableScopesto the narrowest real boundary. A role for one subscription lists that subscription, not/— bounding blast radius and preventing assignment in the wrong place. - Assign to groups, not individuals. Group assignments are auditable, reusable, and survive personnel changes. One assignment to
grp-shop-oncallbeats six to people. - Version roles as IaC with deterministic GUID names. A
guid(subscription().id, '<role-key>')name makes re-deploys idempotent and keeps the definition reviewable in pull requests. Hand-edited portal roles drift. - Prove the negatives. After assigning, run the actions you excluded and confirm they 403. Intent isn’t evidence; the denial message is.
- Separate control plane from data plane deliberately. Decide independently whether the identity may manage the resource and whether it may touch the data — never let a control-plane wildcard silently grant a credential that bypasses data RBAC.
- Wrap privileged custom roles in PIM. A role grants standing access; PIM makes it just-in-time and audited.
- Name and describe roles for the reader. A clear
roleNameand one-sentencedescriptionare the only human context a future reviewer gets. - Delete roles that nothing references. Orphaned custom roles count against the tenant cap and confuse audits.
Security notes
Custom roles are a security control, so these notes are mostly about not undermining the least privilege you set out to build.
The credential bypass is the headline risk. Data-plane RBAC (DataActions) is meaningless if an identity can also call a control-plane operation returning a master credential: .../storageAccounts/listKeys/action returns the account key (full data access, no RBAC), regenerateKey/action lets an attacker rotate you out, and config/list/action-style operations expose connection strings. Audit every role for these /actions, prefer Entra-based data access over shared keys, and disable shared-key auth at the resource where possible.
Guard the meta-permissions. Microsoft.Authorization/roleAssignments/write and .../roleDefinitions/write are the keys to the kingdom — an identity with them can grant itself anything. Only Owner and User Access Administrator carry them by default; never add them to a general-purpose custom role, and treat any role that has them (or a Microsoft.Authorization/* wildcard, which sweeps them in) as a Tier-0 asset.
Identity choice matters as much as the role. A custom role is only as safe as the principal it’s bound to. Bind privileged roles to groups (auditable) and prefer managed identities over long-lived service-principal secrets — a leaked client secret with a scoped role is still a breach, just a smaller one. The trade-offs are in Managed Identities Demystified: System vs User-Assigned and When to Use Each; a tight custom role on a secret-less managed identity is the strongest combination.
Standing privileged access is a liability even when scoped. A minimal role held permanently by six on-call engineers is still six identities that can act on production at 3 a.m. if one is phished. Make privileged roles eligible via PIM rather than active, require activation with justification, and review assignments regularly — the operating model in Privileged Identity Management and PAM Architecture: Just-in-Time Access at Scale. Custom roles answer what; PIM answers when.
Log and review. Role definition and assignment changes are written to the Azure Activity Log and the Entra audit log. Alert on roleDefinitions/write and roleAssignments/write, and periodically reconcile live assignments against your IaC so portal-made drift is caught. A role you can’t see being changed is a control you don’t really have.
Cost & sizing
The direct cost of custom RBAC is zero — defining, assigning and evaluating roles is free in ARM and Entra ID, with no per-role, per-assignment or per-evaluation charge. The “sizing” that matters is not money but limits and operational weight.
| Dimension | Figure | What it costs you |
|---|---|---|
| Custom role definition | Free | No charge to create/keep/evaluate |
| Role assignment | Free | No charge |
| Custom roles per tenant | 5,000 (2,000 in some sovereign clouds) | Hit it by minting a role per resource — don’t |
| Role assignments per subscription | 4,000 | The practical ceiling; group principals to stay under it |
| Authoring/maintenance time | Human hours | The real cost: review, update, audit each role |
| PIM (if you add it) | Requires Entra ID P2 (or Governance) licensing | Per-user licence — the one place real money appears |
The cost trap to internalise: the expensive thing is proliferation, not the roles themselves. A custom role per application or per resource burns toward the 5,000 cap and turns audits into archaeology. The right shape is a small catalogue of well-named roles, each reused across many scopes via AssignableScopes and assigned to groups — a dozen good roles for a 100-subscription estate, not a thousand bespoke ones. The only line item that actually bills is PIM, which needs Entra ID P2; if your posture requires just-in-time elevation, budget P2 (or Microsoft Entra ID Governance) licences for the privileged users. Otherwise custom roles are a free lever — the cost is the discipline to keep the catalogue small and current.
The lab is essentially free if you reuse an existing web app (skip Step 1’s plan); the single B1 plan is the only billable element, and Step 11 tears it down.
Interview & exam questions
These map to AZ-104 (Azure Administrator), AZ-500 (Azure Security Engineer), and SC-300 (Identity and Access Administrator), all of which test RBAC authoring and evaluation.
1. What is the difference between NotActions and a deny assignment?
NotActions subtracts operations from the Actions of the same role definition, but another assignment can re-add the operation because effective permission is the union of all roles. A deny assignment (Blueprints/Managed Apps) overrides any allow, no matter how many roles grant the action. NotActions is a trim; a deny assignment is an absolute block.
2. An identity has Reader at a subscription and your custom role granting Microsoft.Web/sites/restart/action at a resource group. Can it restart a web app in that RG?
Yes. Effective permission is the union of all assignments at or above the scope: Reader contributes */read, your role the restart action — the union includes both, so the restart succeeds.
3. Why doesn’t */read let an auditor read blob contents?
*/read is a control-plane action granting GET/LIST on resource objects. Reading blob bytes is a data-plane operation requiring a DataAction (.../blobs/read). The planes are evaluated separately, so a control-plane reader sees the account’s config but not its data.
4. Why can a role with Storage Account Contributor read all blob data even with no DataActions?
Because it includes .../storageAccounts/listKeys/action, a control-plane operation returning the account key — which grants full data access and bypasses data-plane RBAC entirely. This is why listKeys is effectively a data-tier grant and disabling shared-key access is a hardening step.
5. You add a DataAction to a role whose AssignableScopes is a management group, and create fails. Why?
Roles containing DataActions (or NotDataActions) cannot be assigned at management-group scope — only subscription scope and below. Remove the MG, or split into a control-plane role and a separate data-plane role.
6. What does AssignableScopes do, and what’s a sensible value?
It lists the scopes at which the role may be assigned — granting nothing by itself. A sensible value is the narrowest scope covering all intended use: the specific subscription or resource group, never the tenant root /.
7. How do you make a Bicep role-definition deployment idempotent?
Give the roleDefinitions resource a deterministic name via guid(subscription().id, '<stable-key>'). Because the name is the role’s identity, re-deploying updates the existing role instead of duplicating it. The same trick (guid(scope, principal, roleDef)) makes assignments idempotent.
8. Which permissions are required to create and assign a custom role, and which built-in roles carry them?
Creating/editing a definition needs roleDefinitions/write; assigning needs roleAssignments/write. Only Owner and User Access Administrator hold these by default — Contributor explicitly cannot manage RBAC.
9. A user has a tight custom role but can still delete a resource you didn’t grant delete on. Where do you look?
At their other assignments: az role assignment list --assignee <id> --all. They almost certainly inherit Contributor or Owner from a higher scope. RBAC is purely additive, so the fix is removing or tightening that broad assignment.
10. When should you choose Azure Policy instead of a custom role? When the requirement is about resource configuration (“storage must deny public access,” “only allowed SKUs”) rather than who may act. RBAC governs identities and operations; Policy governs the allowed state of resources. They’re complementary, not substitutes.
11. How does PIM relate to a custom role? A custom role defines the set of permissions; PIM controls whether and for how long an identity holds it. With PIM the assignment is eligible rather than active — activated just-in-time with justification, expiring automatically. The role is the “what”; PIM is the “when.”
12. What’s the danger of authoring a role as Microsoft.KeyVault/*?
A provider-wide wildcard grants every operation in that provider — including sensitive control-plane /actions and any added later. It also doesn’t grant data-plane secret reads unless the vault is in RBAC mode. Enumerate the specific operations, or wildcard reads only and add NotActions for the dangerous bits.
Quick check
- True or false: adding a second role assignment can reduce an identity’s effective permissions.
- You want a role that can read a blob’s bytes. Does that operation go in
ActionsorDataActions? - Your role has
Actions: ["Microsoft.Storage/*"]andNotActions: ["Microsoft.Storage/storageAccounts/listKeys/action"]. The user still retrieves the account key. Give the single most likely reason. - Which two built-in roles can create and assign a custom role?
- Why can’t a role containing
DataActionsbe assigned at management-group scope?
Answers
- False. RBAC is purely additive — effective permission is the union of all assignments. To reduce access you must remove or replace a broad assignment.
DataActions. Reading blob bytes is a data-plane operation (.../blobs/read); a control-planeActiononly manages the account object.- The user holds another assignment (e.g. Contributor) at this or a higher scope whose
ActionsincludelistKeys.NotActionsonly trims its own role; the union re-adds it. - Owner and User Access Administrator — the only built-ins with
roleDefinitions/writeandroleAssignments/write. - Because roles with
DataActionsare restricted to subscription scope and below — the platform rejects a management group inAssignableScopes. Split into separate control-plane and data-plane roles.
Glossary
- RBAC (Role-Based Access Control): Azure’s authorization model granting identities permission to perform operations via role assignments at a scope.
- Role definition: The JSON object listing the
Actions/DataActions(andNot…subtractions) andAssignableScopesthat constitute a role. - Custom role: A role definition you author rather than a Microsoft built-in; defined once per Entra tenant, reusable across its subscriptions.
Actions: Allowed control-plane operations — managing resource objects in ARM.NotActions: Control-plane operations subtracted from the same role’sActions; a trim, not a deny.DataActions: Allowed data-plane operations — a resource’s contents (blob bytes, secret values, messages).NotDataActions: Data-plane operations subtracted from the same role’sDataActions.AssignableScopes: The MGs, subscriptions or resource groups where a role may be assigned; grants nothing by itself.- Control plane: The ARM surface for creating, configuring and deleting resources as objects.
- Data plane: The surface for operating on a resource’s contents, evaluated separately.
- Operation string: A
Provider/resourceType/verbtoken (e.g.Microsoft.Web/sites/restart/action) — the atomic unit of permission. - Role assignment: The binding of a definition to a security principal at a concrete scope; the thing that actually grants.
- Scope: The level (management group → subscription → resource group → resource) at which access applies; inherited downward.
- Deny assignment: A platform-managed hard deny (Blueprints/Managed Apps) that overrides any allow — the only true deny in RBAC.
- Security principal: The identity an assignment targets — user, group, service principal, or managed identity.
- Effective permissions: The union of every assigned role’s
Actions − NotActions(andDataActions − NotDataActions) at or above the request scope.
Next steps
- Solidify the assignment-and-scope foundations this guide builds on with Azure RBAC Fundamentals: Roles, Scopes and Assignments Without the Confusion.
- Make your privileged custom roles just-in-time instead of standing access in Privileged Identity Management and PAM Architecture: Just-in-Time Access at Scale.
- Design the scope hierarchy that RBAC (and Policy) inherit down in Management Groups 101: Designing a Hierarchy That Scopes Policy and RBAC.
- Govern resource configuration (the complement to governing actors) with Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists.
- Apply the control-plane/data-plane split to secrets specifically in Key Vault RBAC vs Access Policies: Choosing and Migrating the Permission Model.