title: RBAC role assignments description: Azure RBAC patterns for Terraform: built-in role assignments, custom role definitions, and federated CI identities (OIDC) for GitHub Actions. tags: - terraform - azure
RBAC role assignments¶
Azure doesn't have AWS-style IAM policies. Permissions are expressed as
role definitions (a list of allowed/denied actions) bound to a
principal at a scope (management group, subscription, resource group,
or resource). The binding itself is an azurerm_role_assignment.
Prefer built-in roles
Microsoft maintains 200+ built-in roles. Reach for a custom role only when no built-in role fits: custom roles are scoped to one tenant and harder to audit.
Built-in role assignment¶
Assign a built-in role at any scope. The trio (scope,
role_definition_name, principal_id) uniquely identifies the assignment.
Subscription scope¶
data "azurerm_subscription" "current" {}
resource "azurerm_role_assignment" "platform_reader" {
scope = data.azurerm_subscription.current.id
role_definition_name = "Reader"
principal_id = azuread_group.platform_team.object_id
}
Resource-group scope¶
resource "azurerm_role_assignment" "rg_contributor" {
scope = azurerm_resource_group.app.id
role_definition_name = "Contributor"
principal_id = azuread_service_principal.app_deployer.object_id
}
Resource scope (managed identity → Key Vault)¶
resource "azurerm_user_assigned_identity" "app" {
name = "id-${var.project}-${var.environment}"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
}
resource "azurerm_role_assignment" "app_kv_secrets" {
scope = azurerm_key_vault.app.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.app.principal_id
}
principal_type
For service principals or managed identities created in the same apply,
set principal_type = "ServicePrincipal" to skip the AAD propagation
poll and avoid PrincipalNotFound errors on first run.
Custom role definition¶
When a built-in role is too broad, define a custom one. List only the
control-plane operations you need; use data_actions for data-plane
operations on storage / Key Vault / etc.
data "azurerm_subscription" "current" {}
resource "azurerm_role_definition" "vm_operator" {
name = "VM Operator (start/stop)"
scope = data.azurerm_subscription.current.id
description = "Start, stop, restart, and view VMs. No create/delete/modify."
permissions {
actions = [
"Microsoft.Compute/virtualMachines/read",
"Microsoft.Compute/virtualMachines/start/action",
"Microsoft.Compute/virtualMachines/restart/action",
"Microsoft.Compute/virtualMachines/deallocate/action",
"Microsoft.Compute/virtualMachines/instanceView/read",
]
not_actions = []
}
assignable_scopes = [
data.azurerm_subscription.current.id,
]
}
resource "azurerm_role_assignment" "ops_team_vm_operator" {
scope = data.azurerm_subscription.current.id
role_definition_id = azurerm_role_definition.vm_operator.role_definition_resource_id
principal_id = azuread_group.ops_team.object_id
}
Use role_definition_resource_id
role_definition_id on the role definition is a tenant-scoped GUID;
role assignments need the full resource ID (which embeds the scope).
Always reference role_definition_resource_id when wiring them up.
Federated CI: GitHub Actions → Azure (OIDC)¶
Federated identity credentials let a GitHub Actions workflow assume an Entra ID app registration without storing a client secret. The flow:
- Create an app registration + service principal.
- Add a federated identity credential that trusts a specific
repo:owner/name:ref:refs/heads/mainsubject. - Assign the SP an Azure role at the right scope.
- In CI, set
ARM_USE_OIDC=trueandARM_CLIENT_ID/_TENANT_ID/_SUBSCRIPTION_ID.
terraform {
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
}
}
# 1. App + SP for the CI pipeline
resource "azuread_application" "ci" {
display_name = "github-${var.project}-${var.environment}"
}
resource "azuread_service_principal" "ci" {
client_id = azuread_application.ci.client_id
}
# 2. Trust GitHub's OIDC issuer for a specific repo + ref
resource "azuread_application_federated_identity_credential" "ci_main" {
application_id = azuread_application.ci.id
display_name = "github-main"
description = "Trust GitHub Actions on main branch"
audiences = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:my-org/${var.project}:ref:refs/heads/main"
}
resource "azuread_application_federated_identity_credential" "ci_pr" {
application_id = azuread_application.ci.id
display_name = "github-pull-request"
audiences = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:my-org/${var.project}:pull_request"
}
# 3. Grant the SP rights on the target subscription
resource "azurerm_role_assignment" "ci_contributor" {
scope = data.azurerm_subscription.current.id
role_definition_name = "Contributor"
principal_id = azuread_service_principal.ci.object_id
principal_type = "ServicePrincipal"
}
# Plus blob data access for the tfstate container
resource "azurerm_role_assignment" "ci_tfstate" {
scope = azurerm_storage_container.tfstate.resource_manager_id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azuread_service_principal.ci.object_id
principal_type = "ServicePrincipal"
}
output "github_actions_env" {
description = "Copy these into your GitHub repo as Variables (not Secrets)."
value = {
AZURE_CLIENT_ID = azuread_application.ci.client_id
AZURE_TENANT_ID = data.azurerm_client_config.current.tenant_id
AZURE_SUBSCRIPTION_ID = data.azurerm_subscription.current.subscription_id
}
}
Subject claim format
subject is matched literally, there is no glob support. Add one
federated credential per ref pattern you want to trust:
| Use case | Subject |
|---|---|
| Branch | repo:org/repo:ref:refs/heads/main |
| Tag | repo:org/repo:ref:refs/tags/v1.2.3 |
| Pull request | repo:org/repo:pull_request |
| GitHub environment | repo:org/repo:environment:production |
Service principal for non-OIDC CI¶
Where federated identity isn't available (self-hosted runners on legacy networks, third-party CI without OIDC), fall back to a client secret stored in your secret manager. Rotate it on a schedule.
resource "azuread_application" "ci_legacy" {
display_name = "ci-${var.project}-legacy"
}
resource "azuread_service_principal" "ci_legacy" {
client_id = azuread_application.ci_legacy.client_id
}
resource "azuread_application_password" "ci_legacy" {
application_id = azuread_application.ci_legacy.id
display_name = "rotation-2026-q2"
end_date = "2026-08-01T00:00:00Z"
}
resource "azurerm_role_assignment" "ci_legacy_contributor" {
scope = azurerm_resource_group.app.id
role_definition_name = "Contributor"
principal_id = azuread_service_principal.ci_legacy.object_id
principal_type = "ServicePrincipal"
}