Azure RBAC
Assignments, scope, least privilege, two planes.
An Azure subscription runs a lot like a hotel. Microsoft Entra ID, the identity directory you met in the previous lesson, is the front desk: it checks your passport and hands you a keycard. That is *authentication*, proving who you are. Azure RBAC (role-based access control) is the keycard system behind the doors. It decides which doors your card opens. That is *authorization*, deciding what you are allowed to do. Programming a card takes three answers: whose card is it, what kind of doors should it open, and on which floors does it work. Azure names those three the security principal, the role definition, and the scope. The programmed card itself, the thing that actually exists in your subscription, is a role assignment.
Hold those two systems apart in your head, because the AZ-104 exam keeps probing the seam between them. Entra ID roles such as Global Administrator and User Administrator govern the *directory*: users, groups, app registrations. Azure RBAC governs *resources*: virtual machines (VMs), storage accounts, networks. A Global Administrator can reset every password in the tenant (your organization's own instance of the directory) and still be unable to read a single blob, which is Azure's word for a file sitting in storage. A subscription Owner can delete every VM you have and still be unable to create one user account. Plenty of people need both. Each grant gets made deliberately, in its own system.
What a role assignment is actually made of
A role assignment ties a security principal to a role definition, at a scope. The principal is a user, a group, a service principal (an identity that belongs to an application rather than a person), or a managed identity (an identity Azure creates and rotates for a resource so nobody has to store a password). Azure ships hundreds of built-in role definitions, and four generic ones hold up the whole model. Reader sees everything and changes nothing. Contributor manages everything *except* access. Owner manages everything *including* access. User Access Administrator manages access and nothing else. The line between Contributor and Owner is classic exam material. A Contributor can drop a production database, yet cannot give anybody else permission to do the same. Only a role carrying Microsoft.Authorization/roleAssignments/write can hand out keys: Owner, User Access Administrator, or the tighter Role Based Access Control Administrator. Those are the assignments you audit hardest.
# Grant a group (never an individual) a resource-specific role, scoped to ONE resource groupaz role assignment create \--assignee-object-id "1f4b2c8e-93a1-4c7d-b1e6-2a9f0d3c5e77" \--assignee-principal-type Group \--role "Virtual Machine Contributor" \--scope "/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590/resourceGroups/rg-app-prod"# Expected output (trimmed):# {# "principalId": "1f4b2c8e-93a1-4c7d-b1e6-2a9f0d3c5e77",# "principalType": "Group",# "roleDefinitionId": ".../roleDefinitions/9980e02c-c2be-4d73-94e8-173b1dc7cf3c",# "scope": "/subscriptions/0b1f6471-.../resourceGroups/rg-app-prod",# "type": "Microsoft.Authorization/roleAssignments"# }
Three details in that command earn their keep. Passing --assignee-object-id skips the Microsoft Graph lookup that a plain --assignee performs for you, so the command still works when the caller has no permission to search the directory, and pairing it with --assignee-principal-type keeps scripts from tripping over Graph replication lag, the short window where a brand-new object exists in one place but has not copied to another yet. The assignee is a *group*, which turns joiners and leavers into membership changes instead of RBAC changes. And the scope is one resource group, not the whole subscription. Expect a wait before the grant bites. Azure Resource Manager caches authorization data, so a fresh assignment can take up to ten minutes to take effect. Do not burn an afternoon debugging a 403 (access denied) that you created ninety seconds ago.
Scope, inheritance, and what Azure does with your request
Scope is a path down the resource hierarchy, the way a folder path runs down a drive: management group → subscription → resource group → resource. Grant something at any level and everything below it inherits the grant. Reader at a management group means Reader on every resource in every subscription underneath. When a request lands at Azure Resource Manager (ARM, the single front door every Azure API call goes through), the evaluation is mechanical. ARM reads the principal out of your token. It gathers every role assignment and every deny assignment that could apply, whether you got it directly or through a group, at the target scope or anywhere above it. Deny assignments get checked first. If none match, ARM takes each role's Actions, subtracts that same role's NotActions, and unions the leftovers across every role you hold. Two consequences fall out of that. RBAC is additive: no assignment can cancel another, and NotActions only trims the role it lives in, so a second role can quietly hand back the action the first one took away. The one true "no" is a deny assignment, created by deployment stacks and Azure managed applications. (Azure Blueprints created them too and is being retired in favor of deployment stacks: phased retirement starts July 31, 2026, and final retirement lands January 31, 2027.) One matching deny beats any number of allows.
Control plane and data plane: the box, and what is inside it
A locked filing cabinet raises two separate questions. Who owns the cabinet, and who gets to read the folders inside it? Azure answers them with different roles, which is why any role definition you open has four permission lists rather than two. Actions and NotActions cover the control plane, meaning what you can do to a resource as an object: create it, delete it, read its configuration, rotate its keys. DataActions and NotDataActions cover the data plane, meaning what you can do to the contents: the blobs in a container, the messages in a queue, the secrets in a key vault. This catches people constantly. Reader on a storage account lets you *see* the account in the portal and will not let you read one blob with your Entra sign-in, because Reader has no DataActions at all. Reading blobs needs Storage Blob Data Reader. Microsoft keeps steering storage away from shared account keys and toward Entra sign-ins, so data-plane roles now decide who reaches real data. The storage lessons build straight on this.
az role definition list --name "Storage Blob Data Reader" --query "[0].permissions[0]"# {# "actions": [# "Microsoft.Storage/storageAccounts/blobServices/containers/read",# "Microsoft.Storage/storageAccounts/blobServices/generateUserDelegationKey/action"# ],# "dataActions": [# "Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read"# ],# "notActions": [],# "notDataActions": []# }# Control plane: see containers. Data plane: read the blobs themselves.
When no built-in role fits
Reach for a custom role only after the closest built-in has genuinely failed you. Every custom role you create is yours to document, review, and maintain for as long as it exists. A custom role is a JSON (JavaScript Object Notation, the plain-text format Azure reads definitions in) document: the same four permission lists plus AssignableScopes, which fences off where the role is allowed to be assigned at all. The classic case is an operations team that must start, stop, and restart VMs but must never create, delete, or resize them. No built-in role draws that exact line. Two limits are worth memorizing for the exam. A tenant holds at most 5,000 custom roles, and a custom role containing DataActions cannot be assigned at management-group scope.
cat > vm-operator.json <<'EOF'{"Name": "VM Operator","Description": "Start, stop, restart VMs. Cannot create, delete, or reconfigure.","Actions": ["Microsoft.Compute/virtualMachines/read","Microsoft.Compute/virtualMachines/start/action","Microsoft.Compute/virtualMachines/restart/action","Microsoft.Compute/virtualMachines/deallocate/action"],"NotActions": [],"AssignableScopes": ["/subscriptions/0b1f6471-1bf0-4dda-aec3-cb9272f09590"]}EOFaz role definition create --role-definition @vm-operator.json# {# "roleName": "VM Operator",# "roleType": "CustomRole",# "assignableScopes": ["/subscriptions/0b1f6471-..."],# ...# }# Note: deallocate (releases compute, stops billing) — not powerOff (billing continues).
Keeping access small, and auditing that it stayed small
Least privilege survives production as a set of habits rather than a slogan. Assign roles to groups, not to individuals. Pick the narrowest scope that still gets the work done. Prefer a resource-specific built-in like Virtual Machine Contributor or Network Contributor over blanket Contributor. Keep standing Owner assignments as close to zero as you can, and make privileged access time-bound through Privileged Identity Management (PIM), which lends someone a role for a few hours and takes it back on its own. Two more items belong on the audit checklist. Each subscription tops out at 4,000 role assignments, and past that ceiling new assignments fail outright, so sprawl turns into an outage rather than an eyesore. And any Global Administrator can flip the *Access management for Azure resources* toggle to grant themselves User Access Administrator at root scope (/), covering every subscription in the tenant at once. That is the designed break-glass bridge between the two planes. It shows up on the exam, and in real life it should page somebody the moment it happens.
# 1. Everything one person can do, including via groups and inherited scopesaz role assignment list --assignee [email protected] \--all --include-groups --include-inherited \--query "[].{role:roleDefinitionName, scope:scope}" -o table# Role Scope# --------------------------- -----------------------------------------------# Reader /subscriptions/0b1f6471-...# Virtual Machine Contributor /subscriptions/0b1f6471-.../resourceGroups/rg-app-prod# 2. Who can grant access to others (the escalation-capable roles)az role assignment list --all \--query "[?roleDefinitionName=='Owner' || roleDefinitionName=='User Access Administrator' || roleDefinitionName=='Role Based Access Control Administrator'].{who:principalName, scope:scope}" -o table# 3. Orphaned assignments pointing at deleted principalsaz role assignment list --all \--query "[?principalName==''].{principalId:principalId, role:roleDefinitionName, scope:scope}" -o table
principalId while the old assignment keeps pointing at the dead one. Sweep for orphans with query 3 above, and favor group assignments so staff churn moves through group membership instead of through RBAC.RBAC answers one question: *who can do what, where*. That is the whole of its job. Nothing in RBAC stops a perfectly authorized Contributor from deploying an untagged, oversized VM in a region your company never approved. Nothing stops an Owner from deleting a critical resource in one fat-fingered moment. Limiting what is allowed to *exist*, no matter who is asking, belongs to a different layer of control: Azure Policy for the rules, resource locks for tamper-proofing, tags for accountability. That governance layer is exactly where the next lesson picks up.
Scope is the part people rush past. The same Contributor role at a management group can rebuild every subscription underneath it. That same role at a single storage account can touch one resource and nothing else. So when somebody asks "why can they delete my VM?", run az role assignment list at the resource first, then at the resource group, then at the subscription. Inheritance means the permission is often sitting a level or two above the object you are staring at.
Built-in roles cover most day-to-day work: Reader, Contributor, Owner, and the service-specific ones like Storage Blob Data Contributor over on the data plane. Custom roles exist for the gaps, and each one is a maintenance tax you pay forever. Stay on built-ins until you can write out the exact Actions and NotActions you need. And keep remembering that data-plane roles (Key Vault, Storage, AKS or Azure Kubernetes Service) sit apart from the management-plane Contributor you already know.
Try this
Pick a resource group you can afford to break in a lab subscription. Give yourself Reader on the group, try a write, and watch it fail. Then raise yourself to Contributor on that one group and confirm the same write goes through.
RG=rg-lab-rbacaz group create -n $RG -l eastus# Least privilege: Reader at the RGaz role assignment create \--assignee $(az account show --query user.name -o tsv) \--role Reader \--scope $(az group show -n $RG --query id -o tsv)az role assignment list --scope $(az group show -n $RG --query id -o tsv) -o table
$ az role assignment list --scope /subscriptions/.../resourceGroups/rg-lab-rbac -o tablePrincipal Role Scope------------------------ ------- ----------------------------------[email protected] Reader /subscriptions/.../rg-lab-rbac# Sample output — write blocked while Reader:# AuthorizationFailed: does not have authorization to perform action 'Microsoft.Storage/...'
Takeaway
An RBAC assignment is always three parts: who (the principal), what (the role), and where (the scope). Permissions flow downhill through the hierarchy, and a deny assignment beats any allow above it.
Keep practising the two planes: Entra ID roles for the directory, Azure RBAC for resources. And when Contributor on one resource group does the job, never reach for Owner across the whole subscription.