Background
Sections
IntroductionFoundations1. Resource Hierarchy2. Resource Manager3. Identity and RBAC4. Regions and Availability5. Naming and TaggingVirtual Machines1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetVirtual Network1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetBlob Storage1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure SQL Database1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Kubernetes Service1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Container Registry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetMicrosoft Entra ID1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure RBAC1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Functions1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAPI Management1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure App Configuration1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Machine Learning1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Monitor1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure AI Foundry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and Cheatsheet

5. Deployment

22 min read

Getting Started proved a grant works. This page makes access repeatable, reviewable, and reversible — which matters more for RBAC than for almost any other resource type, because a role assignment made by hand in the portal is invisible to your state file, invisible to code review, and the thing an auditor will ask about first.

The framing worth carrying through this page: access is code. A grant that isn't in a pull request is a grant nobody approved.

[Image Prompt: 2D minimalistic pipeline diagram of an Azure RBAC deployment flowing from git commit through terraform plan on pull request, a manual approval gate, and apply into dev, staging, and prod subscriptions, with a workload identity federation exchange shown between GitHub OIDC and Microsoft Entra ID replacing a crossed-out client secret, flat design, clean vector art style, white background]


Tool order

Rank Tool What it's for here When it's the wrong choice
1. Primary Terraform (azurerm, azapi for gaps) The full worked example — assignments, custom roles, PIM eligibility. Plan output on a pull request is a readable diff of who can do what, which is exactly the artefact a reviewer needs State is yours to protect, and the state file for an RBAC module is itself sensitive. Portal-made assignments show as drift you must reconcile
2. Secondary Ansible (azure.azcollection) Day-2 operations and bulk work: onboarding a new team's groups, sweeping orphaned assignments, reconciling from a CSV of owners. Genuinely better than Terraform for imperative one-shots Won't reconcile drift. Don't make it the source of truth for standing access
3. Third Bicep / ARM The Azure-native path, and the correct choice when assignments are deployed alongside the resources they protect in a landing zone. First-class support for new features, no state file Azure-only. Crucially, it cannot manage Entra ID objects — groups, app registrations — so a full access model needs another tool anyway

All three apply to RBAC. There is one real gap to name: Bicep and ARM cannot create the Entra groups you should be assigning roles to, because those live in Microsoft Graph, not ARM. There is a Graph Bicep extension ⚠️ verify its current preview status and coverage against current Azure docs, but for now a Bicep-only shop assigns roles to groups created somewhere else. Terraform handles both through azurerm + azuread in one plan, which is the strongest argument for it here.


The Terraform module

The shape below is opinionated in one way that matters: it assigns to groups, at resource-group scope, with the role name as data. Direct-to-user assignments are not expressible, which is the point — the module encodes the policy.

variables.tf

variable "environment" {
  type        = string
  description = "dev | staging | prod"
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

variable "name_prefix" {
  type        = string
  description = "Short workload identifier, e.g. payments"
}

variable "location" {
  type    = string
  default = "uksouth"
}

# The access model, as data. One place to review.
variable "group_role_assignments" {
  description = "Entra group display name → list of built-in role names to grant at the resource group"
  type        = map(list(string))
  default     = {}
  # e.g. { "sg-payments-engineers" = ["Reader", "Website Contributor"] }
}

variable "workload_data_roles" {
  description = "Data-plane roles granted to the workload's user-assigned identity, keyed by a stable label"
  type = map(object({
    role_definition_name = string
    scope_key            = string   # which resource this applies to
  }))
  default = {}
}

main.tf

terraform {
  required_version = ">= 1.6"
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
    azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
    azapi   = { source = "Azure/azapi",       version = "~> 2.0" }
    time    = { source = "hashicorp/time",    version = "~> 0.11" }
  }
}

provider "azurerm" {
  features {}
  # Set explicitly in CI so a mis-scoped credential can't apply to the wrong place
  subscription_id = var.subscription_id
}

locals {
  tags = {
    Environment = var.environment
    Workload    = var.name_prefix
    ManagedBy   = "terraform"
  }

  # Flatten map(list) into a keyed map so each assignment has a stable address.
  # Without this, reordering a list destroys and recreates assignments.
  group_assignments = merge([
    for group, roles in var.group_role_assignments : {
      for role in roles : "${group}|${role}" => { group = group, role = role }
    }
  ]...)
}

resource "azurerm_resource_group" "this" {
  name     = "rg-${var.name_prefix}-${var.environment}"
  location = var.location
  tags     = local.tags
}

# --- The workload identity: user-assigned, deliberately -----------------------
# System-assigned identities get a NEW object ID on every resource replacement,
# which silently orphans every role assignment referencing them.
resource "azurerm_user_assigned_identity" "workload" {
  name                = "id-${var.name_prefix}-${var.environment}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  tags                = local.tags
}

# --- Human access: groups only, resource-group scope --------------------------
data "azuread_group" "assigned" {
  for_each     = toset([for a in local.group_assignments : a.group])
  display_name = each.value
  # Fail loudly rather than creating something in the directory
  security_enabled = true
}

resource "azurerm_role_assignment" "group" {
  for_each = local.group_assignments

  scope                = azurerm_resource_group.this.id
  role_definition_name = each.value.role
  principal_id         = data.azuread_group.assigned[each.value.group].object_id
  principal_type       = "Group"

  description = "Managed by terraform: ${var.name_prefix}/${var.environment}"
}

Three deliberate decisions in there, each of which is a lesson someone learned the hard way:

for_each over a keyed map, never count over a list. A role assignment's Terraform address must be stable. With count, removing the first element of a list shifts every index, and Terraform destroys and recreates every assignment below it. For most resources that's churn; for RBAC it's a window during which nobody has access.

description on the assignment. Azure supports a description field and almost nobody uses it. It is the single cheapest audit improvement available: six months later, "why does this group have Website Contributor here" has an answer in the resource itself.

data "azuread_group" rather than resource. The module consumes directory groups; it doesn't create them. Group lifecycle belongs to identity governance, not to a workload module, and a module that can create groups can create a group and grant it Owner.

Custom roles, when a built-in genuinely doesn't fit

resource "azurerm_role_definition" "vm_restart_operator" {
  name        = "VM Restart Operator (${var.environment})"
  scope       = data.azurerm_subscription.current.id
  description = "Restart and inspect VMs. No resize, no delete, no disk changes."

  permissions {
    actions = [
      "Microsoft.Compute/virtualMachines/read",
      "Microsoft.Compute/virtualMachines/restart/action",
      "Microsoft.Compute/virtualMachines/instanceView/read",
      "Microsoft.Resources/subscriptions/resourceGroups/read",
    ]
    not_actions      = []
    data_actions     = []
    not_data_actions = []
  }

  assignable_scopes = [data.azurerm_subscription.current.id]
}

Note the absence of wildcards. Microsoft.Compute/* in a custom role is a grant that widens over time as Azure adds operations to the provider — no code change, no review, more permission. Enumerate.

Custom role definitions are tenant-level objects, so two environments applying the same module will collide on the role name. Either suffix it per environment as above, or — better — define custom roles once in a separate platform module and have workload modules reference them by ID. The second is correct; the first is what most estates actually do.

The propagation problem, handled

resource "azurerm_role_assignment" "workload_data" {
  for_each = var.workload_data_roles

  scope                = local.scopes[each.value.scope_key]
  role_definition_name = each.value.role_definition_name
  principal_id         = azurerm_user_assigned_identity.workload.principal_id
  principal_type       = "ServicePrincipal"
  description          = "Managed by terraform: workload data access"
}

# Role assignments are eventually consistent. A resource that needs the grant to be
# EFFECTIVE (not merely created) must wait; depends_on is not sufficient.
resource "time_sleep" "rbac_propagation" {
  depends_on      = [azurerm_role_assignment.workload_data]
  create_duration = "60s"
}

This is inelegant and it is the standard answer. depends_on guarantees ordering of API calls, not of effect. AKS-pulling-from-ACR and Function-App-reading-a-secret-at-startup are the two failures that teach this ⚠️ verify current propagation guidance against current Azure docs.

PIM eligibility through azapi

azurerm coverage of PIM has been partial ⚠️ verify current resource support against the provider documentation, and PIM is exactly the kind of surface where azapi earns its place — it speaks to the ARM REST API directly, so a feature the provider hasn't wrapped is still manageable in the same plan.

resource "azapi_resource" "prod_owner_eligible" {
  type      = "Microsoft.Authorization/roleEligibilityScheduleRequests@2020-10-01"
  name      = "b6e5f1a2-0000-4000-8000-000000000001"   # deterministic GUID
  parent_id = azurerm_resource_group.this.id

  body = {
    properties = {
      principalId      = data.azuread_group.assigned["sg-payments-oncall"].object_id
      roleDefinitionId = data.azurerm_role_definition.owner.id
      requestType      = "AdminAssign"
      scheduleInfo = {
        startDateTime = "2026-08-01T00:00:00Z"
        expiration    = { type = "NoExpiration" }
      }
      justification = "On-call break-glass; activation is approval-gated and logged"
    }
  }
}

The pattern to notice: eligibility is standing, activation is not. Terraform manages who may become Owner; it does not manage who is one right now. That's the right division — activations are events, not desired state, and putting them in a state file would be a category error.


Remote state and locking

Local state for an RBAC module is a genuine footgun, not a style preference. Two people applying concurrently against divergent local state can each remove the other's assignments, and the state file itself lists every grant in the estate — a reconnaissance document.

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate-platform"
    storage_account_name = "sttfstateplatform"
    container_name       = "tfstate"
    key                  = "rbac/payments/prod.tfstate"
    use_azuread_auth     = true      # RBAC on the backend too, not an account key
  }
}

The azurerm backend uses native blob leases for state locking. No separate lock table, no second resource to provision — a real simplification over the DynamoDB table AWS users will be expecting.

Harden the state account itself, because it is now the crown jewels:

az storage account update -g rg-tfstate-platform -n sttfstateplatform \
  --allow-shared-key-access false \
  --public-network-access Disabled
az storage account blob-service-properties update \
  -g rg-tfstate-platform -n sttfstateplatform --enable-versioning true

And set use_azuread_auth = true so pipeline access to state is itself governed by RBAC rather than by a key in a secret store.

Workspaces or directory-per-environment? For RBAC specifically, directory per environment with separate state and separate backend keys. Workspaces share one backend and one credential, which means the identity that can apply to dev can reach prod state. When the thing being managed is access, that shared blast radius is the wrong trade. Workspaces are fine for stateless app infra; they are not fine here.


Ansible — day-2 operations

Ansible's honest niche in RBAC work is the imperative sweep: reconciling assignments from an authoritative list, cleaning up after a reorg, onboarding twelve teams at once. Not standing state.

- name: Reconcile workload team access from the authoritative owners list
  hosts: localhost
  connection: local
  gather_facts: false

  vars:
    subscription_id: "{{ lookup('env', 'ARM_SUBSCRIPTION_ID') }}"
    team_access:
      - { group: sg-payments-engineers, rg: rg-payments-prod, role: Reader }
      - { group: sg-payments-oncall,    rg: rg-payments-prod, role: Virtual Machine Contributor }

  tasks:
    - name: Look up each group's object ID
      azure.azcollection.azure_rm_adgroup_info:
        attribute_name: displayName
        attribute_value: "{{ item.group }}"
      loop: "{{ team_access }}"
      register: groups

    - name: Ensure the role assignment exists
      azure.azcollection.azure_rm_roleassignment:
        scope: "/subscriptions/{{ subscription_id }}/resourceGroups/{{ item.0.rg }}"
        assignee_object_id: "{{ item.1.ad_groups[0].object_id }}"
        role_definition_id: >-
          /subscriptions/{{ subscription_id }}/providers/Microsoft.Authorization/roleDefinitions/{{
            role_guids[item.0.role] }}
        state: present
      loop: "{{ team_access | zip(groups.results) | list }}"
      register: assignments

    - name: Idempotency check — a second run must change nothing
      ansible.builtin.assert:
        that: not (assignments.results | map(attribute='changed') | select | list | length)
        fail_msg: "Second run made changes; the playbook is not idempotent"
      tags: [verify]

Two things worth extracting:

azure_rm_roleassignment wants a role definition ID, not a name. Built-in role GUIDs are stable across every tenant, so a lookup table of the dozen you use is a reasonable and much faster approach than resolving names each run.

The assert task is the idempotency demonstration. Run the playbook twice; the second run must report zero changes. If it doesn't, the module is recreating assignments and you have a live window of churn every time it runs. Verifying this is not optional for anything that touches access.

Authenticate the playbook with a managed identity on the runner or workload identity federation — never a service-principal secret in a vars file.


Bicep / ARM equivalent

Bicep module for a role assignment, and the guid() trick that makes it re-runnable

The one genuinely interesting problem in Bicep RBAC is naming. A role assignment's name is a GUID, and ARM is idempotent on name — so a random GUID creates a duplicate on every deployment, and a hard-coded one collides across scopes. The idiom is a deterministic GUID derived from the three fields that define the grant:

// rbac.bicep — assign a built-in role to a principal at this resource group
targetScope = 'resourceGroup'

@description('Object ID of the principal receiving the role')
param principalId string

@allowed(['User', 'Group', 'ServicePrincipal'])
param principalType string = 'ServicePrincipal'

@description('Built-in role definition GUID')
param roleDefinitionGuid string

var roleDefinitionId = subscriptionResourceId(
  'Microsoft.Authorization/roleDefinitions', roleDefinitionGuid)

resource assignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  // Deterministic: same principal + role + scope ⇒ same name ⇒ idempotent
  name: guid(resourceGroup().id, principalId, roleDefinitionId)
  properties: {
    principalId:      principalId
    principalType:    principalType   // skips the Graph lookup; set it
    roleDefinitionId: roleDefinitionId
    description:      'Deployed by Bicep'
  }
}

output assignmentId string = assignment.id

Preview before applying — what-if is the closest thing ARM has to terraform plan:

az deployment group what-if \
  -g rg-payments-prod \
  -f rbac.bicep \
  -p principalId=$OID roleDefinitionGuid=$READER_GUID

⚠️ Deployment modes — the footgun that matters most here

ARM deployments run in one of two modes:

  • Incremental (the default) — resources in the template are created or updated; anything else in the resource group is left alone.
  • Completeanything in the resource group that is not in the template is deleted.

For an RBAC template, a complete-mode deployment will remove every role assignment in the resource group that the template doesn't declare. If your template covers application access but the platform team's assignments were made elsewhere, complete mode silently revokes them. Use complete mode only when the template is genuinely the whole truth for that resource group, and never for a template someone might run partially.

# Explicit is better. Say which one you mean.
az deployment group create -g rg-payments-prod -f rbac.bicep --mode Incremental

When Bicep is the better choice for RBAC: landing zones, where assignments are deployed alongside the subscription they govern; deployment stacks, which give ARM a managed lifecycle with denySettings that can protect the resources they create; and any team whose whole toolchain is Azure DevOps. When it isn't: any access model that needs to create or manage the Entra groups being assigned, which ARM cannot do.


CI/CD wiring

Two non-negotiables for a pipeline that manages access.

No client secrets, ever — use workload identity federation. A leaked secret for a service principal that can write role assignments is a full tenant compromise, and secrets leak.

The pipeline identity must not be able to grant itself more. It needs Microsoft.Authorization/roleAssignments/write, which is the most dangerous permission in Azure. Give it Role Based Access Control Administrator scoped to the subscriptions it manages rather than Owner or User Access Administrator, and — where supported — constrain which roles it may assign ⚠️ verify current constraint capabilities against current Azure docs.

# One-time setup: federate GitHub to an Entra app registration
APP_ID=$(az ad app create --display-name "gh-rbac-payments" --query appId -o tsv)
az ad sp create --id "$APP_ID"

az ad app federated-credential create --id "$APP_ID" --parameters '{
  "name": "gh-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:acme/platform-rbac:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}'

The subject is the security boundary. repo:acme/platform-rbac:ref:refs/heads/main trusts only that repository, that branch. Use repo:acme/platform-rbac:environment:prod to bind the credential to a GitHub environment with required reviewers — then approval is enforced by the credential, not merely by the workflow file, which a pull request could otherwise edit.

name: rbac
on:
  pull_request:
  push:
    branches: [main]

permissions:
  id-token: write        # required to request the OIDC token
  contents: read
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    environment: prod-plan          # read-only credential
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id:       ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id:       ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform plan -var-file=envs/prod.tfvars -out=tf.plan
      - run: terraform show -no-color tf.plan > plan.txt
      - name: Post the access diff on the PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const body = '### Access changes\n```\n' +
              fs.readFileSync('plan.txt','utf8').slice(0, 60000) + '\n```';
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner, repo: context.repo.repo, body });

  apply:
    if: github.ref == 'refs/heads/main'
    needs: plan
    runs-on: ubuntu-latest
    environment: prod                # required reviewers configured here
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id:       ${{ secrets.AZURE_CLIENT_ID_APPLY }}
          tenant-id:       ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform apply -auto-approve -var-file=envs/prod.tfvars

secrets.AZURE_CLIENT_ID there holds an ID, not a credential — the actual token comes from the OIDC exchange. Two separate app registrations, one read-only for plan and one with write for apply, means a compromised pull-request workflow cannot change access.

Posting the plan on the pull request is the highest-value step in this pipeline. For most resources, plan output is noise a reviewer skims. For RBAC it is a human-readable statement of exactly who is gaining or losing which permission, at which scope — the artefact that turns access review from an annual audit into a code review.


Environments

For most Azure resources, resource-group separation is adequate. For RBAC it usually isn't, and the reasoning is worth spelling out.

Boundary Adequate for RBAC? Why
Resource groups in one subscription No A subscription-scope assignment made by anyone reaches every environment. One over-broad grant crosses all three
Subscription per environment Yes — the default answer The subscription is Azure's natural blast-radius, quota, and policy boundary. Contributor on dev-sub reaches nothing in prod-sub
Management group per environment tier Yes, for large estates Lets Policy enforce the difference structurally rather than by convention
Tenant per environment Almost never Identities don't cross tenants; you'd duplicate every group and every person

Pick subscription-per-environment. In Azure the subscription is the blast-radius boundary in a way the AWS account is too — this is one place the AWS instinct transfers cleanly.

envs/
├── dev.tfvars        # subscription_id = <dev>,     groups get Contributor at rg scope
├── staging.tfvars    # subscription_id = <staging>, groups get Contributor, prod-shaped policy
└── prod.tfvars       # subscription_id = <prod>,    groups get Reader; write access via PIM only
# envs/prod.tfvars — note what changes and what doesn't
environment = "prod"
name_prefix = "payments"

group_role_assignments = {
  "sg-payments-engineers" = ["Reader"]                    # standing access is read-only
  "sg-platform-security"  = ["Security Reader"]
}
# Write access in prod is PIM-eligible, not standing. See the azapi block above.

That contrast is the environment strategy in one file: dev grants standing write, prod grants standing read and makes write an activation. Everything else is the same module.

Where Policy enforces it rather than convention. Assign a policy at the management group above prod that audits or denies role assignments of Owner and User Access Administrator at subscription scope, and one that requires the description field on new assignments. Convention says "we don't do that"; Policy makes it fail. There are built-in policy definitions in this area ⚠️ verify the current built-in set against current Azure docs.


Rollback and blast radius

What "undo" means here. For RBAC, rollback is unusually clean — git revert then apply, and the assignment is deleted. There is no data to restore and no resource to rebuild. Role assignments are cheap to create and cheap to destroy.

The asymmetry that matters: rollback of a grant is fast and safe; rollback of a revocation is fast but the outage it caused was immediate. Removing an assignment breaks running workloads at once and the recovery is delayed by propagation. So the risk profile is inverted from most resources — adding access is the safe direction, removing it is the dangerous one. Review removals harder than additions, and stage them.

Operations that force replacement rather than update. azurerm_role_assignment has no meaningful in-place update: change the scope, the role, or the principal and Terraform destroys and recreates it. Between destroy and create, nobody has that access. For a single assignment that's seconds; for a refactor touching fifty, it's a real window. Mitigate with create_before_destroy where the resource supports it, or split the change into add-then-remove across two applies.

Two Azure-specific traps.

Resource locks. A CanNotDelete or ReadOnly lock on the resource group — possibly inherited — makes terraform apply fail with ScopeLocked, which reads exactly like a permissions error and sends people off to grant broader roles. Check for locks first:

az lock list -g rg-payments-prod -o table

Note also that ReadOnly blocks creating role assignments in the scope, which surprises people who assumed read-only meant read-only for data.

Soft delete and purge protection. RBAC has no soft delete of its own, but it interacts with services that do. Delete a Key Vault with purge protection enabled and the vault's name stays reserved — a recreate fails, and so does every role assignment your module wanted to make against it. The apply failure is about the vault; the symptom people report is "RBAC is broken".

Blast radius by scope, worth internalising:

Change at Immediately affects
Resource / sub-resource One resource. The safe place to iterate
Resource group Every resource in it, including ones created later
Subscription Every resource group in it. Reviewable but consequential
Management group Every subscription beneath it. A mistake here is an estate-wide incident
Root scope / Everything in the tenant. Requires elevate access. Should never be in a pipeline

A practical control: keep management-group and subscription-scope assignments in a separate repository with different reviewers from workload-scope ones. Same tooling, different approval gravity.


Drift detection

RBAC drifts more than any other resource type, for a simple reason: granting access is the fastest way to unblock someone, and the portal is right there. An estate with no drift detection accumulates undeclared grants at a steady rate, and every one of them is an audit finding.

Scheduled plan in CI. The baseline. A nightly terraform plan that fails the job on a non-empty diff:

  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id:       ${{ secrets.AZURE_CLIENT_ID }}   # read-only credential
          tenant-id:       ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform plan -detailed-exitcode -var-file=envs/prod.tfvars
        # exit 2 = drift. Fail the job and open an issue.

The gap that scheduled plan does not close. terraform plan only reports drift in resources it manages. An assignment created by hand that your module never declared is invisible to it — the plan is clean and the grant is live. This is the single most important limitation to understand, and it's why the next two checks are not optional.

Azure Resource Graph for the whole estate. The right tool for "what assignments exist anywhere", because it queries across subscriptions without ARM throttling:

authorizationresources
| where type =~ "microsoft.authorization/roleassignments"
| extend principalId      = tostring(properties.principalId),
         roleDefinitionId = tostring(properties.roleDefinitionId),
         scope            = tostring(properties.scope),
         description      = tostring(properties.description)
| where isempty(description)          // our module always sets one — so this is drift
| project subscriptionId, scope, principalId, roleDefinitionId

Setting description on every managed assignment turns "is this ours?" into a query. That's the payoff for the field nobody uses.

Activity log alerts for the assignments that matter. Detection after the fact isn't enough for privileged grants; you want a page:

AzureActivity
| where OperationNameValue =~ "MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE"
| where ActivityStatusValue == "Success"
| extend props = parse_json(Properties)
| project TimeGenerated, Caller, CallerIpAddress,
          scope = tostring(props.entity), requestbody = tostring(props.requestbody)
| order by TimeGenerated desc

Alert on any successful write of Owner or User Access Administrator, and on elevate access (Microsoft.Authorization/elevateAccess/action) unconditionally. The second should wake someone up. Full observability setup in Production.

What to do about drift. Two honest options and a wrong one. Either import it (terraform import, or terraform plan -generate-config-out to draft the block) if the grant is legitimate, or delete it if it isn't. The wrong option is adding it to state without a pull request explaining why it exists — that launders an unreviewed grant into an approved one.


Teardown

terraform destroy -var-file=envs/dev.tfvars

What destroy will not remove:

  • Role assignments created outside this state — portal-made, another module's, or another team's. Destroy is silent about them.
  • Custom role definitions in another state file. They're tenant-level and outlive every resource group.
  • Entra groups. Correctly so — the module reads them with a data block and never owned them.
  • PIM eligible assignments created outside this module, and any active activations, which expire on their own schedule rather than on destroy.
  • Resources behind a CanNotDelete lock, which fails the destroy partway and leaves the state half-applied.
  • The activity-log history of every assignment ever made — which is a feature, not a gap. Deleting an assignment does not delete the record that it existed.
# Confirm nothing privileged survived
az role assignment list --all \
  --query "[?roleDefinitionName=='Owner' || roleDefinitionName=='User Access Administrator'].{role:roleDefinitionName, principal:principalName, scope:scope}" \
  -o table

az role definition list --custom-role-only true -o table
az lock list -o table

Next: Integrations →

← Back to the Azure RBAC overview · ← Previous: Getting Started