Secure Azure Resources with Terraform

Implement least-privilege RBAC and Key Vault secrets management as code.

Introduction

30 Second Summary

Every organization has sensitive information that only certain people should access. The challenge is giving each person exactly the right level of access, nothing more and nothing less.

In this project, you will use Terraform to build an Azure environment with layered access controls. You'll provision a Key Vault for storing secrets, assign built-in RBAC roles at different scopes, and create a custom role that restricts access to specific data-plane operations.

What You'll Build

A production-style Azure environment with two resource groups, an RBAC-secured Key Vault containing application secrets, and a layered access control model implemented entirely in Terraform.

By the end of this project, you'll have:

  • A Terraform project with five configuration files that provisions resource groups, a Key Vault, and secrets as code.
  • Built-in RBAC role assignments demonstrating scope hierarchy and the data-plane vs. control-plane distinction unique to Key Vault.
  • A custom role definition using DataActions to enforce least-privilege access to Key Vault keys.
  • Secret Mission: Scope a role assignment to an individual Key Vault secret to demonstrate the most granular RBAC level available.

Are there any prerequisites?

You'll need an Azure account with Owner or User Access Administrator permissions on your subscription. You'll also need Terraform and the Azure CLI installed and authenticated before starting.

Before We Start

In this project, you are building a Terraform-managed Azure environment that demonstrates how RBAC controls access to Key Vault secrets across different scopes and planes.

Before we can write any infrastructure code, we need to authenticate with Azure and initialize our Terraform project. In this step, you will sign in via the Azure CLI, note your subscription details, create the project directory, and download the required Terraform providers.

In this step, get ready to:
  • Authenticate with Azure CLI and confirm your subscription context.
  • Create the project directory and Terraform provider configuration.
  • Initialize Terraform and verify the provider downloads.
Authenticate and confirm your Azure context

Terraform uses your Azure CLI session to authenticate with Azure. You need an active login and the correct subscription selected before any Infrastructure as Code commands will work.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Sign in to Azure by running this command:
az login

A browser window opens for you to sign in with your Azure account. After you sign in, the terminal displays a list of your subscriptions.

Browser not opening?

If a browser window does not appear, check that your default browser is set correctly in your system settings. You can also try running az login --use-device-code to authenticate using a code you paste into any browser manually.

Ask for help: Why is az login not opening a browser on my machine?

  • Retrieve your subscription ID and tenant ID by running this command:
az account show --query "{subscriptionId:id, tenantId:tenantId}"

You should see a JSON object with your subscriptionId and tenantId displayed.

What are these IDs?

Your subscription ID identifies which Azure billing account owns the resources you create. Terraform needs this to know where to deploy.

Your tenant ID identifies your Azure Active Directory organization. RBAC role assignments are tied to this tenant.

  • Copy your subscriptionId value and save it somewhere you can easily find it. You will need it later when running Terraform.
  • Confirm you have Owner or User Access Administrator permissions on the subscription. Without these, you cannot create role assignments later in the project.

How do I check my permissions?

In the Azure Portal, navigate to Subscriptions, select your subscription, then click Access control (IAM). Click View my access to see your assigned roles. You need either Owner or User Access Administrator listed.

Create the project directory and initialize Terraform

Every Terraform project lives in its own directory. The provider configuration tells Terraform which cloud platform to target and how to authenticate.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder by running this command:
mkdir azure-rbac-lab
  • Confirm the folder was created by running:
ls

You should see azure-rbac-lab in the output.

  • Move into your project folder by running:
cd azure-rbac-lab
  • Create a new file called main.tf in your azure-rbac-lab folder. Open it in your preferred text editor and add the following provider configuration:
# Declare the providers Terraform needs to download
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    random = {
      source = "hashicorp/random"
    }
    time = {
      source = "hashicorp/time"
    }
  }
}

# Configure the Azure provider with Key Vault cleanup features
provider "azurerm" {
  features {
    key_vault {
      purge_soft_deleted_secrets_on_destroy = true
      recover_soft_deleted_secrets          = true
    }
  }
  subscription_id = var.subscription_id
}

# Fetch the current user's identity (used later for role assignments)
data "azurerm_client_config" "current" {}

# Fetch the current subscription details (used for custom role scoping)
data "azurerm_subscription" "current" {}

What does this configuration do?

The terraform block declares three providers: azurerm (pinned to version ~> 4.0 for stability), random (for generating unique resource names), and time (for adding pauses between dependent operations).

