I’ve seen it happen more times than I can count. I’ll walk into an organization using Azure, and the subscription looks like a digital Wild West. Every developer, contractor, and their dog has the Contributor
role assigned at the subscription scope. Then comes the inevitable horror story: a simple script meant to clean up a dev environment runs amok, and because of those wide-open permissions, it vaporizes a production resource group. The post-mortem is always the same: a mix of finger-pointing and the quiet, sinking realization that this was a completely avoidable disaster.
The common, but wrong, reaction is to strip everyone of all permissions, forcing them to file a ticket for every minor change. This grinds productivity to a halt and fosters a culture of frustration. The root cause isn’t a malicious engineer; it’s the absence of a sane, deliberate Role-Based Access Control (RBAC) strategy. Handing out Contributor
like candy isn’t empowering your team; it’s setting them up to fail spectacularly.
This is my field-tested blueprint for building an Azure RBAC strategy on the principle of least privilege. It’s designed to give your team the access they need to be productive, the security guardrails to prevent catastrophes, and the audited emergency access required to fix things when they inevitably break.
Architecture Context: The Three-Tier Azure RBAC Model
Before we write any Terraform, we must discard the “one role to rule them all” mindset. The goal is to create a tiered system where permissions are scoped tightly, granted based on demonstrable need, and elevated only when necessary. We’re building a secure “paved road” for our engineers, not an eight-lane superhighway with no speed limits.
Our model is built on three foundational layers of access:
- Read-Only Baseline: The default for everyone. The built-in
Reader
role allows for investigation and observability across environments without the risk of accidental changes. - Developer Role: A custom-defined role that grants permissions to create and manage resources, but is strictly constrained to non-production resource groups and enforced by tag-based conditions (ABAC). This is the “day job” role.
- Break-Glass Elevation (PIM): Emergency, just-in-time (JIT) elevation to a high-privilege role like
Owner
or a custom admin role using Azure AD Privileged Identity Management (PIM). This access is temporary, requires justification, is heavily audited, and triggers alerts.
Here is the conceptual flow:
+-----------------+ +-----------------------+ +-------------------------+
| | | | | |
| Azure AD Login |----->| Default: Reader |----->| Azure Portal / CLI |
| (user@corp.com) | | (Subscription Scope) | | (View-Only) |
| | | | | |
+-----------------+ +-----------+-----------+ +-------------------------+
| |
| STS: AssumeRole |
+-----------------------------+--------------------------------+
| |
v (For Daily Work) v (For Emergencies)
+-----------------------------+ +-----------------------------------+
| | | |
| Assign Custom Developer | | Request via Azure PIM |
| Role | | (Privileged Identity Management) |
| | | |
+--------------+--------------+ +-----------------+-----------------+
| |
| Condition: | 1. Provide Justification (Ticket #)
| @Resource/tags/Env IN ('dev', 'staging') | 2. Activate for Time-Bound Period (e.g., 1 hr)
v v
+-----------------------------+ +-----------------+-----------------+
| | | |
| Dev / Staging Resources | | Activated High-Privilege Role |
| (Write Access Granted) | | (e.g., Owner) |
| | | |
+-----------------------------+ +-----------------+-----------------+
|
+-------------------+-------------------+
| |
v v
+-----------------------------+ +---------------------------------+
| | | |
| Production Resources | | AUDIT & ALERTING |
| (Temporary Full Access) | | - Azure Monitor Activity Log |
| | | - Alerts to Teams/Email |
+-----------------------------+ +---------------------------------+
This architecture flips the default from “permit-all” to “deny-by-default,” making privilege escalation a deliberate, audited action.
Implementation Details
Let’s dive into the implementation of this system using Terraform. Managing RBAC through the portal is a recipe for configuration drift and security holes. Code is our source of truth.
1. The Read-Only Baseline
We start by assigning the built-in Reader
role at a high level, like a Management Group or the entire Subscription. This ensures everyone can log in and look around without breaking anything.
# /modules/rbac/assignments/readonly.tf
# Assign the built-in "Reader" role to the main developers group
# Scope is set to the entire subscription
data "azurerm_subscription" "primary" {}
data "azuread_group" "all_developers" {
display_name = "All Developers"
}
resource "azurerm_role_assignment" "subscription_reader" {
scope = data.azurerm_subscription.primary.id
role_definition_name = "Reader"
principal_id = data.azuread_group.all_developers.object_id
}
This establishes a secure, foundational default state.
2. The Developer Role with Guardrails (ABAC)
Here’s the core of our strategy. We won’t use the overly-permissive Contributor
role. Instead, we’ll create a custom role and then use an ABAC Condition (Attribute-Based Access Control) to enforce that it can only be used on resources with specific tags.
First, the custom role definition:
# /modules/rbac/roles/developer.tf
data "azurerm_subscription" "primary" {}
resource "azurerm_role_definition" "developer_role" {
name = "CustomDeveloperRole"
scope = data.azurerm_subscription.primary.id
description = "Allows developers to manage resources in non-prod environments."
permissions {
# Allow a broad set of actions needed for development
actions = [
"Microsoft.Compute/virtualMachines/*",
"Microsoft.Storage/storageAccounts/*",
"Microsoft.Network/virtualNetworks/subnets/*",
"Microsoft.Resources/deployments/*"
]
# Specifically deny high-risk actions, even if included above
not_actions = [
"Microsoft.Authorization/*/Write"
]
}
assignable_scopes = [
data.azurerm_subscription.primary.id,
]
}
Now, we assign this role to our developers, but with a critical condition attached. This role assignment only works if the resource they are targeting has the correct Environment
tag.
# /modules/rbac/assignments/developer.tf
resource "azurerm_role_assignment" "developer_abac" {
scope = data.azurerm_subscription.primary.id
role_definition_id = azurerm_role_definition.developer_role.role_definition_resource_id
principal_id = data.azuread_group.all_developers.object_id
# This is the core logic. The condition acts as a real-time gatekeeper.
condition_version = "2.0"
condition = <<-EOT
(
(exists @Request/resource/tags/Environment)
AND
(@Request/resource/tags/Environment In ('dev', 'staging'))
)
OR
(
(exists @Resource/tags/Environment)
AND
(@Resource/tags/Environment In ('dev', 'staging'))
)
EOT
}
With this in place, a developer can create a virtual machine in the dev-testing-rg
resource group, but only if they tag it with Environment = dev
. Attempting to touch anything tagged with prod
results in an immediate AccessDenied
. You’ve built guardrails directly into the fabric of Azure.
Architect’s Note
Use Azure Management Groups to organize your subscriptions (e.g., Prod MG, Non-Prod MG). By applying these RBAC assignments at the Management Group scope, you create a powerful inheritance model. Any new subscription dropped into the Non-Prod MG automatically gets your developer guardrails. This is how you scale security policy without drowning in per-subscription configurations. It’s the difference between playing chess and checkers.
3. The Audited Break-Glass Role with PIM
For production emergencies, we need a clean, audited path to elevated privileges. This is precisely what Azure AD Privileged Identity Management (PIM) was built for. While the full PIM configuration is complex for Terraform, we define the role and the process.
The process looks like this:
- An on-call engineer faces a production outage.
- They go to the Azure PIM portal and request to activate the
Production-Break-Glass
role. - They must provide a justification (e.g., “JIRA-123: Production database is down”).
- The role is activated for a short, pre-configured duration (e.g., 1 hour).
- An alert is immediately sent to management and the security team.
- Every action taken during the session is logged in the Azure Activity Log for review.
The Terraform part is creating the role; the assignment is then managed in PIM.
# The role we will make eligible in PIM
resource "azurerm_role_definition" "break_glass_admin" {
name = "ProductionBreakGlassAdmin"
scope = data.azurerm_subscription.primary.id
description = "Highly privileged access for emergency use ONLY."
permissions {
# Grant almost everything, as this is for true emergencies
actions = ["*"]
# But explicitly deny the ability to change RBAC itself to contain the blast radius
not_actions = ["Microsoft.Authorization/*/Delete"]
}
assignable_scopes = [
data.azurerm_subscription.primary.id,
]
}
PIM transforms break-glass access from a shadowy, risky process into a transparent, governed, and audited workflow.
Pitfalls & Optimisations
- The
Contributor
Trap: The built-inContributor
role is dangerously broad. It allows users to delete entire resource groups but not change permissions. This creates a false sense of security. Always favor creating custom roles with the specificactions
andnot_actions
you need. - Scope Creep: A common mistake is assigning roles at the subscription level when they are only needed on a specific resource group. Always apply roles at the lowest possible scope in the hierarchy.
- Optimisation with Managed Identities: For your CI/CD pipelines, applications, and virtual machines, never use a Service Principal with a static, long-lived secret. Use System-Assigned or User-Assigned Managed Identities. Azure manages the credential rotation automatically, eliminating an entire class of security risks. This is non-negotiable for a secure Azure environment.
Unlocked: Your Key Takeaways
- Default to
Reader
: Everyone starts with read-only access. Privilege is earned, not given. - Custom Roles with ABAC are Your Guardrails: Ditch the
Contributor
role. Create custom roles and use conditional RBAC assignments based on resource tags to enforce your environment boundaries. - Leverage Management Groups: Don’t manage RBAC one subscription at a time. Use Management Groups to apply security policies and roles at scale.
- PIM is Your “Break-Glass” Solution: Use Azure AD Privileged Identity Management for just-in-time, audited, and time-bound elevation of privileges during emergencies.
- Managed Identities Everywhere: Eliminate static secrets from your applications and pipelines by using Managed Identities for all Azure resource authentication.
Azure provides a suite of powerful, built-in tools like PIM and ABAC. Failing to use them isn’t just a missed opportunity; it’s leaving a loaded weapon on the production dinner table.
If your team is facing this challenge, I specialize in architecting these secure, audit-ready systems.
Email me for a strategic consultation: [email protected]
Explore my projects and connect on Upwork: https://www.upwork.com/freelancers/~0118e01df30d0a918e