7. Production
The difference between "I granted the role and it worked" and "I run the access model for an estate". RBAC is unusual among Azure topics here: it costs nothing to run, so the production concerns are almost entirely about governance decay — an access model is correct on the day it's designed and wrong eighteen months later unless something actively holds it in place.
[Image Prompt: 2D minimalistic diagram of five production pillars — security, cost, scaling and limits, observability, and reliability — arranged around a central Azure RBAC role assignment object, flat design, clean vector art style, white background]
Security
RBAC is a security control, so this section is about securing the control itself.
Least privilege, expressed as decisions
Least privilege is easy to agree with and hard to operationalise. Four concrete decisions carry most of the value:
1. Groups, not people. Every standing assignment targets an Entra group. The exception is PIM-eligible assignments, where a person's name on the activation record is the point. This turns access review into membership review and keeps you well under the assignment ceiling.
2. Narrowest scope that works, defaulting to the resource group. Sub-resource scopes — a single blob container, a single queue, a single secret — are underused and dramatically narrower than the parent. Reach for them for workload identities, where the required access is precisely known.
3. Service-specific Contributor over Contributor. Contributor can delete every resource in
scope. Website Contributor + Storage Blob Data Contributor usually covers the same job and is about
ten minutes more work. The reasoning "they're not Owner, so it's contained" misreads what containment
means.
4. No standing privileged access. Where the licence allows it, Owner and User Access Administrator are PIM-eligible, never assigned. Where it doesn't, they're held by a small named group
with an alert on every use.
The permissions that are escalation paths
Four grants are qualitatively different from the rest, because holding any of them means you can grant yourself everything else. Treat them as a separate category with separate review:
| Permission | Held by | Why it's terminal |
|---|---|---|
Microsoft.Authorization/roleAssignments/write |
Owner, User Access Administrator, RBAC Administrator | Grant yourself anything, anywhere in scope |
Microsoft.Authorization/roleDefinitions/write |
Owner, User Access Administrator | Author a custom role with * and assign it |
Microsoft.Authorization/elevateAccess/action |
Global Administrators, on request | User Access Administrator at root scope / — the whole tenant |
Reading a resource's keys (listKeys/action) |
Most service Contributor roles | Bypasses data-plane RBAC entirely |
The fourth is the subtle one and worth restating: Storage Account Contributor is effectively
Storage Blob Data Owner as long as shared-key access is enabled, because it can read the account
keys. The same shape applies to Service Bus SAS rules, Event Hubs, and Cosmos DB keys. Your data-plane
role design is only load-bearing once the key path is closed.
Kill the parallel authorisation paths
The single highest-value hardening pass in an Azure estate, and it's free:
# Storage: RBAC becomes the only door to blob/queue/table data
az storage account update -g rg-app -n stapp --allow-shared-key-access false
# Key Vault: retire access policies
az keyvault update -g rg-app -n kv-app --enable-rbac-authorization true
# AKS: retire the admin kubeconfig that bypasses every Azure role assignment
az aks update -g rg-app -n aks-app --disable-local-accounts
# Azure SQL: retire SQL logins
az sql server ad-only-auth enable -g rg-app -n sql-app
Enforce them with Azure Policy rather than a runbook — there are built-in definitions covering several of these ⚠️ verify the current built-in set against current Azure docs. A convention says "we don't enable shared keys"; a policy makes it fail.
Managed identity, and the trap that bites in production
Use managed identities for everything inside Azure and workload identity federation for everything outside it. No client secrets, no connection strings, no publish profiles.
The production-specific trap: a system-assigned identity gets a new object ID whenever its resource is
replaced. The role assignment then references a dead principal, terraform apply still succeeds, the
pipeline goes green, and the application 403s at runtime. Use user-assigned identities for anything
that gets recreated, which in practice means anything managed by IaC.
Break-glass accounts
If your only path to Owner is PIM activation, and the thing that's broken is the activation path, you
have no path. Two emergency accounts, and the details matter:
- Cloud-only, excluded from Conditional Access policies that could lock them out, with credentials split across two people or held in a physical safe.
- Standing
Ownerat the root management group — the one place standing privilege is correct. - Alerting on any sign-in, wired to a pager rather than an inbox. The account existing is fine; the account being used silently is not.
- Tested twice a year. An untested break-glass account is a documented assumption.
Guest and cross-tenant access
Guest users are legitimate principals and can hold any Azure role. Two things worth checking regularly:
that guests aren't holding privileged roles (a partner's engineer with Contributor on production is a
supply-chain risk you've accepted implicitly), and that subscription tenant moves haven't orphaned
everything. Moving a subscription between tenants invalidates every role assignment and managed
identity in it, because those principals lived in the old directory. Nobody expects this the first time
and there is no quick fix.
Cost
Azure RBAC is free. No meter, no SKU, no per-assignment charge. What costs money is the governance layer around it, and the honest framing is what you're buying rather than what you're paying:
| Feature | Licence | What it buys |
|---|---|---|
| Azure RBAC, custom roles, ABAC conditions | Free | Everything on this page except the rows below |
| PIM — just-in-time activation, approval, expiry | Entra ID P2 or Entra ID Governance | Turns "who is an Owner" from a standing list into an activation log |
| Access reviews | P2 / Governance | Recurring attestation; the mechanism that stops decay |
| Conditional Access on the Azure management plane | P1 | Require MFA or a compliant device before a token for ARM is issued at all |
| Identity Protection risk signals | P2 | Risk-based blocking before authorisation is reached |
⚠️ verify current feature-to-licence mapping and pricing against current Azure docs — Microsoft moves features between tiers.
The cost trap is a licensing one, not a billing one. Entra licences are per user who benefits from the feature, not per tenant. Nothing blocks you technically from applying a P1-gated Conditional Access policy to unlicensed users; you find out at audit. If PIM covers thirty privileged people, licence thirty — not the whole organisation, and not zero.
The optimisation worth naming: PIM is usually far cheaper than people assume, because the population that needs privileged access is small. The comparison to make is not "P2 for everyone" but "P2 for the twenty people who currently hold standing Owner" — against the cost of one incident caused by one of them.
What keeps costing when nothing is happening: nothing, for RBAC itself. The Log Analytics workspace holding your activity log does have a real ingestion and retention bill, and administrative logs are low-volume enough that it's rarely the line item anyone notices. Set a retention period deliberately anyway — audit requirements are usually longer than the default.
Scaling and limits
RBAC has no throughput to provision, but it has ceilings, and the scope each is counted at matters more than the number. A limit without a scope is useless in Azure.
| Limit | Counted at | Practical note |
|---|---|---|
| Role assignments | Per subscription | The one estates actually hit. Group-based assignment is the mitigation and it's usually a 10–50× reduction ⚠️ verify current limit |
| Role assignments | Per management group | A separate, smaller ceiling ⚠️ verify |
| Custom role definitions | Per tenant | Not per subscription — shared across the whole estate ⚠️ verify |
| Role assignments per PIM eligible schedule | Per scope | ⚠️ verify |
| Groups in a token before overage | Per token | Beyond the threshold, Entra emits a group overage claim instead of the list ⚠️ verify current threshold |
| ABAC condition length | Per assignment | One condition per assignment ⚠️ verify |
Hard vs. soft: the assignment ceilings have historically been raisable in some cases via support request ⚠️ verify current behaviour against current Azure docs. Treat them as hard when designing — an architecture that needs the limit raised is an architecture that should have used groups.
The group overage claim deserves attention because the failure is so indirect. A user in very many groups receives a token without their group list; Azure's own evaluators handle it by querying Graph, but application code that reads groups from the token concludes the user is in none. The symptom is one particular power user losing access to your app while RBAC works fine.
Estate-wide queries: use Azure Resource Graph, not a loop. Enumerating assignments across hundreds
of resource groups with az role assignment list will hit ARM throttling (429 with Retry-After).
Resource Graph is the query engine built for this and doesn't consume the same budget:
authorizationresources
| where type =~ "microsoft.authorization/roleassignments"
| extend principalId = tostring(properties.principalId),
roleDefId = tostring(properties.roleDefinitionId),
scope = tostring(properties.scope)
| summarize assignments = count() by subscriptionId
| order by assignments desc
That query is also your early warning on the per-subscription ceiling.
Observability
Nothing about RBAC is logged usefully by default. The activity log captures assignment changes but is retained only briefly unless routed somewhere. This is the diagnostic setting to create on day one of a subscription's life.
az monitor diagnostic-settings subscription create \
--name activity-to-law \
--location uksouth \
--workspace "$(az monitor log-analytics workspace show -g rg-platform-obs -n law-platform --query id -o tsv)" \
--logs '[{"category":"Administrative","enabled":true},{"category":"Security","enabled":true},{"category":"Policy","enabled":true}]'
The log categories that matter
| Source | Table | Answers |
|---|---|---|
| Activity log, Administrative | AzureActivity |
Who created, changed, or deleted a role assignment or definition |
| Entra sign-in logs | SigninLogs |
Which identity actually authenticated, and whether Conditional Access intervened |
| Entra audit logs | AuditLogs |
Directory-role changes, group membership changes, PIM activations |
| Entra PIM | AuditLogs / PIM reports |
Who activated what, when, with what justification |
Note that group membership changes are in AuditLogs, not AzureActivity. If you assign to groups
— as you should — then the log that answers "who gained access to production" is the directory audit
log, not the Azure activity log. An estate that monitors only the latter has a blind spot exactly where
its access model lives.
The KQL query worth keeping
The most common question about RBAC in production is "who granted this, and when":
AzureActivity
| where OperationNameValue in~ (
"MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE",
"MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/DELETE",
"MICROSOFT.AUTHORIZATION/ROLEDEFINITIONS/WRITE",
"MICROSOFT.AUTHORIZATION/ELEVATEACCESS/ACTION")
| where ActivityStatusValue == "Success"
| extend props = parse_json(Properties)
| extend body = parse_json(tostring(props.requestbody))
| project TimeGenerated,
op = OperationNameValue,
Caller,
CallerIpAddress,
scope = tostring(props.entity),
roleId = tostring(body.Properties.RoleDefinitionId),
principal = tostring(body.Properties.PrincipalId)
| order by TimeGenerated desc
The three alerts to configure
1. Privileged role assigned. Owner or User Access Administrator written at subscription or
management-group scope. Should notify the security channel within minutes.
2. Elevate access used. MICROSOFT.AUTHORIZATION/ELEVATEACCESS/ACTION, unconditionally, to a pager.
A Global Administrator granting themselves root-scope User Access Administrator is legitimate as
break-glass and must never be silent.
3. Break-glass account sign-in. From SigninLogs, any authentication by the emergency accounts.
4. Custom role definition changed. Lower urgency, but a custom role gaining a wildcard is a silent widening of everything assigned it.
Baseline hygiene queries
Run these monthly; each has produced findings in every estate I'd expect you to inherit.
# Standing privileged assignments anywhere
az role assignment list --all \
--query "[?roleDefinitionName=='Owner' || roleDefinitionName=='User Access Administrator'].{role:roleDefinitionName, principal:principalName, type:principalType, scope:scope}" \
-o table
# Orphaned assignments — principal no longer resolves
az role assignment list --all \
--query "[?principalName==null].{role:roleDefinitionName, oid:principalId, scope:scope}" -o table
# Direct-to-user assignments, which should be rare
az role assignment list --all --query "[?principalType=='User'].{principal:principalName, role:roleDefinitionName, scope:scope}" -o table
# Custom roles, and whether any contains a wildcard
az role definition list --custom-role-only true \
--query "[].{name:roleName, actions:permissions[0].actions}" -o json
# Guests holding any role
az role assignment list --all --query "[?contains(to_string(principalName), '#EXT#')]" -o table
The orphaned assignments query is the one people are most surprised by. They accumulate silently from every deleted service principal and every replaced system-assigned identity, they count against the subscription ceiling, and they make an access audit unreadable.
Access reviews as the anti-decay mechanism
Queries detect. Access reviews correct. A recurring review that asks each group's owner "does everyone here still need this" is the only mechanism that reliably removes access, because nobody ever files a ticket to lose a permission. Requires P2/Governance licensing ⚠️ verify. Without it, the manual equivalent is a quarterly calendar entry and the discipline to act on it — which is worse but not nothing.
Reliability
RBAC's availability is ARM's availability; there's no separate SLA ⚠️ verify current terms against the current SLA documentation. Three properties shape how you plan around it.
Authorisation data is replicated and read-optimised. Evaluation survives conditions that would prevent you from changing assignments. In a control-plane incident you can typically keep using access you already hold while being unable to grant new access — which is exactly the wrong way round during an incident, and the reason break-glass accounts must pre-exist rather than be created on demand.
Propagation is eventually consistent, in both directions. A new assignment takes time to become effective ⚠️ verify current guidance. So does a removal — a revoked grant remains usable for the life of the cached token plus the propagation window. For a genuine compromise, deleting the assignment is insufficient: revoke the principal's refresh tokens or disable the principal.
# Actually cut off a compromised user, in order
az ad user update --id user@contoso.com --account-enabled false
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/users/user@contoso.com/revokeSignInSessions"
Regional outages don't stop authorisation but can stop the resources being authorised. RBAC is not in the failure path you need to design around; the resources are.
The failure drill worth rehearsing
Twice a year, with someone who hasn't done it before:
- Break-glass activation. Sign in with an emergency account, confirm root-scope access, confirm the alert fired. This is the one that's always broken when it's finally needed.
- Revocation timing. Remove a role assignment from a test identity and measure how long it keeps working. Everyone's mental model of this is wrong until they've measured it.
- Orphaned-identity recovery. Force-replace a resource with a system-assigned identity and observe
the runtime 403 that
terraform applyreported as success. The lesson lands better than reading it. - Reconstruct access from code. Delete a resource group's assignments and restore them with
terraform apply. If that doesn't fully restore access, you have undeclared grants — which is the finding, not the failure.
Number 4 is the drill that validates the whole "access is code" premise. Most estates fail it the first time.
The production checklist
- Every standing assignment targets a group, not a person
-
OwnerandUser Access Administratorare PIM-eligible, not assigned - Two break-glass accounts exist, are excluded from lockout policies, alert on sign-in, and were tested this year
- Shared keys, Key Vault access policies, AKS local accounts, and SQL logins are disabled, enforced by Policy
- Workloads use user-assigned managed identities; pipelines use workload identity federation
- The activity log is routed to a Log Analytics workspace with deliberate retention
- Alerts exist for privileged assignment, elevate access, and break-glass sign-in
- Every managed assignment sets
description, so undeclared grants are queryable - Nightly
terraform planfor drift, plus a Resource Graph query for assignments outside state - Access reviews recurring on every group holding a privileged role
- Orphaned assignments swept monthly
- Custom roles enumerate actions — no wildcards
- Assignment counts per subscription tracked against the ceiling
Next: Interview Questions →
← Back to the Azure RBAC overview · ← Previous: Integrations