The provider "azurerm" block configures Key Vault cleanup behavior and sets the subscription_id from a variable. In azurerm version 4.0+, specifying the subscription ID is required.

The two data sources fetch your current Azure identity and subscription details at runtime. You will reference these later when creating role assignments.

  • Save main.tf.
  • Confirm the file exists by running:
ls

You should see main.tf in the output.

Don't see main.tf?

Make sure you saved the file inside the azure-rbac-lab folder on your Desktop. Check that your terminal is in the same directory by running pwd. It should end with /azure-rbac-lab.

Ask for help: I created main.tf but it doesn't appear when I run ls in my terminal.

  • Initialize Terraform to download the required providers by running this command:
terraform init

Terraform downloads the azurerm, random, and time provider plugins. This takes a few seconds.

You should see Terraform has been successfully initialized! in the output.

  • Confirm the provider binaries were downloaded by running:
ls .terraform/providers/registry.terraform.io/hashicorp

You should see three directories: azurerm, random, and time.

Seeing an error during terraform init?

If you see a version constraint error, double check that your main.tf has version = "~> 4.0" inside the azurerm block. Make sure the quotes are straight quotes, not curly quotes from a word processor.

If you see a network error, check your internet connection. Terraform needs to reach registry.terraform.io to download providers.

Ask for help: I'm getting an error when running terraform init in my azure-rbac-lab project.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf and that terraform init completed without errors.

ⓧ I'd like to double check the full code

Here is the complete main.tf file for comparison:

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    random = {
      source = "hashicorp/random"
    }
    time = {
      source = "hashicorp/time"
    }
  }
}

provider "azurerm" {
  features {
    key_vault {
      purge_soft_deleted_secrets_on_destroy = true
      recover_soft_deleted_secrets          = true
    }
  }
  subscription_id = var.subscription_id
}

data "azurerm_client_config" "current" {}

data "azurerm_subscription" "current" {}

Your Terraform project is initialized and ready to go. Next, you will define the actual Azure infrastructure: resource groups, a Key Vault, and the secrets it protects.

Define the Infrastructure

With Terraform initialized and the provider ready, you can start declaring the actual Azure resources your project needs.

In this step, you'll define the foundational infrastructure: two resource groups for environment isolation, an RBAC-secured Key Vault for secrets management, and application secrets stored entirely as code.

In this step, get ready to:
  • Create a variables file for reusable configuration values.
  • Build the infrastructure configuration with resource groups, a Key Vault, and secrets.
  • Create an outputs file to expose important resource identifiers.
Create the variables file

Variables keep your configuration flexible and reusable. Instead of hardcoding values throughout your Terraform files, you define them once and reference them everywhere.

  • Create a new file called variables.tf in your azure-rbac-lab directory and add the following code:
variable "subscription_id" {
  description = "The Azure subscription ID to deploy resources into"
  type        = string
}

variable "location" {
  description = "Azure region for all resources"
  type        = string
  default     = "eastus"
}

variable "prefix" {
  description = "Prefix for resource naming"
  type        = string
  default     = "rbac-lab"
}

What does this code do?

  • subscription_id has no default, so Terraform will prompt you for it at deploy time. This keeps your subscription ID out of version control.
  • location defaults to eastus and controls where all resources are provisioned.
  • prefix defaults to rbac-lab and is used to generate consistent resource names across the project.
  • Save variables.tf.

Seeing a syntax error?

Make sure each variable block uses curly braces {} and that string values are wrapped in double quotes.

help me fix a syntax error in my variables.tf file

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

variable "subscription_id" {
  description = "The Azure subscription ID to deploy resources into"
  type        = string
}

variable "location" {
  description = "Azure region for all resources"
  type        = string
  default     = "eastus"
}

variable "prefix" {
  description = "Prefix for resource naming"
  type        = string
  default     = "rbac-lab"
}
Build the infrastructure configuration

This file defines the core Azure resources: resource groups to organize your environments, a Key Vault to store secrets, and the secrets themselves.

  • Create a new file called infrastructure.tf in your azure-rbac-lab directory.
  • Add the following code to define the environment foundation:
resource "random_string" "suffix" {
  length  = 6
  special = false
  upper   = false
}

resource "azurerm_resource_group" "dev" {
  name     = "${var.prefix}-dev-rg"
  location = var.location
}

resource "azurerm_resource_group" "prod" {
  name     = "${var.prefix}-prod-rg"
  location = var.location
}

