Azure Identity & Access

Build Custom RBAC Roles: Actions, NotActions, and DataActions for Least Privilege

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:

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:

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.

  1. Go to portal.azure.com → Resource groups → rg-rbaclab → Access control (IAM).
  2. Click + Add → Add custom role. (Doing it at the resource group pre-fills AssignableScopes with this RG.)
  3. 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).
  4. 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.
  5. On Assignable scopes: confirm rg-rbaclab is listed. Do not add the subscription or /.
  6. 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.
  7. 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

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

  1. True or false: adding a second role assignment can reduce an identity’s effective permissions.
  2. You want a role that can read a blob’s bytes. Does that operation go in Actions or DataActions?
  3. Your role has Actions: ["Microsoft.Storage/*"] and NotActions: ["Microsoft.Storage/storageAccounts/listKeys/action"]. The user still retrieves the account key. Give the single most likely reason.
  4. Which two built-in roles can create and assign a custom role?
  5. Why can’t a role containing DataActions be assigned at management-group scope?

Answers

  1. False. RBAC is purely additive — effective permission is the union of all assignments. To reduce access you must remove or replace a broad assignment.
  2. DataActions. Reading blob bytes is a data-plane operation (.../blobs/read); a control-plane Action only manages the account object.
  3. The user holds another assignment (e.g. Contributor) at this or a higher scope whose Actions include listKeys. NotActions only trims its own role; the union re-adds it.
  4. Owner and User Access Administrator — the only built-ins with roleDefinitions/write and roleAssignments/write.
  5. Because roles with DataActions are restricted to subscription scope and below — the platform rejects a management group in AssignableScopes. Split into separate control-plane and data-plane roles.

Glossary

Next steps

AzureRBACCustom RolesLeast PrivilegeIdentityGovernanceaz CLIBicep
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading