3. Architecture
The machinery most tutorials skip. RBAC looks like a lookup table and behaves like a distributed cache with two independent evaluators, and almost every confusing authorisation failure in Azure comes from one of those two facts rather than from a wrong role.
The authorisation decision, traced end to end
Consider the simplest possible request: a web app's managed identity reading a blob. Two things happen that look like one, and separating them is the point of this page.
Phase 1 — the token.
- Code inside the app calls
DefaultAzureCredential, which reaches the Instance Metadata Service at a link-local address that isn't routable from outside the host. - IMDS returns a JWT signed by Microsoft Entra ID, scoped to an audience — and the audience
matters enormously. A token for
https://management.azure.comis not accepted byhttps://storage.azure.comand vice versa. The SDK requests the right one; hand-rolled code frequently doesn't. - The token carries the principal's
oid(object ID), the tenanttid, group membership claims, and an expiry. Role assignments are not in the token. This is the single most important structural fact on the page — the token proves identity, and authorisation is looked up fresh at the resource.
Phase 2 — the decision.
- The request goes to
https://stapp.blob.core.windows.net/uploads/report.csvwith the token as a bearer header. Note the endpoint: this is not ARM. - The Storage service validates the signature against Entra's published key set, extracts the
oid, and asks the authorisation system: does this principal holdMicrosoft.Storage/storageAccounts/blobServices/containers/blobs/readat this scope? - The evaluator gathers every role assignment applying at or above the blob's scope — the
container, the storage account, the resource group, the subscription, every ancestor management
group — and unions the
dataActionsof their role definitions. - If the operation string matches, and any ABAC condition on the matching assignment evaluates true, the read proceeds. Otherwise: HTTP 403.
The corresponding control-plane request — say, reading the account's network rules — goes to
management.azure.com instead, and ARM performs step 6 against actions rather than dataActions.
Same principal, same scope tree, different evaluator and a different array.
[Image Prompt: 2D minimalistic numbered sequence diagram tracing an authorisation decision, from a workload fetching a token from the instance metadata service, through Microsoft Entra ID signing the token, then splitting into two paths — one to Azure Resource Manager evaluating actions for a control-plane call, one to a service data endpoint evaluating dataActions for a data-plane call — with role assignment lookup shown as a separate step at each evaluator, flat design, clean vector art style, white background]
Control plane vs. data plane — the split, precisely
Azure's split is sharper than AWS's and it catches people constantly. Stated as plainly as possible:
| Control plane | Data plane | |
|---|---|---|
| Endpoint | management.azure.com |
stapp.blob.core.windows.net, kv.vault.azure.net, ns.servicebus.windows.net … |
| Evaluator | Azure Resource Manager | The service itself |
| Role arrays | actions / notActions |
dataActions / notDataActions |
| Governs | Existence and configuration of the resource | The contents |
| Example operation | Rotate the storage account keys | Read report.csv |
| Covered by Owner? | Yes | No |
Owner does not let you read a blob. Nor does Key Vault Contributor let you read a secret, nor
Azure Service Bus Contributor let you send a message. Data access requires a role carrying
dataActions — Storage Blob Data Reader, Key Vault Secrets User, Azure Service Bus Data Sender.
The diagnostic shortcut: look at the hostname in the error. If it isn't management.azure.com,
no amount of Owner will help.
The back door this creates
An Owner or Storage Account Contributor cannot read a blob directly — but they can read the
account keys, and a key grants unrestricted data access outside RBAC entirely. So control-plane
privilege is a path to data privilege, just an indirect and auditable one.
This is precisely why disabling key-based auth is a real control rather than theatre:
# Make RBAC the only door to the data
az storage account update -g rg-app -n stapp --allow-shared-key-access false
The same shape recurs: Key Vault's enableRbacAuthorization = true retires access policies, AKS's
disableLocalAccounts retires the admin kubeconfig, and Service Bus namespaces can have their SAS
rules removed. Until you flip these, you have two authorisation systems and only one of them is the
one you designed.
The evaluation algorithm
Three properties, and everything surprising follows from them.
1. It is a union. The effective permission set is the union of every role assignment applying at or above the scope. Ten assignments granting Reader and one granting Contributor produce Contributor.
2. There is no author-able deny. notActions subtracts within a single role definition,
evaluated before the union. So:
Assignment A: Contributor (actions: */*, notActions: Microsoft.Authorization/roleAssignments/write)
Assignment B: User Access Administrator (actions: Microsoft.Authorization/*)
Effective: full Contributor AND the ability to write role assignments.
Assignment A's notActions did not prevent anything. It described what A alone grants. This is the
mistake that produces a real privilege escalation in an estate that believed it had a boundary.
The one override is a deny assignment, which outranks role assignments and is created only by the platform. You can read them; you cannot write them.
3. Inheritance is downward only and cannot be revoked from below. An assignment at the subscription cannot be narrowed at a resource group. It must be deleted where it was made. In an estate with several years of accreted assignments, this is the reason a "who can delete production" audit is genuinely hard: the answer lives in assignments at four levels, several of them made by people who have left.
Evaluation order in practice
When a request arrives, several independent systems can refuse it, and they produce different error codes. Reading the code first saves the afternoon:
| Order | System | Error code | Will a role fix it? |
|---|---|---|---|
| 1 | Conditional Access (at token issuance, before the request exists) | Token never issued; sign-in log has the answer | No |
| 2 | Deny assignment | AuthorizationFailed, but for an Owner |
No |
| 3 | Azure RBAC | AuthorizationFailed |
Yes |
| 4 | Azure Policy | RequestDisallowedByPolicy |
No |
| 5 | Resource lock | ScopeLocked |
No |
| 6 | Resource provider validation / quota | Provider-specific, QuotaExceeded |
No |
Only row 3 is an RBAC problem. Rows 4 and 5 are the two most often misdiagnosed as RBAC, and both waste time if you start by granting roles.
Consistency, propagation, and caching — where the latency lives
RBAC is not strongly consistent, and this produces the most common false negative in the whole system: you make the assignment, it's visibly there in the portal, and the request still 403s.
Three separate caches sit between an assignment and its effect:
1. Assignment replication. Role assignment data replicates across regions. A freshly created assignment takes a short while to be visible to every evaluator ⚠️ verify current propagation guidance against current Azure docs. This is why Terraform sometimes needs a wait between creating an assignment and creating a resource that depends on it — covered in Deployment.
2. Token claims. Group membership is carried in the token. Adding a principal to a group has no effect on an already-issued token; it keeps the old claims until it expires. Sign out and back in, or force a refresh. For a managed identity, the platform caches tokens too, so a restart is sometimes the fastest fix.
3. Service-side authorisation caches. Individual services cache decisions briefly.
The operational rule: after a permission change, wait, then force a fresh token, then retest. Debugging a 403 within seconds of making the assignment tells you nothing.
There is a mirror-image problem that matters more for security than convenience: removing a role assignment is also not instant. A revoked grant remains effective for the life of the cached token and the propagation window. For a genuine compromise, deleting the assignment is not enough — you revoke the principal's refresh tokens, or disable the principal outright.
The scope tree at evaluation time
The evaluator walks the ancestry of the target resource ID. For a blob:
container .../storageAccounts/stapp/blobServices/default/containers/uploads
account .../storageAccounts/stapp
rg /subscriptions/{sub}/resourceGroups/rg-app
sub /subscriptions/{sub}
mg /providers/Microsoft.Management/managementGroups/mg-platform
mg (root) /providers/Microsoft.Management/managementGroups/{tenant-id}
root /
Every level is queried. Two consequences worth internalising:
Management-group assignments are easy to forget and apply everywhere. A Reader at the tenant
root management group is a Reader on every resource in every subscription, forever. It's often the
right call for a security team, and it's also the answer to "why can this person see production" when
nobody assigned them anything in production.
Assigning at the resource group won't cover a resource in a sibling group. Obvious stated
plainly, and responsible for a large share of "I assigned it and it didn't work" — the resource was
somewhere else. --include-inherited and reading the scope in the error message resolve it in
seconds.
Scaling model and limits
RBAC has no throughput to provision, but it has real ceilings, and — this is the Azure-specific part — the scope each is counted at matters more than the number.
| Limit | Counted at | Notes |
|---|---|---|
| Role assignments | Per subscription | The one people actually hit. Group-based assignment is the mitigation ⚠️ verify current limit against current Azure docs |
| Role assignments | Per management group | Separate, smaller ceiling ⚠️ verify |
| Custom role definitions | Per tenant | Not per subscription. Shared across everything ⚠️ verify |
| Groups in a token | Per token | Beyond a threshold, Entra emits a group overage claim instead of the group list, and the app must query Graph. Breaks naive apps ⚠️ verify current threshold |
| ABAC conditions | Per assignment | One condition per assignment; length limits apply ⚠️ verify |
The group-overage claim deserves its own line because the failure is so indirect: a user in very many groups gets a token without their group list, and an application that reads groups from the token concludes they're in none. Azure's own evaluators handle it; your code might not.
ARM control-plane throttling applies to reading and writing assignments like any other ARM
operation: 429 with a Retry-After header. A pipeline that enumerates assignments across hundreds
of resource groups will hit it. Use Azure Resource Graph for estate-wide queries instead of
looping az role assignment list — it's the query engine built for exactly this and it doesn't
consume the same budget.
Failure modes worth recognising
403 immediately after granting. Propagation or a cached token. Wait, refresh, retest.
403 on the data plane while holding Owner. Wrong plane. Look at the hostname. You need a
dataActions role.
403 for a wrong-audience token. Hand-rolled token acquisition asking for
https://management.azure.com and calling vault.azure.net. The signature validates and the
audience check fails; the error can look like a permission problem.
Worked yesterday, fails today, nothing changed. A system-assigned managed identity whose resource was replaced. New object ID, orphaned assignment, runtime-only failure. Check whether the assignment's principal still resolves.
ScopeLocked. A CanNotDelete or ReadOnly lock, quite possibly inherited from the resource
group. Not RBAC.
RequestDisallowedByPolicy. Azure Policy. Different system, different owner, no role will help.
An Owner is refused. Deny assignment, or a lock. Check both before assuming a platform bug.
Terraform 403s on an assignment it created moments ago. The classic ordering problem: the assignment exists but hasn't propagated to the evaluator the next resource talks to.
Subscription moved between tenants and everything broke. Every role assignment and managed identity referenced principals in the old directory. They're all orphaned. Nobody expects this the first time and there is no quick fix — the assignments have to be recreated.
Reliability posture
RBAC's availability is ARM's availability; there is no separate SLA ⚠️ verify current terms against the current SLA documentation. Two practical implications:
Authorisation data is replicated and read-optimised, so evaluation survives conditions that would stop you from changing assignments. In a control-plane incident you can often still use resources you already have access to while being unable to grant new access.
That makes break-glass a real design requirement. If your only path to Owner is PIM activation and the thing that's broken is the activation path, you need a pre-existing, credential-managed, alarmed-on-use emergency account. Production covers what that looks like.
Next: Getting Started →
← Back to the Azure RBAC overview · ← Previous: Core Concepts