What does this code do?

  • random_string.suffix generates a 6-character lowercase string appended to the Key Vault name. Key Vault names must be globally unique across all of Azure, so this avoids naming collisions.
  • The two resource groups (dev and prod) represent environment isolation. In production teams, each environment gets its own resource group with different access controls.

You won't see a change yet. The next chunk configures the Key Vault that lives inside these resource groups.

  • Below the resource groups, add the Key Vault resource:
resource "azurerm_key_vault" "main" {
  name                       = "${var.prefix}-kv-${random_string.suffix.result}"
  location                   = azurerm_resource_group.prod.location
  resource_group_name        = azurerm_resource_group.prod.name
  tenant_id                  = data.azurerm_client_config.current.tenant_id
  sku_name                   = "standard"
  rbac_authorization_enabled = true
  soft_delete_retention_days = 7
  purge_protection_enabled   = false
}

Why rbac_authorization_enabled matters

This is the most important flag in the entire project. Setting rbac_authorization_enabled = true switches the Key Vault from legacy access policies to Azure RBAC for data plane operations.

With RBAC enabled, who can read secrets is controlled by role assignments (the same system that controls who can manage Azure resources). Without it, you'd need to configure a separate access policy system that lives only inside the vault.

What is the data plane vs. control plane?

The control plane is where you manage the vault resource itself: creating it, deleting it, changing its network rules. The data plane is where you work with the data stored inside: reading secrets, creating keys, signing certificates.

Key Vault is unique because these two planes have completely separate permission sets. Someone with full control-plane access can manage the vault but still have zero ability to read any secret inside it.

You won't see a change yet. The next chunk grants the permissions needed to store secrets in this vault.

  • Below the Key Vault resource, add the RBAC bootstrapping and secrets:
resource "azurerm_role_assignment" "kv_admin_self" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Administrator"
  principal_id         = data.azurerm_client_config.current.object_id
}

resource "time_sleep" "wait_for_rbac" {
  depends_on      = [azurerm_role_assignment.kv_admin_self]
  create_duration = "30s"
}

resource "azurerm_key_vault_secret" "db_connection" {
  name         = "db-connection-string"
  value        = "Server=prod-sql.database.windows.net;Database=AppDB;User=admin;Password=P@ssw0rd123!"
  key_vault_id = azurerm_key_vault.main.id
  depends_on   = [time_sleep.wait_for_rbac]
}

resource "azurerm_key_vault_secret" "api_key" {
  name         = "external-api-key"
  value        = "sk-prod-abc123def456ghi789"
  key_vault_id = azurerm_key_vault.main.id
  depends_on   = [time_sleep.wait_for_rbac]
}

What does this code do?

  • kv_admin_self assigns the "Key Vault Administrator" built-in role to your own identity. With RBAC enabled, even the vault creator cannot write secrets without an explicit role assignment.
  • time_sleep.wait_for_rbac pauses for 30 seconds after the role assignment. Azure RBAC role assignments can take up to 30 seconds to propagate, and creating secrets before propagation completes would fail with a 403 error.
  • The two secrets (db-connection-string and external-api-key) are sample application secrets stored in the vault. Both depend on the time_sleep to guarantee RBAC is ready.
  • Save infrastructure.tf.
  • Confirm the file exists in your editor's sidebar alongside main.tf and variables.tf.

Seeing red squiggles or errors in your editor?

Terraform files reference each other across the same directory. If your editor shows errors about undefined variables or resources, make sure main.tf and variables.tf are saved in the same azure-rbac-lab folder.

help me debug Terraform reference errors in my infrastructure.tf

✔️ Awesome, I've got everything!

Great. Double check you've saved infrastructure.tf before moving on.

ⓧ I'd like to double check the full code

resource "random_string" "suffix" {
  length  = 6
  special = false
  upper   = false
}

resource "azurerm_resource_group" "dev" {
  name     = "${var.prefix}-dev-rg"
  location = var.location
}

resource "azurerm_resource_group" "prod" {
  name     = "${var.prefix}-prod-rg"
  location = var.location
}

resource "azurerm_key_vault" "main" {
  name                       = "${var.prefix}-kv-${random_string.suffix.result}"
  location                   = azurerm_resource_group.prod.location
  resource_group_name        = azurerm_resource_group.prod.name
  tenant_id                  = data.azurerm_client_config.current.tenant_id
  sku_name                   = "standard"
  rbac_authorization_enabled = true
  soft_delete_retention_days = 7
  purge_protection_enabled   = false
}

resource "azurerm_role_assignment" "kv_admin_self" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Administrator"
  principal_id         = data.azurerm_client_config.current.object_id
}

resource "time_sleep" "wait_for_rbac" {
  depends_on      = [azurerm_role_assignment.kv_admin_self]
  create_duration = "30s"
}

resource "azurerm_key_vault_secret" "db_connection" {
  name         = "db-connection-string"
  value        = "Server=prod-sql.database.windows.net;Database=AppDB;User=admin;Password=P@ssw0rd123!"
  key_vault_id = azurerm_key_vault.main.id
  depends_on   = [time_sleep.wait_for_rbac]
}

resource "azurerm_key_vault_secret" "api_key" {
  name         = "external-api-key"
  value        = "sk-prod-abc123def456ghi789"
  key_vault_id = azurerm_key_vault.main.id
  depends_on   = [time_sleep.wait_for_rbac]
}
Create the outputs file

Outputs expose important values from your deployment so you can reference them later or pass them to other configurations.

  • Create a new file called outputs.tf in your azure-rbac-lab directory and add the following code:
output "key_vault_name" {
  value = azurerm_key_vault.main.name
}

output "key_vault_uri" {
  value = azurerm_key_vault.main.vault_uri
}

output "dev_resource_group_id" {
  value = azurerm_resource_group.dev.id
}

output "prod_resource_group_id" {
  value = azurerm_resource_group.prod.id
}

What does this code do?

  • key_vault_name and key_vault_uri surface the vault's unique name and URI so you can locate it in the portal or use it in scripts.
  • dev_resource_group_id and prod_resource_group_id expose the full Azure Resource Manager IDs, which you'll use as scope values for RBAC role assignments in the next step.
  • Save outputs.tf.

Editor showing an error about azurerm_key_vault?

Make sure infrastructure.tf is saved in the same directory. Terraform resolves references across all .tf files in a folder, so every file must be in azure-rbac-lab.

help me fix reference errors in my outputs.tf file

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

output "key_vault_name" {
  value = azurerm_key_vault.main.name
}

output "key_vault_uri" {
  value = azurerm_key_vault.main.vault_uri
}

output "dev_resource_group_id" {
  value = azurerm_resource_group.dev.id
}

output "prod_resource_group_id" {
  value = azurerm_resource_group.prod.id
}
Validate your configuration

Terraform's validate command checks all your files for syntax errors and broken references without contacting Azure.

  • Run the validation command in your terminal:
terraform validate
  • Confirm the output reads Success! The configuration is valid. with no errors or warnings.

Seeing a validation error?

Check that all four files (main.tf, variables.tf, infrastructure.tf, outputs.tf) are saved in the same azure-rbac-lab directory. Terraform reads all .tf files in the current folder as one configuration.

If the error mentions a specific resource or variable name, compare the name in the error message to the exact spelling in your code. Terraform names are case-sensitive.

help me debug a terraform validate error

Your infrastructure is defined and validated. Next up, you'll layer RBAC role assignments on top of these resources to control exactly who can access what.

Assign Built-in RBAC Roles at Different Scopes

Your infrastructure is defined and validated. Now it is time to layer access controls on top of it.

Azure RBAC uses role assignments to connect a security principal (who) with a role definition (what they can do) at a specific scope (where). In this step, you will assign three built-in roles at two different scopes to see how scope hierarchy and plane separation work in practice.

In this step, get ready to:
  • Create a dedicated RBAC configuration file with three built-in role assignments.
  • Understand the data-plane vs. control-plane distinction unique to Key Vault.
  • Preview the role assignments with a Terraform plan.
Create rbac.tf with built-in role assignments

Keeping RBAC configuration in its own file makes it easy to audit who has access to what. You will create three role assignments that demonstrate two critical concepts: scope hierarchy (resource group vs. resource) and plane separation (data plane vs. management plane).

  • Create a new file called rbac.tf in your azure-rbac-lab folder and add the following role assignments:
# Scope: Resource Group level - demonstrates scope hierarchy
resource "azurerm_role_assignment" "dev_rg_reader" {
  scope                = azurerm_resource_group.dev.id
  role_definition_name = "Reader"
  principal_id         = data.azurerm_client_config.current.object_id
}

# Scope: Key Vault resource - DATA PLANE access (can read secret values)
resource "azurerm_role_assignment" "kv_secrets_user" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = data.azurerm_client_config.current.object_id
}

# Scope: Key Vault resource - MANAGEMENT PLANE access (CANNOT read secrets)
resource "azurerm_role_assignment" "kv_contributor" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Contributor"
  principal_id         = data.azurerm_client_config.current.object_id
}

What does this code do?

  • dev_rg_reader assigns the "Reader" role scoped to the dev resource group. This principal can view all resources inside that resource group but cannot modify them.
  • kv_secrets_user assigns "Key Vault Secrets User" scoped to the vault itself. This grants data-plane read access, meaning the principal can retrieve secret values stored inside the vault.
  • kv_contributor assigns "Key Vault Contributor" scoped to the vault. This grants management-plane access (modifying vault properties, network rules) but explicitly cannot read secrets, keys, or certificates inside the vault.
  • Save rbac.tf.
  • Confirm the syntax is valid by running:
terraform validate
  • You should see Success! The configuration is valid. in the output.

Seeing a validation error?

  • Check that each resource block has matching opening and closing braces.
  • Confirm the scope values reference resources defined in infrastructure.tf (azurerm_resource_group.dev.id and azurerm_key_vault.main.id).
  • Make sure there are no typos in the role names. They must match exactly: Reader, Key Vault Secrets User, Key Vault Contributor.

Help me debug my Terraform validation error for rbac.tf role assignments

Understand data-plane vs. control-plane access

The two Key Vault roles you just assigned look similar but grant completely different types of access. This distinction is the most commonly misunderstood aspect of Key Vault RBAC.

What is data-plane vs. control-plane?

Access to a key vault is controlled through two interfaces: the control plane and the data plane. The control plane is where you manage the vault resource itself (create, delete, update properties, configure network rules). The data plane is where you work with the data stored inside the vault (secrets, keys, certificates).

"Key Vault Contributor" is a control-plane role. It can modify vault settings but has zero access to secrets, keys, or certificates inside the vault. "Key Vault Secrets User" is a data-plane role. It can read secret values but cannot manage the vault resource itself.

This separation means a vault administrator who manages infrastructure never needs to see the actual secrets. An application that reads secrets never needs permissions to reconfigure the vault.

Why does scope matter?

The "Reader" role is scoped to the dev resource group, meaning it applies to all resources inside that group. The Key Vault roles are scoped to the specific vault resource. Azure RBAC supports four scope levels: management group, subscription, resource group, and resource.

A role assigned at a broader scope (like subscription) automatically inherits down to child scopes. Scoping narrowly to a single resource is the least-privilege best practice.

Run terraform plan to preview role assignments
  • Preview what Terraform will create by running:
terraform plan

What should I see?

The plan output lists every resource Terraform will create. Look for three azurerm_role_assignment resources in the output. Each one shows a different scope value.

The plan also includes all the resources from infrastructure.tf (resource groups, Key Vault, secrets). The total resource count depends on whether you have already applied the infrastructure in a previous run.

  • Confirm the plan completes without errors and shows the azurerm_role_assignment resources with their scopes.

Plan showing errors?

  • If you see a reference error mentioning azurerm_resource_group.dev or azurerm_key_vault.main, confirm your infrastructure.tf still has those resources defined.
  • If you see an authentication error, run az login again to refresh your session.
  • If prompted for var.subscription_id, you have not yet created your terraform.tfvars file. You will create it in the next step when you deploy. For now, enter your subscription ID at the prompt to confirm the plan works.

Help me fix my terraform plan error for role assignments

✔️ Awesome, I've got everything!

Great. Double check you have saved rbac.tf and that your plan ran without errors.

ⓧ I'd like to double check the full code

Here is the complete rbac.tf file at this point:

# Scope: Resource Group level - demonstrates scope hierarchy
resource "azurerm_role_assignment" "dev_rg_reader" {
  scope                = azurerm_resource_group.dev.id
  role_definition_name = "Reader"
  principal_id         = data.azurerm_client_config.current.object_id
}

# Scope: Key Vault resource - DATA PLANE access (can read secret values)
resource "azurerm_role_assignment" "kv_secrets_user" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = data.azurerm_client_config.current.object_id
}

# Scope: Key Vault resource - MANAGEMENT PLANE access (CANNOT read secrets)
resource "azurerm_role_assignment" "kv_contributor" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Contributor"
  principal_id         = data.azurerm_client_config.current.object_id
}

Your RBAC assignments are planned and ready to deploy. Next up, you will create a custom role definition with DataActions to enforce even more granular access controls on Key Vault keys.

Create a Custom Role with DataActions

Your built-in role assignments now demonstrate how Azure RBAC controls access at different scopes and planes.

Built-in roles cover common scenarios, but they can be too broad. What if you need a role that only allows backing up keys without decrypting or signing? In this step, you'll define a custom role using DataActions to enforce exactly that level of least-privilege access.

