Azure RBAC, scope & deny assignments
Inheritance, the roles that grant access, deny wins.
A building's badge system answers three questions before a door opens: who are you, what are you cleared for, and which door are you standing at. Azure RBAC (Role-Based Access Control, the system that decides which identities may perform which operations on which resources) works the same way. A role assignment is the badge. It ties a principal (a user, a group, a service principal, which is the identity an app or pipeline signs in as, or a managed identity, a service principal whose credentials Azure creates and rotates for you) to a role definition (a named bundle of allowed operations) at a scope (a slice of the resource tree). Those principals all live in Microsoft Entra ID, the identity directory Microsoft renamed from Azure Active Directory (Azure AD). One az role assignment create writes the badge. The security is not in that command. It lives in how a badge cut high in the building opens every door beneath it, which rare badges can print new badges, and the one override that seals a door shut no matter what badge you hold.
Scope is a tree, and grants roll downhill
Scope has four levels, stacked like nested keyrings: management group, then subscription, then resource group, then a single resource. A management group is a folder that holds subscriptions. A subscription holds resource groups. A resource group holds the actual things: the virtual machines (VMs), the storage accounts, the key vaults (Azure's managed store for secrets, keys, and certificates). An assignment you make at one level applies there and at everything beneath it. Grant Contributor (a built-in role that can create and change almost any resource) on a subscription, and you have quietly handed it out on every resource group and every resource inside, and it stays handed out until someone removes it. That is why a broad grant near the top is the one that hurts. It is one short line to type, and nearly invisible from the resource at the bottom, where nobody assigned anything at all.
RBAC only ever adds. A principal's real permissions are the union of every assignment it holds, added up across all the scopes it inherits from. You cannot take a permission back by writing a second, narrower assignment on top of it. Give someone Contributor on the subscription, then try to 'downgrade' them to Reader on one resource group, and they are still Contributor on that resource group, because the higher grant keeps flowing down and the union wins. Only two things ever subtract: a role's NotActions list (operations carved out of that one role) and a deny assignment, which you will meet lower down. So here is the rule that sounds obvious and gets skipped every day. Assign the most specific built-in role at the tightest scope the workload can stand. One key vault, not the whole subscription. To an attacker, an over-broad grant near the top is the prize, because it reaches everywhere and hides in plain sight.
# List every assignment that REACHES this resource group, including grants# inherited from the subscription and management group above it.az role assignment list \--scope /subscriptions/1111.../resourceGroups/payments-prod \--include-inherited \--query "[].{who:principalName, role:roleDefinitionName, scope:scope}" -o table
Who Role Scope----------------- ---------------------- -------------------------------------------------------------------------[email protected] Owner /subscriptions/1111.../ <- subscription![email protected] Contributor /subscriptions/1111.../resourceGroups/payments-prodpayments-fn Key Vault Secrets User /subscriptions/1111.../resourceGroups/payments-prod/providers/Microsoft.KeyVault/vaults/app-kv# Read the scope column, not only the role. alice is Owner *here* only because# the grant sits at the SUBSCRIPTION and inherits down. Nobody assigned it on the# resource group, and you would never spot it by looking at the group alone.
The badges that print badges
Most roles let you use resources. Turn a machine on, read a file, restart a database. A small set lets you hand out access instead, and those are the keys to the key cabinet. Three matter. Owner is full control plus the power to create role assignments. User Access Administrator (UAA) is the sneaky one. It can read everything and change almost no resource, so in a review it looks close to harmless, yet it holds Microsoft.Authorization/*, which includes writing role assignments. Anyone with UAA can assign themselves Owner and walk straight through. Role Based Access Control Administrator is the newer, tighter option, and the right default when a platform team needs to delegate access. On its own it can create and delete role assignments and read control-plane state, nothing else. Unlike UAA it cannot touch role definitions or the other authorization objects.
Its whole point is that you pin it behind an ABAC (Attribute-Based Access Control, rules that check attributes of a request before allowing it) condition at assignment time. The condition fences exactly which roles it may hand out, and to whom. May assign Reader. May never assign Owner or User Access Administrator. Leave the condition off and it still holds roleAssignments/write, which means it can grant anything, Owner included. The safety lives in the condition, not in the role's name. Treat a Role Based Access Control Administrator with no condition on a production subscription exactly as you would treat a standing User Access Administrator: a quiet, complete path to takeover that one forgotten grant leaves wide open.
# Find every principal that can hand out access across the scopes you can see.# The cond column is the whole story: a Role Based Access Control Administrator# with an empty condition is unconstrained, and just as dangerous as UAA.az role assignment list --all --include-inherited \--query "[?roleDefinitionName=='Owner' \|| roleDefinitionName=='User Access Administrator' \|| roleDefinitionName=='Role Based Access Control Administrator'] \.{who:principalName, type:principalType, role:roleDefinitionName, cond:condition, scope:scope}" \-o table
Who Type Role Cond Scope--------------- ---------------- --------------------------------------- ------ -----------------------------[email protected] User Owner /subscriptions/1111.../ci-deployer ServicePrincipal User Access Administrator /subscriptions/1111.../rg-ciplatform-admins Group Owner .../managementGroups/root-mg[email protected] User Role Based Access Control Administrator <cond> /subscriptions/1111.../rg-appbuild-bot ServicePrincipal Role Based Access Control Administrator /subscriptions/1111.../# ci-deployer is a service principal with User Access Administrator: a straight# self-escalation to Owner. build-bot is the same trap wearing the newer name,# an RBAC Administrator with an empty Cond can assign ANY role, Owner included.# dara's grant carries a condition (<cond> stands in for a long string), so hers# is fenced. Justify or cut the other two.
Here is what fencing one actually looks like. The condition below lets the holder write a role assignment only when the role being assigned is Reader. That long value, acdd72a7-3385-48ef-bd42-f606fba81ae7, is the built-in Reader ID, a GUID (globally unique identifier, a 128-bit label Azure uses to name a role for certain). Any attempt to assign Owner fails the condition and is refused before it is ever written. This fences the create side, which is the escalation you care about most. A full production condition adds a matching clause for the delete action, so the holder also cannot remove assignments it did not make.
# Grant Role Based Access Control Administrator, but fenced to assign Reader only.az role assignment create \--assignee [email protected] \--role "Role Based Access Control Administrator" \--scope /subscriptions/1111.../resourceGroups/rg-app \--condition "((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {acdd72a7-3385-48ef-bd42-f606fba81ae7}))" \--condition-version "2.0" \--description "Delegated: may assign Reader only" \--query "{condVer:conditionVersion, scope:scope}"
{"condVer": "2.0","scope": "/subscriptions/1111.../resourceGroups/rg-app"}# condVer came back as 2.0, so the fence attached. Verify the effect the honest# way: have dara try to assign Owner. She gets (AuthorizationFailed), because the# request's RoleDefinitionId is not Reader, so the condition evaluates false and# the write is refused. A null condVer would have meant no fence, no protection.
Custom roles: cut the key to fit the lock
When no built-in role fits, you write a custom role: a small JSON file (JavaScript Object Notation, a plain-text way to write down structured data) that lists the exact operations you want to allow. Four fields carry the meaning. Actions are control-plane operations, the ones that manage the resource itself (create a storage account, list its keys). DataActions are data-plane operations, the ones that reach inside the resource to the actual data (read a secret's value, not merely see that a secret exists). NotActions and NotDataActions subtract from those two. AssignableScopes pins where the role is even allowed to be used. The split between control plane and data plane is the part people get wrong. A role with storage Actions but no DataActions can manage the account and still never read a single byte of blob content (a blob is Azure's name for an arbitrary file kept in a storage account). Often that is exactly what you want.
Build the Actions list from the provider's real catalogue instead of guessing at the strings. az provider operation show prints every operation a resource type exposes, so you can grant containers/read without quietly shipping listKeys in the same role. listKeys is the operation that returns a storage account's master keys, which is effectively full access to every byte in it. A purpose-built role beats reaching for Contributor, the role that can change almost anything and is the most over-granted role in Azure. Custom roles cost you something honest. They are objects you now own and have to keep current as providers add new operations. Write them on purpose, not by reflex, and read the permissions back before anyone assigns them.
# Discover the real operations a resource type exposes (don't guess the strings):az provider operation show --namespace Microsoft.Storage \--query "resourceTypes[?name=='storageAccounts/blobServices/containers'].operations[].name" \-o tsv
Microsoft.Storage/storageAccounts/blobServices/containers/readMicrosoft.Storage/storageAccounts/blobServices/containers/writeMicrosoft.Storage/storageAccounts/blobServices/containers/delete# Note what is NOT here: listKeys lives one level up, at# Microsoft.Storage/storageAccounts/listkeys/action. Grant a container op and you# have not handed over the account keys, as long as you never add that action.
{"Name": "Blob Container Lister (payments)","IsCustom": true,"Description": "List containers only. No data access, no key retrieval.","Actions": ["Microsoft.Storage/storageAccounts/blobServices/containers/read"],"NotActions": [],"DataActions": [],"NotDataActions": [],"AssignableScopes": ["/subscriptions/1111.../resourceGroups/payments-prod"]}
# Create the role, then read its permissions back BEFORE anyone can assign it.az role definition create --role-definition @blob-lister.json \--query "{name:roleName, type:roleType}" -o tableaz role definition list -n "Blob Container Lister (payments)" \--query "[0].permissions[0].actions"
Name Type-------------------------------- ----------Blob Container Lister (payments) CustomRole["Microsoft.Storage/storageAccounts/blobServices/containers/read"]# listKeys is absent and DataActions is empty. Holders can enumerate containers# and never pull an access key or read a blob's contents. That is the whole point.
Deny assignments: the block that beats Owner
A deny assignment names a set of actions and a set of principals, and blocks those actions for those principals. It beats every granting role, Owner included. A court injunction taped over a door works the same way: your badge still says Owner, the lock still reads it, and the door still will not open, because the injunction is checked first and overrides the badge. Here is the part that surprises people. You cannot create one with az role assignment. Deny assignments are authored only by Azure itself, on behalf of managed applications (a service another team publishes and runs inside your subscription), deployment stacks (a newer way to deploy a bundle of resources and lock them down as one unit, through its deny settings), and the older Blueprints feature, which Microsoft has since deprecated. That is how a managed application can hand you a resource group you nominally own while stopping you from deleting the machinery underneath it.
You can read deny assignments, and you should, through Azure Resource Manager (ARM, the control-plane API sitting behind every az command). An unexpected one is the usual answer to the ticket that reads 'I am Owner but Azure says access denied.' Because they are read-only to you, they double as an audit signal. List them across a subscription and confirm each one traces back to a managed application or a deployment stack you actually installed, not to something that let itself in.
# Deny assignments have no first-class 'az' verb. Read them through the ARM API.az rest --method get \--uri "https://management.azure.com/subscriptions/1111.../resourceGroups/payments-prod/providers/Microsoft.Authorization/denyAssignments?api-version=2022-04-01" \--query "value[].{name:properties.denyAssignmentName, blocks:properties.permissions[0].actions[0], system:properties.isSystemProtected}" \-o table
Name Blocks System-------------------------------- -------------------------- ------Deny-managed-app-payments-engine Microsoft.Compute/*/delete True# Prove the effect: an Owner who runs 'az vm delete' on that VM gets# (AuthorizationFailed) ... does not have authorization to perform action# The deny wins over the Owner grant. isSystemProtected=True means the managed# app authored it; you cannot edit or remove it without uninstalling the app.
Make the dangerous badges temporary with PIM
Standing Owner and standing User Access Administrator are the exposures you most want gone. Not the roles themselves, but the fact that a person holds them all day, every day, for a permission they use twenty minutes a month. PIM (Privileged Identity Management, the feature that turns a role into something you check out rather than something you keep) fixes that. It makes a role eligible instead of active. Day to day the principal holds nothing. To use the role they activate it just in time, with a second sign-in factor, a written reason, optional approval, and a hard expiry, and every activation lands in the log. It works like a front desk that lends you a temporary badge for two hours and voids it on its own when the window ends. Azure CLI has no az pim command, so you drive PIM through its ARM endpoints with az rest: a role eligibility schedule grants the standing eligibility, and a SelfActivate request turns it on for a bounded window.
# Self-activate your ELIGIBLE Owner assignment for a 2-hour on-call window.# The roleDefinitionId below is the built-in Owner GUID (8e3af657-...).REQ=$(cat /proc/sys/kernel/random/uuid) # any fresh GUID names the requestaz rest --method put \--uri "https://management.azure.com/subscriptions/1111.../providers/Microsoft.Authorization/roleAssignmentScheduleRequests/$REQ?api-version=2020-10-01" \--body '{"properties": {"principalId": "8f3c...-my-object-id","roleDefinitionId": "/subscriptions/1111.../providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635","requestType": "SelfActivate","justification": "on-call incident INC-4821","scheduleInfo": { "startDateTime": null,"expiration": { "type": "AfterDuration", "duration": "PT2H" } }}}'# Confirm the activation is live and note exactly when it lapses:az rest --method get \--uri "https://management.azure.com/subscriptions/1111.../providers/Microsoft.Authorization/roleAssignmentScheduleInstances?api-version=2020-10-01" \--query "value[?properties.assignmentType=='Activated'].{role:properties.expandedProperties.roleDefinition.displayName, expires:properties.endDateTime}" -o table
Role Expires----- --------------------------Owner 2026-07-22T18:22:04Z# At 18:22 the activation lapses on its own. No cleanup task, no standing Owner# left behind to be found by the next audit, or by the next attacker.
az role assignment delete on its own can leave that identity fully operational for an hour or more. To cut access for real you do two things: remove the grant so the authorization path closes, and neutralise the identity so it cannot keep acting or sign back in. Revoke a user's sign-in sessions, or disable a service principal outright.So the incident-response move is two steps, never one. Strip the assignment, then kill the identity behind it. Here is the pair you run together when a service principal is compromised.
# 1. Remove the grant. This closes the Owner path once ARM's cache refreshes.az role assignment delete \--assignee 9f1e...-sp-object-id \--role "Owner" \--scope /subscriptions/1111.../# 2a. If the principal is a USER: revoke every issued session (Microsoft Graph).az rest --method POST \--uri "https://graph.microsoft.com/v1.0/users/<user-object-id>/revokeSignInSessions"# 2b. If it is a SERVICE PRINCIPAL: disable it so it cannot mint a new token.az rest --method PATCH \--uri "https://graph.microsoft.com/v1.0/servicePrincipals/9f1e...-sp-object-id" \--body '{"accountEnabled": false}'
# (1) delete returns no output on success.# (2a) revokeSignInSessions ->{"value": true}# (2b) PATCH servicePrincipals -> HTTP 204, empty body.# Now the removed grant and the disabled identity line up: the SP cannot mint a# new token, cannot sign back in, and loses authorization as ARM's cache clears,# so it is out within minutes, not whenever its old token would have expired.
Try this
Work through “Make the dangerous badges temporary with PIM” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: deleting a role assignment is not an instant kill switch. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.