In this step, get ready to:
  • Define a custom "Key Vault Backup Operator" role using DataActions.
  • Assign the custom role at the Key Vault scope.
Add a custom role definition to rbac.tf

Azure lets you create custom role definitions when built-in roles grant more permissions than you need. Your custom role will allow only three specific key operations: reading, backing up, and restoring.

  • In rbac.tf, scroll to the bottom of the file (after the kv_contributor role assignment).
  • Add the custom role definition by copying this code below the existing role assignments:
# Custom Role Definition: Key Vault Backup Operator
resource "azurerm_role_definition" "kv_backup_operator" {
  name        = "Key Vault Backup Operator"
  scope       = data.azurerm_subscription.current.id
  description = "Can read and backup keys but cannot decrypt or sign. Demonstrates DataActions for data-plane access."

  permissions {
    data_actions = [
      "Microsoft.KeyVault/vaults/keys/read",
      "Microsoft.KeyVault/vaults/keys/backup/action",
      "Microsoft.KeyVault/vaults/keys/restore/action",
    ]
    not_data_actions = []
  }

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

What does this code do?

This defines a custom Azure role called "Key Vault Backup Operator" with tightly scoped permissions.

  • scope sets the subscription as the highest level where this role can be defined.
  • data_actions grants data-plane operations only. The three listed permissions allow reading keys, backing them up, and restoring them.
  • not_data_actions = [] means nothing is explicitly denied beyond what's already excluded by the allow-list.
  • assignable_scopes controls where this role can later be assigned (the current subscription and anything below it).

You won't see a change yet. The next chunk assigns this role to your Key Vault.

Assign the custom role at vault scope

A role definition on its own does nothing until you create a role assignment that binds it to a principal at a specific scope.

  • Still in rbac.tf, add this role assignment directly below the custom role definition you just added:
# Assign the custom role at vault scope
resource "azurerm_role_assignment" "kv_backup_operator" {
  scope              = azurerm_key_vault.main.id
  role_definition_id = azurerm_role_definition.kv_backup_operator.role_definition_resource_id
  principal_id       = data.azurerm_client_config.current.object_id
}

What does this code do?

This assigns your custom role to your own identity at the Key Vault scope.

  • role_definition_id references your custom role (not role_definition_name, which only works for built-in roles).
  • role_definition_resource_id is the Azure Resource Manager ID that Terraform outputs from the role definition.
  • The scope is the Key Vault itself, so this principal can only use the backup permissions on keys in this specific vault.
  • Save rbac.tf.
  • Confirm your configuration is syntactically correct by running:
terraform validate

You should see Success! The configuration is valid. in the output.

Seeing a validation error?

  • Check that your permissions block is nested inside the azurerm_role_definition resource, not outside it.
  • Make sure data_actions is inside the permissions block, not at the top level of the resource.
  • Verify each string in the data_actions list ends with a comma and the list itself is wrapped in square brackets.

Help me fix a terraform validate error in my custom role definition.

Understand why DataActions matter

Why DataActions instead of actions?

Azure RBAC splits permissions into two planes. The actions field controls the Azure Resource Manager (management plane). These operations create, delete, or configure vault resources.

The data_actions field controls what happens to the data inside the vault. Reading keys, backing up, decrypting, and signing are all data-plane operations.

A role with only actions and no data_actions cannot touch the vault's contents, even if it has wildcard management access. Your custom role uses only data_actions because key backup operations happen on the data plane.

✔️ Awesome, I've got everything!

Great. Double check you've saved rbac.tf and that terraform validate passes cleanly.

ⓧ I'd like to double check the full code

# Scope: Resource Group level - demonstrates scope hierarchy
resource "azurerm_role_assignment" "dev_rg_reader" {
  scope                = azurerm_resource_group.dev.id
  role_definition_name = "Reader"
  principal_id         = data.azurerm_client_config.current.object_id
}

# Scope: Key Vault resource - DATA PLANE access (can read secret values)
resource "azurerm_role_assignment" "kv_secrets_user" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = data.azurerm_client_config.current.object_id
}

# Scope: Key Vault resource - MANAGEMENT PLANE access (CANNOT read secrets)
resource "azurerm_role_assignment" "kv_contributor" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Contributor"
  principal_id         = data.azurerm_client_config.current.object_id
}

# Custom Role Definition: Key Vault Backup Operator
resource "azurerm_role_definition" "kv_backup_operator" {
  name        = "Key Vault Backup Operator"
  scope       = data.azurerm_subscription.current.id
  description = "Can read and backup keys but cannot decrypt or sign. Demonstrates DataActions for data-plane access."

  permissions {
    data_actions = [
      "Microsoft.KeyVault/vaults/keys/read",
      "Microsoft.KeyVault/vaults/keys/backup/action",
      "Microsoft.KeyVault/vaults/keys/restore/action",
    ]
    not_data_actions = []
  }

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

# Assign the custom role at vault scope
resource "azurerm_role_assignment" "kv_backup_operator" {
  scope              = azurerm_key_vault.main.id
  role_definition_id = azurerm_role_definition.kv_backup_operator.role_definition_resource_id
  principal_id       = data.azurerm_client_config.current.object_id
}

Your custom role is defined and assigned. Next up, you'll deploy the entire infrastructure and verify every role assignment in the Azure Portal.

Deploy and Verify in the Azure Portal

Your Terraform configuration is complete. You have infrastructure definitions, RBAC role assignments, and a custom role with DataActions all written as code.

Now it is time to deploy everything to Azure and verify that the access controls work as expected. You will use the Azure Portal's IAM blade to confirm each role assignment and inspect the custom role's permissions visually.

In this step, get ready to:
  • Deploy all resources to Azure with terraform apply.
  • Verify role assignments in the Azure Portal's Access control (IAM) blade.
  • Check the Activity Log for an audit trail of Terraform-created role assignments.
Deploy the infrastructure

Terraform needs your subscription ID to know where to create resources. The variables.tf file you created earlier declares this variable without a default, so you need to supply the value in a terraform.tfvars file.

  • Create a new file called terraform.tfvars in your azure-rbac-lab folder with the following content:
subscription_id = "[[SUBSCRIPTION_ID="your Azure subscription ID from az account show"]]"

Where do I find my subscription ID?

You noted this value in Step 1 when you ran az account show. It looks like a UUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.

  • Save terraform.tfvars.
  • Deploy all your resources by running this command:
terraform apply

Terraform displays a plan showing approximately 13 resources to create, then asks for confirmation.

  • Review the plan output. You should see resources for 2 resource groups, 1 Key Vault, 2 secrets, 5 role assignments, 1 custom role definition, 1 random string, and 1 time_sleep.
  • Type yes and press Enter to confirm the deployment.

Why does this take a while?

The time_sleep resource adds a 30-second pause after creating the Key Vault Administrator role assignment. This gives Azure time to propagate the RBAC permissions before Terraform tries to write secrets into the vault.

Without this pause, the secret creation would fail because your account would not yet have data-plane access to the vault.

  • Confirm the deployment succeeds. You should see a message that says Apply complete! Resources: 13 added, 0 changed, 0 destroyed. followed by your output values.

Seeing an authorization error?

If you see an error about insufficient permissions, confirm your Azure account has Owner or User Access Administrator permissions on the subscription. You can check this by running az role assignment list --assignee $(az ad signed-in-user show --query id -o tsv) and looking for one of those roles.

If you see a conflict error about a role assignment already existing, a previous partial run may have created some resources. Run terraform apply again and Terraform will reconcile the state.

Help me troubleshoot my terraform apply error

Verify role assignments in the Azure Portal

The Azure Portal gives you a visual way to confirm that your Terraform-managed RBAC assignments are live and correctly scoped.

  • Open the Azure Portal at portal.azure.com and sign in with the same account you used for az login.
  • In the search bar at the top, type Key vaults and select the Key vaults service.
  • Click on your Key Vault (its name starts with rbac-lab-kv- followed by a random suffix).
  • In the left navigation menu, click Access control (IAM).
  • Click the Role assignments tab.

You should see your role assignments listed here. Look for these four roles assigned to your account:

  • Key Vault Administrator (the self-assignment that lets Terraform write secrets).
  • Key Vault Secrets User (data-plane read access to secret values).
  • Key Vault Contributor (management-plane access that cannot read secrets).
  • Key Vault Backup Operator (your custom role with DataActions for key backup).

What about the Reader role?

The Reader role assignment is scoped to the dev resource group, not the Key Vault. You will not see it on this page because this IAM blade only shows assignments scoped to this specific Key Vault resource.

To verify the Reader assignment, you would navigate to the rbac-lab-dev-rg resource group and check its Access control (IAM) blade instead.

  • Click on the Key Vault Backup Operator role name to inspect its details.
  • Confirm that the permissions section shows only DataActions (not Actions). This proves the role operates exclusively on the data plane.

Why only DataActions?

Regular Actions control the Azure Resource Manager (management plane). They let you create, delete, or configure the vault resource itself.

DataActions control what happens to the data inside the vault. Reading keys, backing them up, and restoring them are all data-plane operations. Your custom role only uses DataActions, so it cannot modify the vault's configuration at all.

Check the Activity Log

Azure records every role assignment operation in the Activity Log. This creates an audit trail showing exactly when each role was assigned and by whom.

  • In the Key Vault's left navigation menu, click Activity log.
  • In the Timespan filter at the top, select Last hour to narrow the results.
  • Look for operations with the name Create role assignment. You should see multiple entries corresponding to the role assignments Terraform created.

Each entry shows the timestamp, the caller (your Azure identity), and the resource affected. This is how security teams audit access changes in production environments.

Filter for clarity

If you see too many entries, use the Resource type filter and select Role assignments to isolate only the RBAC operations.

Don't see any Activity Log entries?

Activity Log entries can take a few minutes to appear after deployment. Wait 2-3 minutes and refresh the page.

Also confirm your Timespan filter covers the time when you ran terraform apply. If you deployed more than an hour ago, expand the filter to Last 6 hours.

Help me find my role assignment entries in the Activity Log

Your entire RBAC configuration is now live and verified. Next up, you will take on a Secret Mission to scope a role assignment to an individual secret for the most granular access control Azure offers.

Secret mission

Scope a Role to an Individual Secret

So far, every role assignment you've created targets an entire resource (a resource group or the whole Key Vault). But Azure RBAC for Key Vault supports something most practitioners never use: scoping access down to an individual secret, key, or certificate. In this mission, you'll lock a role assignment to a single secret.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project's Azure resources cost near zero. RBAC role assignments are free, and the Key Vault Standard tier charges roughly $0.03 per 10,000 operations.

Resources you used:

  • 2 Azure Resource Groups (rbac-lab-dev-rg and rbac-lab-prod-rg).
  • 1 Azure Key Vault (Standard tier, RBAC-enabled).
  • 2 Key Vault Secrets (db-connection-string and external-api-key).
  • 6 Role Assignments (including the secret-scoped assignment from the Secret Mission).
  • 1 Custom Role Definition (Key Vault Backup Operator).
  • Local project directory azure-rbac-lab with 5 Terraform configuration files.

Keep everything running

No action needed. Choose this if you're still actively experimenting with RBAC configurations or want to try additional role assignments.

  • Your Azure resources remain deployed. Costs stay near zero since there are no compute resources running.
  • Your local azure-rbac-lab directory stays on your Desktop with all Terraform files intact.
  • You can continue modifying rbac.tf and running terraform apply to test new role assignments.

Pause - I'll come back to this later

This project has no running compute resources to pause. The Key Vault and role assignments sit idle at near-zero cost.

  • There is nothing to stop or shut down. Your resources remain deployed but generate virtually no charges while idle.
  • If you want to reduce cost to absolute zero, follow the Delete tab instructions to remove Azure resources, but keep your local azure-rbac-lab folder. You can redeploy anytime with terraform apply.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes every Azure resource Terraform created and removes your local project files.

  • Destroy all Azure resources by running this command from inside your azure-rbac-lab directory:
terraform destroy
  • When prompted, type yes and press Enter to confirm.
  • Wait for the destroy to complete. Terraform removes all role assignments, the custom role definition, the Key Vault secrets, the Key Vault itself, and both resource groups.

What about the Key Vault after deletion?

The Key Vault enters a soft-delete state for 7 days, then Azure purges it automatically. Because purge_protection_enabled was set to false in your configuration, no manual purge step is needed.

  • Delete the local project files. On macOS or Linux, run:
cd ~/Desktop
rm -rf azure-rbac-lab
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force azure-rbac-lab
  • Confirm the directory is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see azure-rbac-lab in the output.

Nice Work!

Nice Work!

Nice work! You just built a production-style Azure environment with layered RBAC controls and a custom role, all managed as Infrastructure as Code with Terraform.

You've learned how to:

  • Provision a multi-environment Azure infrastructure with an RBAC-secured Key Vault entirely in Terraform.
  • Demonstrate the data-plane vs. control-plane distinction by assigning built-in roles at different scopes and proving that Key Vault Contributor cannot read secrets.
  • Create a custom role definition using DataActions to enforce least-privilege key backup access.
  • Secret Mission: Scoped a role assignment to an individual secret for the most granular access control possible.

Ready to quiz yourself?